跳到内容

第 2 卷 / 第 7 章 / 第 2 课

7.2 优先队列 ​

前置:堆|下一章:图。这一页把堆封装成工程接口,并明确同优先级、更新、取消、Top K 与 K 路归并各自的顺序合同。

上一节手写了堆。工程代码通常更关心接口:怎样加入任务,怎样查看下一项,优先级相同时谁先出来,取消任务后会发生什么。

优先队列是抽象数据类型,堆是它的一种实现。 未排序数组也能实现优先队列,尾部加入可做摊销 O(1),但每次出队要扫描 O(n);有序数组能让出队便宜,却把插入推到 O(n)。二叉堆的上滤与下滤都至多为 O(log n),查看根是 O(1)。

堆的数组关系没有改变:父节点 (i - 1) // 2,左孩子 2 * i + 1,右孩子 2 * i + 2。优先队列在这层结构之上定义业务顺序。

1. 同优先级需要第二把尺子 ​

假设调度器同时收到两个优先级为 2 的字典任务。Python 若只存 (priority, task),优先级相同时会继续比较 task;字典不可排序,因此程序会抛出 TypeError。即便任务对象可以比较,它的自然顺序也不等于“先到先服务”。

解决办法是在中间加入单调序号:

text
(priority, sequence, task)

优先级先决定顺序;优先级相同,较小的序号先出队;序号不会重复,所以比较永远不必走到任务对象。普通堆本身不保证稳定,这个序号才建立了稳定合同。

2. 支持更新与取消的完整实现 ​

二叉堆擅长改根,却不擅长从数组中间找一个具名任务。下面用映射定位当前条目,取消时先标记,等条目来到根再跳过。这个策略叫惰性删除。数字越小越先出队;同分时按最近一次 add 的顺序排列。更新会获得新序号,所以同优先级更新相当于重新排队。

任务名必须是字符串,优先级必须是整数,检查发生在改动旧条目之前。不存在的任务名传给 remove,或没有活任务时调用 pop,均抛出 KeyError。此教学类不提供线程同步。

python
from __future__ import annotations

import heapq
import itertools
from operator import index as integer_index
from collections.abc import Iterable
from typing import Any, TypeVar

T = TypeVar("T")

class StablePriorityQueue:
    def __init__(self) -> None:
        self._heap: list[list[Any]] = []
        self._entries: dict[str, list[Any]] = {}
        self._counter = itertools.count()
        self._removed = object()

    def __len__(self) -> int:
        return len(self._entries)

    def add(self, name: str, payload: Any, priority: int = 0) -> None:
        if not isinstance(name, str):
            raise TypeError("name must be a string")
        if not isinstance(priority, int):
            raise TypeError("priority must be an integer")
        if name in self._entries:
            self.remove(name)
        entry = [priority, next(self._counter), name, payload]
        self._entries[name] = entry
        heapq.heappush(self._heap, entry)

    def remove(self, name: str) -> None:
        if not isinstance(name, str):
            raise TypeError("name must be a string")
        entry = self._entries.pop(name)
        entry[2] = self._removed
        entry[3] = None

    def compact(self) -> None:
        self._heap = list(self._entries.values())
        heapq.heapify(self._heap)

    def pop(self) -> tuple[str, Any, int]:
        while self._heap:
            priority, _sequence, name, payload = heapq.heappop(self._heap)
            if name is not self._removed:
                del self._entries[name]
                return name, payload, priority
        raise KeyError("pop from an empty priority queue")

def top_k_largest(values: Iterable[T], k: int) -> list[T]:
    k = integer_index(k)
    if k <= 0:
        return []
    heap: list[T] = []
    for value in values:
        if len(heap) < k:
            heapq.heappush(heap, value)
        elif heap[0] < value:
            heapq.heapreplace(heap, value)
    return sorted(heap, reverse=True)

def merge_k_sorted(groups: list[list[T]]) -> list[T]:
    heap: list[tuple[T, int, int]] = []
    for group_index, group in enumerate(groups):
        if group:
            heapq.heappush(heap, (group[0], group_index, 0))
    merged: list[T] = []
    while heap:
        value, group_index, item_index = heapq.heappop(heap)
        merged.append(value)
        next_index = item_index + 1
        if next_index < len(groups[group_index]):
            next_value = groups[group_index][next_index]
            heapq.heappush(heap, (next_value, group_index, next_index))
    return merged

if __name__ == "__main__":
    queue = StablePriorityQueue()
    queue.add("backup", {"node": 3}, priority=2)
    queue.add("report", {"node": 1}, priority=2)
    queue.add("alert", {"node": 7}, priority=1)
    print(queue.pop())
    print(queue.pop())
    print(top_k_largest([3, 2, 1, 5, 6, 4], 2))
    print(merge_k_sorted([[1, 4, 5], [1, 3, 4], [2, 6]]))

完整输出如下,字典载荷从未互相比较:

text
('alert', {'node': 7}, 1)
('backup', {'node': 3}, 2)
[6, 5]
[1, 1, 2, 3, 4, 4, 5, 6]

更新任务时,旧条目仍留在堆数组中,取消会清掉它对载荷的引用。len(queue) 只统计活任务。compact() 从活条目重建堆,保留原序号,所以不会改变同分顺序;何时调用取决于内存和延迟目标,本例没有自动阈值。

