1. 什么是堆?
堆是一种完全二叉树,堆中每一个节点的值都必须大于等于(或者小于等于)子树中每一个节点的值。
完全二叉树要求,除了最后一层,每一层的节点数都是满的,最后一层的节点都是靠左排列。
对于每一个节点的值都大于等于其子树中每一个节点的值的堆,叫大顶堆;对于每一节点的值都小于等于其子树中每一节点的值的堆,叫小顶堆。
看几个例子:
上图中,①是大顶堆②是大顶堆③是小顶堆④不是堆因为他不是完全二叉树。
2.堆的存储方式
完全二叉树比较适合用数组来存储,相比链表法存储非常节省空间。所以对于堆,也非常适合用数组来存储。
下图是用数组存储堆的例子
堆顶元素存储在数组下标为i=0的位置,左子节点存储在数组下标为i=1的位置,右子节点存在下标为i=2的位置。总结下来,假设堆中某个元素存储在数组下标i的位置,那这个元素的左子节点就存在数组下标为2i+1的位置,右子节点就存在数字下标为2i+2的位置,父节点就是存在i/2的位置。
3.往大顶堆中插入一个元素
往堆中插入一个元素后,我们需要继续满足堆的两个特性。
我们可以让新插入的节点先放在数组最后,然后将其与父节点对比大小。如果不满足子节点小于等于父节点的大小关系,我们就互换两个节点。一直重复这个过程,直到父子节点之间的大小关系满足大顶堆的要求。
https://www.cnblogs.com/MasterMonkInTemple/p/11363445.html
class Heap:
def __init__(self):
"""
用数组存储,从下标为0处开始存
假设堆中某个节点在数组中的下标是index,则:
1. 它的父节点在数组中的下标是index//2,
2. 它的左子节点在数组的下标是2*index+1
3. 它的由子节点在数组的下标是2*index+2
"""
self.items = []
def insert(self, data):
"""
插入元素到堆
将元素先放到数组最后,然后从下往上堆化
"""
index = len(self.items) # 以下两行顺序不能颠倒
self.items.append(data)
# 开始从下往上堆化
while index != 0:
parent_index = index // 2
if self.items[index] > self.items[parent_index]:
self.items[index], self.items[parent_index] = self.items[parent_index], self.items[index]
index = parent_index
if __name__ == '__main__':
heap = Heap()
heap.insert(9)
heap.insert(10)
heap.insert(7)
heap.insert(12)
heap.insert(3)
heap.insert(4)
print(heap.items)
4.删除大顶堆顶元素
把最后一个节点放到堆顶,然后利用同样的父子节点对比方法。对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止。这就是从上往下的堆化方法。
class Heap:
def __init__(self):
"""
用数组存储,从下标为0处开始存
假设堆中某个节点在数组中的下标是index,则:
1. 它的父节点在数组中的下标是index//2,
2. 它的左子节点在数组的下标是2*index+1
3. 它的由子节点在数组的下标是2*index+2
"""
self.items = []
def insert(self, data):
"""
插入元素到堆
将元素先放到数组最后,然后从下往上堆化
"""
index = len(self.items) # 以下两行顺序不能颠倒
self.items.append(data)
# 开始从下往上堆化
while index != 0:
parent_index = index // 2
if self.items[index] > self.items[parent_index]:
self.items[index], self.items[parent_index] = self.items[parent_index], self.items[index]
index = parent_index
def remove(self):
"""
删除队顶元素
将最后一个元素放在堆顶,再从上往下依次堆化
"""
removed_data = self.items[0]
self.items[0] = self.items[-1]
del self.items[-1]
# 从上往下堆化
self.heapify(0)
return removed_data
def heapify(self, index):
"""
从index开始,由上往下依次堆化
"""
max_index = len(self.items) - 1
while True:
parent_index = index
left_child_index = 2 * index + 1
right_child_index = 2 * index + 2
if left_child_index <= max_index and self.items[left_child_index] > self.items[parent_index]:
parent_index = left_child_index
if right_child_index <= max_index and self.items[right_child_index] > self.items[parent_index]:
parent_index = right_child_index
if parent_index == index: # 前面两个if都不成立,才会到这里
break
self.items[index], self.items[parent_index] = self.items[parent_index], self.items[index]
index = parent_index
if __name__ == '__main__':
heap = Heap()
heap.insert(9)
heap.insert(10)
heap.insert(7)
heap.insert(12)
heap.insert(3)
heap.insert(4)
print(heap.items)
print(heap.remove())
print(heap.items)
一个包含 n 个节点的完全二叉树,树的高度不会超过 log2n。堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是 O(logn)。插入数据和删除堆顶元素的主要逻辑就是堆化,所以,往堆中插入一个元素和删除堆顶元素的时间复杂度都是 O(logn)。
5.堆排序
排序的过程大致分解成两个大的步骤,建堆和排序。
下面图示展示了数组[7, 5, 19, 8, 4, 1, 20, 13, 16]的建堆过程。调整节点与左右子节点大小关心,让其满足堆的特性,这个过程就叫作堆化(heapify)。建堆,就是不断对节点进行堆化的过程。
因为堆是完全二叉树,而对于完全二叉树来说,下标从 n/2到 n-1 的节点都是叶子节点,而叶子节点是不需要堆化的,我们只需要对下标从 n/2-1 开始到 0 的数据进行堆化。见下面代码中heapify函数。
def heapify(arr, n, i): # 堆化以下标i为根的子树
"""
从下标i开始堆化数组arr。
:param arr: 待堆化的数组
:param n: 数组长度
:param i: 堆顶下标
:return:
"""
while True:
max_pos = i
left = 2 * i + 1
right = 2 * i + 2
if left < n and arr[left] > arr[i]:
max_pos = left
if right < n and arr[right] > arr[max_pos]:
max_pos = right
if max_pos == i: # 前两个if不成立时,不用堆化了
break
arr[i], arr[max_pos] = arr[max_pos], arr[i] # 交换
i = max_pos
def build_heap(arr, n): # 建堆,构建大顶堆
for i in range(n // 2, -1, -1):
heapify(arr, n, i)
建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的下标为0的元素就是堆顶,也就是最大的元素。我们把它跟最后一个元素交换,那最大元素就放到了下标为 n-1 的位置。然后再通过堆化的方法,将剩下的 n−1 个元素重新构建成堆。
def heap_sort(arr):
n = len(arr)
build_heap(arr, n)
for i in range(n - 1, 0, -1):
arr[0], arr[i] = arr[i], arr[0] # 交换堆顶元素和最后一个元素
heapify(arr, i, 0)
arr = [7, 5, 19, 8, 4, 1, 20, 13, 16, 21]
build_heap(arr, 10)
print(arr)
heap_sort(arr)
print(arr)
整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法。堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),所以,堆排序整体的时间复杂度是 O(nlogn)。
6. 为什么快速排序要比堆排序性能好?
实际的应用开发中,很少有人使用堆排序,而是更多的采用快速排序。主要有以下两点原因:
6.1 堆排序数据访问不友好
对于堆排序,访问数组元素是跳着访问的。比如,下图对堆顶元素进行堆化时,要一次访问下标为1,2,4,8的元素。而快速排序时,对数组的访问是顺序访问的。因此快速排序对CPU的缓存更友好。
6.2 堆排序的交换次数过多
前面排序算法的章节中,介绍了基于比较的排序算法,最大的交换次数是逆序度。而堆排序的第一步建堆的过程中,会打乱数组的有序度。从而增加了逆序度。比如,对于一组已经有序的数据来说,经过建堆之后,数据反而变得更无序了。比如下图:
10.Pyhton的heapq模块
10.1 创建堆
import heapq
li = [7, 5, 19, 8, 4, 1, 20, 13, 16, 21]
heap = []
print(li)
for i in li:
heapq.heappush(heap, i)
print(heap)
heapq.heapify(li)
print(li)
10.2 合并多个的序列使之有序
l1 = [23, 2, 12, 656, 324, 23, 54]
l2 = [7, 5, 19, 8, 4, 1, 20, 13, 16, 21]
sl1 = sorted(l1)
sl2 = sorted(l2)
print(sl1, sl2)
sl = heapq.merge(sl1, sl2)
for i in sl:
print(i)
10.3 堆排序
l3 = [7, 5, 19, 8, 4, 1, 20, 13, 16, 21]
def heap_sort(nums):
heapq.heapify(nums)
return [heapq.heappop(l3) for _ in range(len(l3))]
print(heap_sort(l3))
10.4 获取堆中最大或最小值
l4 = [7, 5, 19, 8, 4, 1, 20, 13, 16, 21]
profile = [
{'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}
]
n_largest = heapq.nlargest(3, l4) # 相当于sorted(iterable, reverse=True)[:n]
print(n_largest)
n_smallest = heapq.nsmallest(3, l4) # 相当于sorted(iterable, reverse=False)[:n]
print(n_smallest)
n_largest_profile = heapq.nlargest(3, profile, key=lambda x: x['shares']) # 相当于sorted(profile, key=lambda x: x['shares'], reverse=True)[:3]
print(n_largest_profile)
10.5 更换堆顶元素
l5 = [7, 5, 19, 8, 4, 1, 20, 13, 16, 21]
heapq.heapify(l5)
heapq.heapreplace(l5, 50) # 相当于删除堆中最小元素并加入一个新元素
print(l5)
10.1 实现优先级队列
优先级队列的特点:
给定一个优先级(Priority)
每次pop操作都会返回一个拥有最高优先级的项。
class MyPriorityQueue:
def __init__(self):
self._index = 0 # 指针用于记录push的次序
self.queue = [] # 创建一个空列表用于存放队列
def push(self, priority, value):
"""队列由(priority, index, item)形式的元组构成"""
heapq.heappush(self.queue, (-priority, self._index, value)) # 根据-priority的值建堆,heapq构建的是小顶堆,所以要加负号
self._index += 1 # 如果优先级一样,则按index
def pop(self):
return heapq.heappop(self.queue)[-1] # 返回拥有最高优先级的项
pq = MyPriorityQueue()
pq.push(5, "foo")
pq.push(1, "bar")
pq.push(3, "spam")
pq.push(1, "grok")
for i in range(4):
print(pq.queue)
print(pq.pop())
从输出中可以看到,pop()是每次返回一个拥有最高优先级的项。对于拥有相同优先级的项(bar和grok),会按照被插入队列的顺序来返回。代码的核心是利用heapq模块,之前已经说过,heapq.heappop()会返回最小值项,因此需要把 priority 的值变为负,才能让队列将每一项按从最高到最低优先级的顺序级来排序。
其实Python提供了现成的优先级队列,就是queue.PriorityQueue类。相比我自己实现的优先级队列,PriorityQueue的操作是同步的,提供锁操作,支持并发的生产者和消费者。
10.2 Top K问题
求 Top K 的问题抽象成两类:一类是从静态数据集合中求TopK,一类是从动态集合中求TopK。
静态集合数据求Top K,指的是在数据结合不变的情况下,求出Top K。可以先通过排序,在用切片操作。例如借助sorted(iterable, key=key, reverse=True)[:n],也可以通过heapq.nlargest或者heapq.nsmallest方法。
这是针对数据量比较小的情况下,如果数据量是海量的数据,这种方法将导致内存不够用。
从海量数据中找Top K,可以维护一个包含K个元素的小顶堆,最开始将海量数据中的前K个元素放入堆中,K+1个以后的元素,逐个与堆顶比较,若大于堆顶,则入堆,然后删除堆顶;依此往复,直至扫描完所有元素。无论任何时候需要查询当前的前 K 大数据,我们把堆中数据返回即可。
动态集合求Top K,指的是数据集合中一边在添加数据,另一边实时询问当前的Top K 。求解办法一样。
下面看代码实现:
def top_k(nums, k):
if inputs is None or len(nums) < k or len(nums) <= 0 or k <= 0: # 注意极限条件的确定
return []
output = []
for number in nums:
# 先用前k个元素生成一个小顶堆,这个小顶堆用于存储当前最大的k个元素。
if len(output) < k:
heapq.heappush(output, number)
# 从第k+1个元素开始扫描,和堆顶(堆中最小的元素)比较
else:
if number > output[0]: # 如果被扫描的元素大于堆顶,则替换堆顶的元素
heapq.heapreplace(output, number) # heapreplace有替换并调整堆的功效,以保证堆内的k个元素,总是当前最大的k个元素。
return output[::-1]
inputs = [4, 5, 1, 6, 2, 7, 10, 3, 8]
print(top_k(inputs, 3))
10.3 中位数问题
中位数是有序列表中间的数。如果列表长度是偶数,中位数则是中间两个数的平均值。
例如,
[2,3,4] 的中位数是 3
[2,3] 的中位数是 (2 + 3) / 2 = 2.5
对于数据量比较小的数据集,求中位数,最简单的办法是先排序在索引。比如,
L = [0, 1, 5, 6, 2, 3, 4]
l = len(L) # 数出列表中有几个元素,将个数放到l里
L.sort() # 将列表按升序排列
if l % 2 == 0: # 如果有偶数个整数
m = (L[int(l // 2) - 1] + L[int(l // 2)]) / 2 # 计算中间两个的平均值,存到m里
print("%.1f" % m)
else:
m = L[int(l // 2)] # 将中间那个整数的值存到m里
print(m) # 直接打印m
如果是从数据流中,实时获取数据的中位数,则需要借助堆,利用两个堆来实现,一个大顶堆(利用取相反数来实现),一个小顶堆。
用一个大顶堆和一个小顶堆来维护数据,每次每个数进来,先把它丢进小顶堆,然后把小顶堆的堆顶丢进大顶堆,调整两个堆,使得size 差最大为1。
这么搞的好处在于,小顶堆是数据流里前一半大的数,大顶堆是数据流里后一半的大的数,
而且小顶堆的size一定 >= 大顶堆的size,小顶堆的堆顶M是小顶堆里最小的数,大顶堆的堆顶N是大顶堆里最大的数,如果两个堆的size相同,那么中位数就是return (M + N) / 2.0 ,否则,return M / 1.0。
min_h = []
heapq.heapify(min_h)
max_h = []
heapq.heapify(max_h)
# 构建大顶堆和小顶堆
def add_num(num):
heapq.heappush(min_h, num) # 先放入小顶堆
heapq.heappush(max_h, -heapq.heappop(min_h)) # 把小顶堆堆顶放入大顶堆
if len(max_h) > len(min_h): # 维持小顶堆比大顶堆数据多一个
heapq.heappush(min_h, -heapq.heappop(max_h))
# 输出中位数
def find_median(num):
add_num(num)
print(min_h)
print(max_h)
if len(min_h) == len(max_h): # 大顶堆和小顶堆数据一样多
return (min_h[0] + (-max_h[0])) / 2
else: # 小顶堆对大顶堆多一个数据,返回小顶堆堆顶元素
return min_h[0]
# 随着不断加入数据,可以输出中位数
print(find_median(1))
print(find_median(2))
print(find_median(3))
print(find_median(4))
print(find_median(5))
11.参考资料
https://www.geeksforgeeks.org/heap-queue-or-heapq-in-python/
https://docs.python.org/3.8/library/heapq.html
https://docs.python.org/zh-cn/3/library/heapq.html
https://cloud.tencent.com/developer/article/1414361
https://dbafu.github.io/2017/12/25/Heap-python-data-structure-and-its-realization/