摘要
优先队列不按照元素进入顺序处理数据,而是优先处理优先级最高或权值最小的元素。堆是实现优先队列的常用数据结构,可以在较低成本下完成插入、查看最优元素和删除最优元素。
本文介绍最小堆、最大堆、堆的上浮与下沉、Python 的 heapq,并通过 Top K、合并有序序列、任务调度和 Dijkstra 算法示例,理解堆在算法和工程中的应用。
一、背景与问题
普通队列遵循先进先出,但实际任务往往需要优先处理:
- 紧急工单先处理。
- 延迟最小的任务先执行。
- 费用最低的路径先扩展。
- 保留最大的 K 个数据。
- 多个有序数据流合并。
如果每次都把所有任务完整排序,插入一个新任务的成本可能较高;如果只需要当前最优任务,也没有必要维护完整排序。
优先队列提供这样的语义:
text
插入任务
→ 按优先级自动维护
→ 快速取出当前最优任务
堆则以较简单的结构实现这种优先级维护。
二、核心概念
1. 最小堆
最小堆满足每个父节点不大于子节点,因此根节点是最小值:
text
1
/ \
3 2
/ \ /
7 5 4
注意:堆只保证父子之间的大小关系,不保证整个数组有序。
2. 最大堆
最大堆满足每个父节点不小于子节点,根节点是最大值。Python 的 heapq 直接提供最小堆,可以通过存储负值模拟最大堆。
3. 数组表示
完全二叉树可以用数组表示。对于下标为 i 的节点:
text
父节点: (i - 1) // 2
左子节点: 2 * i + 1
右子节点: 2 * i + 2
堆不需要显式的树节点对象。
4. 上浮与下沉
- 上浮:新元素加入末尾后,与父节点比较并向上移动。
- 下沉:根节点被移除后,末尾元素放到根部,再向下交换。
这两个操作维持堆性质。
5. 复杂度
| 操作 | 复杂度 |
|---|---|
| 查看最优元素 | O(1) |
| 插入 | O(log n) |
| 删除最优元素 | O(log n) |
| 建堆 | O(n) |
| 查找任意元素 | O(n) |
堆适合处理最优元素,不适合任意值快速查找。
三、工作原理
1. 插入元素
text
新元素放到数组末尾
→ 与父节点比较
→ 如果更优则交换
→ 继续向上
→ 到达根节点或满足堆性质
2. 删除最优元素
text
保存根节点
→ 将最后一个元素移到根
→ 删除末尾位置
→ 与更优的子节点比较
→ 向下交换
3. Top K 思路
如果要找最大的 K 个元素,可以维护一个大小为 K 的最小堆:
text
遍历数据
→ 堆未满:加入
→ 堆已满且新值更大:替换堆顶
→ 堆已满且新值不大:忽略
复杂度从完整排序的 O(n log n) 降为 O(n log k),当 k 远小于 n 时更有效。
4. 优先队列的稳定性
如果两个任务优先级相同,需要额外加入递增序号作为第二排序键:
text
(priority, sequence, task)
这样可以保持相同优先级任务的进入顺序。
四、实战示例
1. 使用 heapq
python
import heapq
heap: list[int] = []
heapq.heappush(heap, 5)
heapq.heappush(heap, 2)
heapq.heappush(heap, 8)
print(heap[0])
print(heapq.heappop(heap))
print(heapq.heappop(heap))
heap[0] 查看最小值,heappop 删除并返回最小值。
2. 将列表原地建堆
python
import heapq
values = [7, 2, 9, 1, 5]
heapq.heapify(values)
print(values)
heapify 将列表转换为最小堆,整体复杂度为 O(n)。
3. 模拟最大堆
python
import heapq
max_heap: list[int] = []
for value in [7, 2, 9, 1, 5]:
heapq.heappush(max_heap, -value)
largest = -heapq.heappop(max_heap)
print(largest)
取出元素时要还原符号。
4. Top K 最大值
python
import heapq
def top_k_largest(values: list[int], k: int) -> list[int]:
if k <= 0:
return []
heap: list[int] = []
for value in values:
if len(heap) < k:
heapq.heappush(heap, value)
elif value > heap[0]:
heapq.heapreplace(heap, value)
return sorted(heap, reverse=True)
堆中始终保存当前最大的 K 个候选值。
5. Top K 高频元素
python
from collections import Counter
import heapq
def top_k_frequent(
values: list[str],
k: int,
) -> list[tuple[str, int]]:
counts = Counter(values)
return heapq.nlargest(
k,
counts.items(),
key=lambda item: item[1],
)
heapq.nlargest 适合直接获取较少数量的最大元素。
6. 带优先级的任务队列
python
import heapq
from dataclasses import dataclass, field
from itertools import count
@dataclass(order=True)
class ScheduledTask:
priority: int
sequence: int
name: str = field(compare=False)
class PriorityTaskQueue:
def __init__(self) -> None:
self._sequence = count()
self._heap: list[ScheduledTask] = []
def add(self, priority: int, name: str) -> None:
task = ScheduledTask(
priority,
next(self._sequence),
name,
)
heapq.heappush(self._heap, task)
def pop(self) -> ScheduledTask:
if not self._heap:
raise IndexError("empty priority queue")
return heapq.heappop(self._heap)
这里数值越小表示优先级越高。序号用于保证同优先级任务按照加入顺序处理。
7. 合并多个有序列表
python
import heapq
def merge_sorted_lists(
lists: list[list[int]],
) -> list[int]:
heap: list[tuple[int, int, int]] = []
result: list[int] = []
for list_index, values in enumerate(lists):
if values:
heapq.heappush(
heap,
(values[0], list_index, 0),
)
while heap:
value, list_index, value_index = heapq.heappop(heap)
result.append(value)
next_index = value_index + 1
if next_index < len(lists[list_index]):
heapq.heappush(
heap,
(
lists[list_index][next_index],
list_index,
next_index,
),
)
return result
堆中只保存每个列表当前最小元素,不需要把所有元素同时重新排序。
8. Dijkstra 中的优先队列
python
import heapq
def dijkstra(
graph: dict[str, list[tuple[str, int]]],
start: str,
) -> dict[str, int]:
distances = {start: 0}
heap = [(0, start)]
while heap:
distance, node = heapq.heappop(heap)
if distance != distances.get(node):
continue
for neighbor, weight in graph.get(node, []):
candidate = distance + weight
if candidate < distances.get(neighbor, float("inf")):
distances[neighbor] = candidate
heapq.heappush(heap, (candidate, neighbor))
return distances
每次优先扩展当前距离最小的节点。边权不能为负数,否则 Dijkstra 的正确性不成立。
9. 延迟任务调度
python
import heapq
import time
delayed_tasks: list[tuple[float, str]] = []
heapq.heappush(
delayed_tasks,
(time.time() + 5, "send-notification"),
)
heapq.heappush(
delayed_tasks,
(time.time() + 2, "refresh-cache"),
)
run_at, task_name = delayed_tasks[0]
if run_at <= time.time():
heapq.heappop(delayed_tasks)
print("run:", task_name)
生产环境还需要处理并发、持久化、取消、重试、时钟漂移和进程重启。
10. 使用标准库优先队列
python
from queue import PriorityQueue
queue: PriorityQueue[tuple[int, str]] = PriorityQueue()
queue.put((2, "普通任务"))
queue.put((1, "紧急任务"))
print(queue.get())
PriorityQueue 提供线程安全操作,适合多线程环境;单线程算法通常使用 heapq 更轻量。
五、常见问题与实践建议
1. 堆是不是完全有序?
不是。堆只保证根节点是当前最优元素,以及父子节点满足堆关系。如果需要完整有序序列,应使用排序或重复弹出所有元素。
2. 为什么 Top K 使用大小为 K 的堆?
因为只关心 K 个结果,没有必要保存所有候选值。堆大小为 K 可以控制额外空间。
3. heappushpop 和 heapreplace 有什么区别?
heappushpop 先加入新值再弹出最小值,可能直接弹出新值;heapreplace 先弹出旧堆顶再加入新值。使用前要确认业务语义。
4. 任务优先级相同时顺序为什么变化?
如果元组中第二个字段是任务名称,字符串会参与比较,可能造成非预期顺序。应加入递增序号作为稳定排序键。
5. 堆适合任意删除吗?
不适合。堆擅长查看和删除最优元素,删除任意元素需要额外定位和重建。需要任意键删除时可以结合字典建立索引,或选择其他数据结构。
六、进阶思考
1. 堆与排序的选择
| 需求 | 推荐 |
|---|---|
| 完整排序 | sorted |
| 只要最大或最小 K 个 | 堆 |
| 动态加入并不断取最优 | 优先队列 |
| 任意键快速查找和更新 | 堆 + 索引,或专用结构 |
2. 任务取消
堆中直接删除任意任务成本较高。常见做法是使用取消标记,弹出任务时跳过已取消任务,并定期清理堆中的无效记录。
3. 任务公平性
优先级队列可能导致低优先级任务长期得不到执行。生产调度需要考虑老化机制、最大等待时间和不同租户配额。
4. 分布式优先队列
单进程 heapq 无法在多个实例之间共享状态。分布式任务调度还需要持久化、锁、租约、故障恢复、重复执行保护和时钟一致性。
5. 负权边
Dijkstra 使用优先队列,但只适用于非负权边。包含负权边时,应根据问题选择 Bellman-Ford、SPFA 的受控变体或其他算法。
结论
堆通过维护局部有序关系,让优先队列可以高效查看和删除当前最优元素。Python 的 heapq 适合实现最小堆、Top K、多个有序序列合并、任务调度和 Dijkstra。
使用堆时要明确它只保证最优元素,不保证整体有序;同时要处理相同优先级、任务取消、公平性、并发和持久化问题。
参考资料
- Python
heapq文档:https://docs.python.org/3/library/heapq.html - Python
queue.PriorityQueue:https://docs.python.org/3/library/queue.html#queue.PriorityQueue - Introduction to Algorithms:https://mitpress.mit.edu/9780262046305/introduction-to-algorithms/