编码 · 数据结构
二叉堆详解:优先队列、代码与时间复杂度
简短回答
二叉堆是存放在数组中的完全二叉树,其中每个父节点都小于或等于它的孩子(最小堆),或大于或等于它的孩子(最大堆)。最小的元素始终在下标 0,因此读取它只需 O(1),插入或移除它需要 O(log n)。堆是实现优先队列的标准方式;在 Python 中使用 heapq 模块。
堆解决什么问题
许多程序需要不断从一个变化的集合中取出最紧急的元素:模拟中的下一个事件、Dijkstra 算法中最近的未访问节点、优先级最高的任务,或数据流中最大的 k 个值。每次插入后都对整个集合排序,每次要花 O(n log n)。有序列表让移除变得便宜,但插入是 O(n)。堆让插入和移除最小值都是 O(log n),这正是优先队列所需要的。
思路:把树存放在数组里
堆是一棵完全二叉树:除最后一层外每一层都是满的,最后一层从左到右填充。这样的形状让我们可以用一个普通列表存储这棵树,不需要任何指针。
| 关系 | 下标公式(从 0 开始) |
|---|---|
| i 的左孩子 | 2 * i + 1 |
| i 的右孩子 | 2 * i + 2 |
| i 的父节点 | (i - 1) // 2 |
两种修复操作用来维持堆性质。上浮:在末尾追加新元素后,只要它比父节点小,就与父节点交换。下沉:把最后一个元素移到根部后,只要它比较小的那个孩子大,就与该孩子交换。每个操作都沿着一条从根到叶子的路径移动,所以最多大约做 log2(n) 次交换。
示例演练:push 和 pop
从最小堆 [2, 5, 3, 9, 6] 开始(2 是根,它的孩子是 5 和 3,5 的孩子是 9 和 6)。
- Push 1:把它追加到末尾,得到
[2, 5, 3, 9, 6, 1]。下标 5 的父节点是下标 2(值为 3)。 - 1 比 3 小,交换:
[2, 5, 1, 9, 6, 3]。现在下标 2 的父节点是下标 0(值为 2)。 - 1 比 2 小,交换:
[1, 5, 2, 9, 6, 3]。它到达了根部,停止。 - Pop:移除根 1,把最后一个元素 3 移到根部:
[3, 5, 2, 9, 6]。 - 根的孩子是 5 和 2,较小的是 2,而 3 比它大,交换:
[2, 5, 3, 9, 6]。下标 2 没有孩子,停止。这次 pop 返回了 1。
用 Python 实现最小堆
这个类演示了其中的机制。实际代码中请使用 heapq,见下一节。
class MinHeap:
def __init__(self):
self.a = []
def push(self, x):
a = self.a
a.append(x)
i = len(a) - 1
while i > 0 and a[i] < a[(i - 1) // 2]: # sift up
p = (i - 1) // 2
a[i], a[p] = a[p], a[i]
i = p
def pop(self):
a = self.a
top, last = a[0], a.pop() # IndexError if empty
if a:
a[0], i = last, 0
while True: # sift down
c = 2 * i + 1
if c + 1 < len(a) and a[c + 1] < a[c]:
c += 1 # pick the smaller child
if c >= len(a) or a[i] <= a[c]:
break
a[i], a[c] = a[c], a[i]
i = c
return top
h = MinHeap()
for x in [5, 3, 9, 1, 6, 2]:
h.push(x)
print([h.pop() for _ in range(6)]) # [1, 2, 3, 5, 6, 9]用 Python heapq 实现优先队列
heapq 模块作用于普通列表,并始终把它维持为最小堆。heapify 能在 O(n) 时间内原地把现有列表变成堆。要使用优先级,就压入元组:Python 会逐个元素比较元组,所以第一个元素就是优先级。再加一个计数器作为决胜项,这样优先级相同的元素就不需要直接比较。
import heapq
from itertools import count
nums = [7, 2, 9, 4, 1]
heapq.heapify(nums)
print(nums[0], heapq.heappop(nums)) # 1 1
print(heapq.nlargest(2, [7, 2, 9, 4])) # [9, 7]
tasks, order = [], count()
heapq.heappush(tasks, (2, next(order), "write report"))
heapq.heappush(tasks, (1, next(order), "fix outage"))
heapq.heappush(tasks, (2, next(order), "reply to email"))
while tasks:
priority, _, name = heapq.heappop(tasks)
print(priority, name)
# 1 fix outage
# 2 write report
# 2 reply to email对于最大堆,Python 3.14 新增了 heapq.heapify_max、heapq.heappush_max 和 heapq.heappop_max。在旧版本中,常用的技巧是压入取负的键,例如 heapq.heappush(h, -score),弹出时再取负。
时间与空间复杂度
| 操作 | 时间 | 原因 |
|---|---|---|
| 查看最小值 | O(1) | 它始终在下标 0 |
| Push(插入) | O(log n) | 沿一条路径上浮 |
| Pop 最小值 | O(log n) | 沿一条路径下沉 |
| 对 n 个元素建堆 | O(n) | 大多数节点靠近底部,只需移动一点 |
| 查找任意值 | O(n) | 堆没有排序 |
| 堆排序 | O(n log n) | 先建堆,再弹出 n 次 |
| 空间 | O(n) | 只有一个数组,没有指针 |
调用 n 次 push 来建堆需要 O(n log n)。heapify 从最后一个父节点开始向根部逐个下沉,只需 O(n),所以已经拿到全部元素时请优先使用它。
变体与常见错误
- 最大堆:代码相同,只是反转比较方向;在 Python 中,使用
_max系列函数(3.14 及以上),或把键取负。 - d 叉堆:每个节点有 d 个孩子。树更矮,使 push 更便宜,pop 则稍贵一些。
- 带索引的优先队列 / decrease-key:
heapq无法原地修改某个元素的优先级。常见的变通办法(用于 Dijkstra 算法)是压入一个新条目,并在弹出过期条目时跳过它们。 - 比较不可比较的元素:压入
(priority, dict)时,若两个优先级相等就会抛出TypeError,因为 Python 接着会去比较字典。请把计数器作为元组的第二个元素。 - 把堆列表当作有序的。只有
heap[0]有保证;如果需要全部有序,请使用sorted(heap)。 - 把教科书中从 1 开始的公式(孩子为
2i和2i + 1)与从 0 开始的 Python 列表混用,造成差一错误。
何时使用堆,以及典型问题
当你不断只需要一个持续变化的集合中的最小或最大元素时,请使用堆。如果还需要有序遍历或任意删除,请使用有序列表或平衡树;如果只需要一次最小值,直接用 min() 即可。
经典用途:Dijkstra 最短路径和 Prim 最小生成树、合并 k 个有序列表(heapq.merge)、找出最大或最小的 k 个元素(维护一个大小为 k 的堆,O(n log k))、滚动中位数(两个堆)、任务调度器和事件驱动的模拟,以及堆排序。
常见问题
堆和优先队列有什么区别?
优先队列是一种抽象数据类型:插入元素,并移除优先级最高的那个。二叉堆是实现它最常用的数据结构。
Python 的 heapq 是最小堆还是最大堆?
heappush 和 heappop 等主要函数维护的是最小堆,所以 heappop 返回最小的元素。Python 3.14 为最大堆新增了单独的 _max 函数;在旧版本中,请存储取负后的数字。
为什么建堆是 O(n) 而不是 O(n log n)?
建堆从底部开始依次下沉每个父节点。大约一半的节点是叶子,不需要移动;四分之一的节点最多移动一层,依此类推;总工作量加起来是 O(n)。
二叉堆和二叉搜索树一样吗?
不一样。BST 对左右子树排序,因此可以在 O(log n) 内查找任意键。堆只保证每个父节点小于它的孩子,所以能快速找到最小值,而查找其他键是 O(n)。
堆排序是稳定的吗?
不是。下沉时的交换可能改变相等键的相对顺序。它原地排序,时间 O(n log n),额外空间 O(1)。