堆与优先队列:从 Top K 到任务调度

摘要

优先队列不按照元素进入顺序处理数据,而是优先处理优先级最高或权值最小的元素。堆是实现优先队列的常用数据结构,可以在较低成本下完成插入、查看最优元素和删除最优元素。

本文介绍最小堆、最大堆、堆的上浮与下沉、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。

使用堆时要明确它只保证最优元素,不保证整体有序;同时要处理相同优先级、任务取消、公平性、并发和持久化问题。

参考资料

  1. Python heapq 文档:https://docs.python.org/3/library/heapq.html
  2. Python queue.PriorityQueue:https://docs.python.org/3/library/queue.html#queue.PriorityQueue
  3. Introduction to Algorithms:https://mitpress.mit.edu/9780262046305/introduction-to-algorithms/
相关推荐
shaibdoio1 小时前
瞬维AI技术拆解:AI获客智能体的双层漏斗算法原理
大数据·人工智能·算法
XIAEE1 小时前
ES6 与 Node.js 群侠传:那些年我们追过的语法,和那些年把我们追杀的 API
java·spring
XIAEE1 小时前
WebRTC 探险记:我是如何让两个浏览器"看对眼"的
java·spring
XIAEE1 小时前
UniApp 本地打包通关记:Android 是地狱难度,iOS 是隐藏 BOSS
java·spring
hetao17338371 小时前
联盟场-提高组-R7 & ZYZSC-S-Round 11
c++·算法
高晶1 小时前
Android 物联网组网实战:NFC 从零到落地
android·java
Dreamboat-L2 小时前
日志级别基础 + Spring Boot 日志实践指南
java·服务器·数据库·java-ee·springboot
啵啵啵鱼2 小时前
第二章 线性表
java·数据结构·算法
虾..2 小时前
计算机图形学:几何表示
算法