树这一章真是又臭又长,这一节从优先队列着手,学习堆的概念。
优先队列
队列是一种FIFO(First-In-First-Out)先进先出的数据结构,优先队列(Priority Queue)是特殊的队列,从“优先”一词,可看出有“插队现象”,取出元素的顺序是依照元素的 优先权(关键字)。
优先队列有两种特殊的操作:删除最大元素和插入元素。
我们来看一下优先队列的实现方案,元素插入时如果不处理O(1),取出最大元素时就需要查找O(n);元素插入时如果要按序存储O(n),取出元素时就可O(1)。
堆
优先队列的实现常选用二叉堆,在数据结构中,优先队列一般也是指堆。
我们来认识一下堆:
- 二叉堆是一种特殊的堆,二叉堆是完全二叉树或者是近似完全二叉树。
- 堆序性:由于我们想很快找出最小元,则最小元应该在根上,任意节点都小于它的后裔,这就是小顶堆(Min-Heap);如果是查找最大元,则最大元应该在根上,任意节点都要大于它的后裔,这就是大顶堆(Max-heap)。也可以称为最大堆、最小堆。
- 由完全二叉树的性质可知,我们可以用一个数组来表示堆,不需要去建树。
我们来回忆一下完全二叉树的性质:(假设一棵完全二叉树,结点编号为1~n。)
- 子节点计算:对于i结点,若i结点有左孩子,则编号为2i,若i结点有右孩子,则编号为(2i+1);
- 父结点计算:除根结点外,i结点的父结点编号为i/2(向下取整);
- 对于该完全二叉树,若i ≤ n/2(向下取整),则i结点为分支结点,否则为子结点。编号最大的分支结点是n/2向下取整。
堆的基本操作
首先看一下元素如何插入:当我们需要插入一个元素时,总是把它加到数组末尾(最后一个叶子节点),然后调整到合适的位置。
以最大堆为例,当堆的结构被破坏,某个节点(如插入的结点)大于其父节点时,我们就需要通过交换它和它的父节点来修复堆,这个操作称为“上浮(swim)”。通过循环不断交换当前节点与父节点直到其键值不大于父节点为止。
这一操作在代码中仅仅体现为 : k 结点大于其父, 交换 k,k/2; 再令k = k/2,直到堆有序。需要注意的是,上文提到的完全二叉树的性质中,结点编号从1开始,这跟数组中的索引是不同的。
相反,当某个节点的键值小于其子节点的键值时,我们要交换它和它的较大子节点来修复堆,直到堆有序。这个操作称为“下沉(sink)”。
堆中元素的删除,一般是指删除堆的根结点,也就是最大堆删除堆中最大值,最小堆删除堆中最小值。
当我们要删除根节点时,与最后一个叶子节点交换,然后使新的根节点下沉到合适的位置,并将最后一个叶子节点置空。
算法复杂度分析:
大小为N的完全二叉树的高度小于等于logN,所以对于二叉堆实现优先队列来说,从代码层面来看插入和删除操作也显而易见,算法复杂度为 O(logN).
参考博客:
python 实现
补充一下,从堆的原理也可理解堆排序的算法,堆的相关问题,其实都可以认为是堆排序的应用,但是我们还是到讲排序的时候再学一学。
我们先看一下 自己如何实现二叉堆:
import copy
class MaxHeap(object):
def __init__(self):
self.data = [] # 创建堆 #
self.count = len(self.data) # 元素数量
def init(self, arr):
self.data = copy.copy(arr)
self.count = len(self.data)
i = self.count // 2 # 从倒数第二层向上 检查堆的性质
while i >= 1:
self.sink(i)
i -= 1
def size(self):
return self.count
def is_empty(self):
return self.count == 0
def insert(self, item): # 插入元素入堆
self.data.append(item)
self.count += 1
self.swim(self.count)
def swim(self, pos):
# 注意pos 是完全二叉树中的编号,而不是索引
# 将插入的元素放到合适位置,保持最大堆
while pos > 1 and self.data[(pos // 2) - 1] < self.data[pos - 1]:
self.data[(pos // 2) - 1], self.data[pos - 1] = self.data[pos - 1], self.data[(pos // 2) - 1]
pos //= 2
def sink(self, pos):
# 注意pos 是完全二叉树中的编号,而不是索引
# 将堆的索引位置元素向下移动到合适位置,保持最大堆
while 2 * pos <= self.count:
# 证明有孩子
j = 2 * pos
if j + 1 <= self.count:
# 证明有右孩子
if self.data[j] > self.data[j-1]: # 选择左右孩子中值较大的
j += 1
if self.data[pos - 1] >= self.data[j - 1]:
# 堆的索引位置已经大于两个孩子节点,不需要交换了
break
self.data[pos - 1], self.data[j - 1] = self.data[j - 1], self.data[pos - 1]
pos = j
def top_max(self): # 出堆
if self.count > 0:
ret = self.data[0]
self.data[0], self.data[self.count-1] = self.data[self.count-1], self.data[0]
self.data.pop()
self.count -= 1
self.sink(1)
return ret
else:
return None
if __name__ == '__main__':
heap = MaxHeap()
heap.init([4,2,7,13,6,1,20,15,3,11])
print(heap.data)
heap.insert(10)
heap.insert(28)
print(heap.data)
print(heap.top_max())
print(heap.top_max())
print(heap.top_max())
print(heap.data)
主要的代码还是sink和swim的操作,也并不复杂,虽然工具包提供了相关算法,还是建议亲自手撕一下,关注一些细节,比如完全二叉树在这个问题上的应用。
接下来介绍Python中已有的 heapq,该工具最重要的一点是,堆本身,是作为参数传进去的,heapq只提供操作方法。我们可以看这几篇博客,也包括了堆排序的实现。
文章其实都是在介绍heapq的使用,其实,建堆的过程就是在堆排序。heapq提供了建堆出堆的相关方法,让一个list对象符合堆的性质。我们先看heapq提供的函数:
>>> heapq.__all__
['heappush', 'heappop', 'heapify', 'heapreplace', 'merge', 'nlargest', 'nsmallest', 'heappushpop']
这些函数的功能及接口大致如下,从heappop可以看出,heapq实现的是一个最小堆。
- heappush(heap, item):将 item 元素加入堆。
- heappop(heap):将堆中最小元素弹出。
- heapify(heap):将堆属性应用到列表上。
- heapreplace(heap, x):将堆中最小元素弹出,并将元素x 入堆。
- merge(*iterables, key=None, reverse=False):将多个有序的堆合并成一个大的有序堆,然后再输出。
- heappushpop(heap, item):将item 入堆,然后弹出并返回堆中最小的元素。
- nlargest(n, iterable, key=None):返回堆中最大的 n 个元素。
- nsmallest(n, iterable, key=None):返回堆中最小的 n 个元素。
我们简单看一下各函数使用栗子:
from heapq import *
# 建堆的两种方法
data = [1,5,3,2,8,5]
heap = []
# 一个一个push
for n in data:
heappush(heap, n)
# 或者这样
heapify(data)
#####################################
# merge 合并多个有序堆
a = [4, 7, 11]
b = [1, 5, 8]
c = merge(a, b)
print(list(c)) # [1, 4, 5, 7, 8, 11]
######################################
heap = []
# 向堆中依次增加数值 heap一直是堆
heappush(heap, 8)
heappush(heap, 5)
heappush(heap, 13)
print(heap) # [5, 8, 13]
# 取出最小值
print(heappop(heap)) # 5
# 如果采用其他方法操作heap, 就会破坏堆的性质
heap.append(2)
print(heappop(heap)) # 8
###################################
data = [1,5,3,2,8,5]
print(nlargest(3, data))
print(nsmallest(3, data))
# 这个两个函数还接受一个关键字参数key, 用于更加复杂的数据结构中。
portfolio = [
{'name': 'IBM', 'shares': 100, 'price': 91.1},
{'name': 'AAPL', 'shares': 50, 'price': 543.22},
{'name': 'FB', 'shares': 200, 'price': 21.09},
{'name': 'HPQ', 'shares': 35, 'price': 31.75},
{'name': 'YHOO', 'shares': 45, 'price': 16.35},
{'name': 'ACME', 'shares': 75, 'price': 115.65}
]
# 对每个元素进行比较时,会以price的值进行比较。
cheap = nsmallest(3, portfolio, key=lambda s: s['price'])
expensive = nlargest(3, portfolio, key=lambda s: s['price'])
##############################################
# 由于堆中的元素可以为元组,所以可以对带权值的元素进行排序。
h = []
heappush(h, (5, 'write code'))
heappush(h, (7, 'release product'))
heappush(h, (1, 'write spec'))
heappush(h, (3, 'create tests'))
heappop(h)
'''
(1, 'write spec')
'''
##################################
'''
heappushpop(heap, item) 是heappush和heappop的合体,同时完成两者的功能.
注意:相当于先操作了heappush(heap,item),然后操作heappop(heap)
'''
h = [1, 2, 9, 5]
print(heappop(h)) # 1
print(heappushpop(h, 4)) # 2
print(h) # [4, 5, 9]
'''
heapreplace(heap, item) 是heappop和heappush的联合操作。
注意,与heappushpop(heap,item)的区别在于,顺序不同,这里是先进行删除,后压入堆
'''
print(heapreplace(h, 2)) # 4
print(h) # [2, 5 ,9]
这一工具基本上提供了堆的操作,使用方便。但是可以看出,需要外部维护一个list 作为堆本身,有破坏堆性质的危险;所以在复杂的使用场景中,我们一般可以利用heapq工具封装一个堆的class,再进行各种操作比较安全,使用栗子可以看后面的一些题目。