设堆数组含 m 条记录,其中 a 条活跃。按平均字典访问成本计,标记删除为 O(1);加入的堆调整为 O(log(m+1));一次出队若跳过 s 条陈旧记录,需要至多 O((s+1) log(m+1)) 的堆调整。底层列表和字典扩缩容还需按操作序列摊销。即使只剩一个活任务,一次 pop 也可能清理大量旧条目。重建活堆用 O(a),释放旧列表还需处理 m 条引用;总成本为 O(m+a),存储由 O(m+a) 回到 O(a),均不计载荷对象。

3. Top K:保留候选集合的淘汰线 ​

分数逐项到达,只保留最大的 k 个。用大小至多为 k 的最小堆,堆顶就是候选集合的淘汰线:

  1. 堆未满,直接加入;
  2. 新值不超过堆顶,它进不了前 k;
  3. 新值超过堆顶,用它替换当前最小候选。

k 必须支持整数索引,浮点数会在读取输入前被拒绝。k <= 0 直接返回 [],不消耗输入;k >= n 返回全部值的降序副本;重复次数保留。对 k > 0,令 q=min(k,n),扫描与最终排序的总上界是 O(n log(q+1)),辅助堆和结果各占 O(q);k=1 仍需线性扫描。

元素应满足一致的顺序比较,不能含 NaN,比较成本按常数计。Top K 只承诺值和重复次数;相等对象之间的原始次序不保证保留。

这里用 heapreplace,是因为代码已经证明新值大于堆顶。它先移除堆中的根,再加入新值。heappushpop(heap, value) 则先考虑新值和旧根,返回两者中更小的值,把较大者留在堆里。固定大小筛选中两者都可能有用,但语义不能凭名字猜。

4. K 路归并:每队只派一个代表 ​

若干输入列表已经按升序排好。先把每个非空列表的第一项放入堆;取走一项后,只补入同一列表的下一项。输入列表不会被修改,但也不会被预先检查是否有序:这是调用前提。

设共 K 个列表、N 个元素,其中 r 个列表非空。扫描列表头还要 O(K),逐个加入初始堆为 O(r log(r+1)),后续处理为 O(N log(r+1))。由于 r <= N,总上界可写为 O(K + N log(r+1));K=1 或大量空列表时也成立。辅助堆占 O(r),返回列表另占 Θ(N)。

元素必须具有一致的比较与相等语义。值相等时,group_index 让编号较小的列表先出,同一列表保持原顺序。这也解释了元组中的平局规则。需要边读边输出时,可改成生成器,或使用 heapq.merge。

5. 三种标准库的方向不同 ​

环境默认根 / 头部重要合同
Python heapq最小值在普通 list 上维护堆;heapify 是线性时间
Java PriorityQueue比较器下的最小值同值头部任意;迭代器不保证顺序;不允许 null;非同步
C++ std::priority_queue<T>默认最大值容器适配器;用 std::greater<T> 得到最小优先队列

Python 3.14 新增 heapify_max、heappush_max、heappop_max 等公开最大堆 API。需要兼容更早版本时,对数值可取负数或包装比较键;Python 整数取负不会发生固定宽度溢出,但在其他语言中要检查最小整数的取负边界。heapq 只是作用于列表的算法模块;线程间阻塞式任务队列应看 queue.PriorityQueue。

Java 的 offer、poll 是 O(log n),peek 是 O(1)。相同最小键的头部任意,不能把 PriorityQueue 当稳定队列。iterator() 和 toString() 展示的内部顺序也不是排序结果;需要有序取出时反复 poll,但这会清空队列。多线程修改要使用合适的同步或 PriorityBlockingQueue。

C++ 默认比较器 std::less<T> 让最大值出现在 top()。Top K 最大值问题需要维护最小堆:

cpp
#include <functional>
#include <queue>
#include <vector>

std::priority_queue<int, std::vector<int>, std::greater<int>> candidates;

标准适配器提供 push、pop、top,不提供有序迭代或任意元素删除。需要更新优先级时,常见做法也是加入新条目并在弹出时跳过旧版本,或另选支持句柄的数据结构。

6. 延伸模式:两个堆维护中位数 ​

数据流中位数把较小的一半放在最大堆,较大的一半放在最小堆,并保持两边大小差不超过 1。奇数个值时,中位数是较大那一堆的堆顶;偶数个数值时,通常取两个堆顶的平均值。空输入时尚无中位数。这是“只维护边界,不维护完整顺序”的另一个例子。先把单堆合同练熟,再实现这个扩展。

7. 故障排查 ​

  • 同优先级偶发 TypeError:元组缺少唯一序号,比较落到了载荷。
  • 取消后仍出队:pop 没有循环跳过删除标记。
  • 更新后活任务数增加:替换前没有移除同名旧条目。
  • Java 打印结果看似乱序:内部数组遍历没有排序保证。
  • C++ Top K 淘汰了最大值:误用了默认最大优先队列。
  • 内存持续增长:惰性删除留下太多陈旧条目,需要重建策略。

官方合同 ​

从任务顺序到图遍历 ​

优先队列的工程难点往往不在上滤和下滤,而在业务顺序:数字越小是否越紧急,同分是否先到先出,更新与取消是否允许,空队列怎样报告。先写清接口合同,再让堆执行它;若采用惰性删除,还要监控陈旧条目并安排重建。

下一章进入 图,路径不再只有父子方向,待处理节点也会从一条队列扩展成整张网络。

Built with VitePress | Software Systems Atlas