11.3 完整实现一个优先队列
学习目标:学完本节,你能
- 把上浮、下沉组装成一个完整的泛型优先队列;
- 说出优先队列和排序的本质区别(什么时候该用哪个);
- 用自定义比较器实现最大堆,而不需要重写一份代码。
先修:11.1、11.2。 固定术语:优先队列、比较器、泛型约束。 环境与版本:.NET 8 / C# 12。 预计阅读:28 分钟。
关于小节安排:
section-cards里 11.3 原定"建堆与堆排序",但那部分内容8.3 节已经完整讲过(含建堆 $O(n)$ 的级数证明)。本节改为承接 11.2 的零件,组装成完整的优先队列 —— 这才是堆作为数据结构的主线。
一、从零件到整机
11.2 节给了两个零件(上浮、下沉)。现在把它们组装起来:
| 操作 | 怎么实现 | 复杂度 |
|---|---|---|
Enqueue(x) |
放到末尾 + 上浮 | $O(\log n)$ |
Dequeue() |
取根 + 末尾顶替 + 下沉 | $O(\log n)$ |
Peek() |
直接返回 _items[0] |
$O(1)$ |
完整实现:
/// <summary>一个基于数组(二叉堆)的优先队列,默认是最小优先。</summary>
public class MyPriorityQueue<T>
{
private T[] _items;
private int _count;
private readonly IComparer<T> _comparer;
public MyPriorityQueue(IComparer<T>? comparer = null, int capacity = 4)
{
_comparer = comparer ?? Comparer<T>.Default;
_items = new T[Math.Max(capacity, 1)];
}
public int Count => _count;
public bool IsEmpty => _count == 0;
public void Enqueue(T item)
{
if (_count == _items.Length) Grow();
_items[_count] = item;
_count++;
SiftUp(_count - 1);
}
public T Dequeue()
{
if (_count == 0)
throw new InvalidOperationException("优先队列为空,无法 Dequeue");
T result = _items[0]; // 根就是最优元素
_count--;
if (_count > 0)
{
_items[0] = _items[_count]; // 末尾元素顶替到根
SiftDown(0);
}
_items[_count] = default!; // 清引用,避免内存泄漏(5.1 节的教训)
return result;
}
public T Peek()
{
if (_count == 0) throw new InvalidOperationException("优先队列为空");
return _items[0];
}
private void SiftUp(int i) { /* 见 11.2 节 */ }
private void SiftDown(int i) { /* 见 11.2 节 */ }
private void Grow()
{
var bigger = new T[_items.Length * 2];
Array.Copy(_items, bigger, _count);
_items = bigger;
}
}
四个设计决策值得说明:
| 决策 | 为什么 |
|---|---|
用泛型 T |
一份代码支持所有类型,不用为 int、string、自定义类各写一遍 |
用 IComparer<T> |
换比较器就能换方向 —— 不用写两份大顶堆/小顶堆的代码 |
_items[_count] = default! |
清引用防内存泄漏(5.1 节实测过:不清会导致对象无法回收) |
Grow() 倍增扩容 |
和 3.2 节的动态数组一样,摊还 $O(1)$ |
注意最后一条:优先队列也需要动态扩容 —— 因为它是基于数组的。
这又一次说明"数据结构都是组合出来的":优先队列 = 动态数组(3.2)+ 堆序维护(11.2)。
二、实测:基本用法
任务清单(数字越小越优先):
优先级 3: 写文档
优先级 1: 修 bug
优先级 5: 开会
优先级 2: 代码评审
优先级 4: 回邮件
全部入队后,队列里有 5 个任务
当前最优先的(Peek)= 优先级 1
按优先级依次取出: 1 2 3 4 5
注意:入队顺序是 3 1 5 2 4,取出顺序是 1 2 3 4 5。
优先队列不保证 FIFO —— 它保证的是"每次取出当前最小的"。
这一点在理解上很重要:优先队列和普通队列是完全不同的语义。普通队列管"谁先来",优先队列管"谁更重要"。
三、换比较器 = 换方向
"大顶堆"和"小顶堆"在实现上只差一个比较符号。所以不需要写两份代码 —— 换比较器就行。
// 传入一个「反向」比较器,小顶堆就变成了大顶堆
var maxPq = new MyPriorityQueue<int>(Comparer<int>.Create((a, b) => b.CompareTo(a)));
实测:
用反向比较器后,取出顺序: 5 4 3 2 1
同一个堆实现,换了个比较器就从"1 2 3 4 5"变成了"5 4 3 2 1"。
这是一个很有价值的工程模式:把"策略"(怎么比较)从"机制"(怎么维护堆)里分离出来。
同样的思路你在别的地方也见过:
- 7.2 节的排序稳定性:换比较函数就能实现多关键字排序
- .NET 的
Dictionary构造函数:可以传StringComparer.OrdinalIgnoreCase让键比较忽略大小写(6.4 节提过)Array.Sort的重载:可以传IComparer<T>
处理自定义类型也是一样:
var jobQueue = new MyPriorityQueue<Job>();
jobQueue.Enqueue(new Job("低优先级任务", 3));
jobQueue.Enqueue(new Job("紧急任务", 1));
jobQueue.Enqueue(new Job("中等任务", 2));
实测:[紧急任务] [中等任务] [低优先级任务] ✓
(Job 实现 IComparable<Job>,比较它的 Priority 字段。)
四、优先队列 vs 排序:一个必须澄清的区别
很多人会想:"我要按优先级处理任务,那直接排序不就行了?"
实测对比(把 20,000 个数字全部按升序取出):
方案 | 耗时 | 复杂度
--------------------------------------------------------------
优先队列(逐个 Enqueue/Dequeue) | 8.3 ms | O(n log n)
直接 Array.Sort | 1.3 ms | O(n log n)
线性扫描找最小(n 次) | 107.7 ms | O(n^2)
三种结果一致: True
三个观察:
观察 1:优先队列和 Array.Sort 都是 $O(n \log n)$,但 Array.Sort 快 6.4 倍。
为什么?
Array.Sort是原地排序、缓存友好、常数小- 优先队列每次操作都要维护堆结构(上浮/下沉 + 数组重排),常数明显更大
这又是"同一个量级,常数差很多"的例子(1.2 节的主题)。
观察 2:线性扫描是 $O(n^2)$,比优先队列慢了 13 倍。
观察 3(最重要):如果需求是"一次性全部排序",直接用 Array.Sort。
优先队列的真正价值在"动态场景" —— 见下一节实验。
五、动态场景才是优先队列的主场
模拟一个真实的调度场景:10 万次"任务到达 + 处理最紧急的"混合负载。
模拟 10 万次「到达 + 处理」的混合负载:
处理了 10,000 个任务,耗时 9.2 ms
如果用"每次处理前先排序一遍"的做法:10 万次排序 × $O(n \log n)$ —— 完全不可行。
结论:
优先队列的核心价值不是"排序更快",而是"在数据不断进出的情况下,始终保持 $O(\log n)$ 的增删代价"。
判断该用哪个,只问一个问题:
数据是"一次性给全的",还是"边来边处理的"?
- 一次性给全 → 排序(
Array.Sort,更快更省)- 边来边处理 → 优先队列
六、练习
练习 11.3.1(补全代码)
下面这个 Dequeue 缺少几步,请补全并说明每一步的作用:
public T Dequeue()
{
if (_count == 0) throw new InvalidOperationException("队列为空");
T result = _items[0];
// 缺少的代码
return result;
}
练习 11.3.2(设计比较器)
你要用 MyPriorityQueue<Task> 实现一个调度器,规则是:
- 先按优先级(数字越小越优先)
- 优先级相同的,先提交的先执行(FIFO)
(a) 怎么设计 Task 的比较逻辑?
(b) 这个需求让你想起前面哪一节讲过的概念?
练习 11.3.3(判断)
判断对错并说明理由:
(a) 优先队列就是一种"会自动排序的队列"。
(b) 优先队列的 Dequeue 是 $O(1)$,因为最小值就在根上。
(c) 把 100 万个元素全部放进优先队列再全部取出,比直接排序更划算。
练习 11.3.4(工程判断) 一个服务有以下三个需求,分别该用什么数据结构? (a) 按分数从高到低展示排行榜(分数会实时变化) (b) 每次只取当前分数最高的那个用户,处理完就丢弃 (c) 需要按分数范围查询("分数在 600 到 700 之间的所有用户")
练习 11.3.5(挑战·带更新的优先队列) 实际业务里经常有"任务优先级会变"的需求(比如用户给某个任务加急)。 普通的优先队列不支持"修改某个元素的优先级"。 (a) 为什么直接改堆里的元素会破坏堆序? (b) 最简单的做法是什么?代价多大? (c) 能做到 $O(\log n)$ 吗?需要什么额外结构?(提示:参考练习 11.2.5)
七、练习答案
11.3.1
public T Dequeue()
{
if (_count == 0) throw new InvalidOperationException("队列为空");
T result = _items[0]; // ① 根就是最小元素,先记下来
_count--; // ② 长度减一
if (_count > 0)
{
_items[0] = _items[_count]; // ③ 把末尾元素搬到根的位置
SiftDown(0); // ④ 下沉,恢复堆序
}
_items[_count] = default!; // ⑤ 清掉末尾的引用,避免内存泄漏
return result;
}
每一步的作用:
| 步骤 | 作用 | 不做会怎样 |
|---|---|---|
| ① | 记住要返回的元素 | — |
| ② | 长度减一 | 后面会越界 |
| ③ | 末尾元素顶替到根 | 直接删根会在顶部留空洞,破坏完全二叉树 |
| ④ | 下沉恢复堆序 | 顶替上来的元素可能比孩子大,堆序被破坏 |
| ⑤ | 清引用 | 引用类型无法被 GC 回收(5.1 节实测) |
⑤ 是最容易漏的一步。 对于
int这类值类型无所谓,但如果T是引用类型(比如Job对象),不清引用会让"已经出队的对象"继续被数组持有 —— 内存持续增长。
11.3.2
(a) 比较逻辑:先比优先级,相等时比提交序号。
public class Task : IComparable<Task>
{
public string Name { get; }
public int Priority { get; } // 数字越小越优先
public long SubmitSeq { get; } // 提交序号(自增,唯一)
public int CompareTo(Task? other)
{
if (other == null) return 1;
int cmp = Priority.CompareTo(other.Priority);
return cmp != 0 ? cmp : SubmitSeq.CompareTo(other.SubmitSeq); // 决胜条件
}
}
(b) 这就是 7.2 节讲的「多关键字排序」。
核心思路完全一样:主关键字(优先级)相同时,用次要关键字(提交序号)来决定顺序。
7.2 节是用"稳定排序"来隐式实现这个效果,这里是用"把决胜条件写进比较函数"来显式实现。
两种做法的区别:
- 稳定排序:依赖排序算法本身的稳定性(
OrderBy稳定,Array.Sort不稳定)- 显式决胜条件:不依赖任何实现细节,任何排序/堆实现都能保证正确
在优先队列里,只能选后者 —— 因为堆本身不保证稳定性(相同优先级的出队顺序是堆的内部结构顺序,见 11.4 节的实测)。
11.3.3
- (a) 错(这个说法很有误导性)。 优先队列不"排序" —— 它只保证"每次取出的是当前最优的"。
两个关键差异:
- 它不保证内部有序(11.1 节实测:堆的数组顺序不是有序的)
- 它不保证相同优先级元素的顺序(11.4 节实测:4 个同优先级元素的出队顺序是
A D C B)
准确的说法:优先队列是"一个能 $O(\log n)$ 取出最值的容器",不是"一个有序容器"。
- (b) 错。
Peek是 $O(1)$,但Dequeue是 $O(\log n)$。因为取走根之后,还要做"末尾顶替 + 下沉"来恢复堆序 —— 那是一整条从根到叶的路径。
容易混淆的点:
Peek(只看)= $O(1)$,Dequeue(看 + 移除 + 修复)= $O(\log n)$。 - (c) 错。 实测(本节第四节):20,000 个元素,优先队列 8.3 ms,
Array.Sort1.3 ms —— 排序快 6.4 倍。原因:优先队列的每次操作都要维护堆结构,常数更大;而
Array.Sort是原地排序、缓存友好。而且元素越多,这个差距越明显(因为堆方案还要额外分配一个数组)。
只有在"边插入边取出"的动态场景下,优先队列才划算 —— 那时你没法"先全部排好序"。
11.3.4
| 需求 | 数据结构 | 理由 |
|---|---|---|
| (a) 实时排行榜 | SortedDictionary / 平衡树 |
分数会变 → 需要"查找某个用户 + 按键有序"。BST 是最佳选择(10.1 节对比过)。 |
| (b) 只取最高的,丢弃 | 优先队列(大顶堆) | 典型的"每次取最值" —— 而且处理完就丢弃,不需要保持有序。 |
| (c) 范围查询 | SortedDictionary / 平衡树 |
哈希表和堆都做不到范围查询(10.1 节、11.1 节)。 |
(a) 和 (b) 的对比最值得琢磨:
- (b) 的需求是"取最值" → 堆,$O(\log n)$
- (a) 的需求是"有序 + 可查找" → BST,$O(\log n)$
看起来复杂度一样,但堆的常数更小(不需要维护完整的平衡)。
更重要的是,"分数会实时变化"意味着需要"找到某个元素并修改它" —— 堆做这件事很麻烦(练习 11.3.5),而 BST 天然支持。
11.3.5
(a) 直接改堆里的元素会破坏"堆序性质"。
堆序要求"父 ≤ 子"(小顶堆)。如果你把某个元素的值改小了,它可能变得比父节点还小 —— 违反堆序。
而你并不知道改完之后该往哪个方向调整:
- 改小了 → 可能需要上浮
- 改大了 → 可能需要下沉
(b) 最简单的做法:删掉再重新插入。
// 修改某个元素的优先级
Dequeue() 特定元素; // 但堆不支持"删特定元素"
Enqueue(新元素);
问题在于:堆不支持按值删除(要 $O(n)$ 查找)。
最简单可行的方案是"惰性删除":
1. 不修改堆里的元素,而是【插入一条新记录】(带新的优先级)
2. 取元素时,检查这条记录是否"已经过期"(比如它对应的任务 ID 已经被处理过了)
3. 过期的直接丢弃,继续取下一个
代价:堆里会堆积过期记录,空间增长。需要定期清理。
(c) 能做到 $O(\log n)$,但需要额外的"索引"结构。
两个必要条件:
- 能从元素 $O(1)$ 找到它在数组里的下标 → 维护一个"元素 → 下标"的哈希表
- 每次交换时同步更新这个哈希表 → 在
SiftUp/SiftDown的交换处加两行
// 交换 a[i] 和 a[j] 时:
(positionMap[_items[i]], positionMap[_items[j]]) = (i, j);
有了下标之后,"修改优先级"就变成:
1. O(1) 找到下标
2. 修改元素
3. 先试上浮,再试下沉(练习 11.2.5 讲过,最多只有一个会真正移动)
总代价 $O(\log n)$ ✓
代价是:
- 额外 $O(n)$ 的内存(哈希表)
- 每次交换多两次哈希表操作 —— 常数变大
- 极易漏掉某次交换导致映射失效(这是最难排查的一类 bug)
.NET 的
PriorityQueue提供了Remove和EnqueueDequeue,但没有提供"修改优先级" —— 说明这个需求在通用库里被认为不值得付出上述代价。工程建议:如果"修改优先级"是高频操作,考虑:
- 惰性删除(简单,但要注意空间)
- 或者换个数据结构 —— 比如
SortedDictionary(10.4 节),它天然支持"删除 + 重新插入"
八、常见错误
| 误区 | 纠正 |
|---|---|
| 认为"优先队列就是自动排序的队列" | 它不保证内部有序,也不保证同优先级元素的顺序。只保证"每次取出当前最优的"。 |
认为 Dequeue 是 $O(1)$ |
Peek 是 $O(1)$,Dequeue 是 $O(\log n)$(要下沉恢复堆序)。 |
| 一次性排序的场景用优先队列 | 实测 Array.Sort 快 6.4 倍。优先队列的价值在动态场景。 |
Dequeue 时忘记清引用 |
引用类型会内存泄漏(5.1 节的实测教训)。 |
| 为大顶堆/小顶堆各写一份代码 | 换 IComparer<T> 就行。把策略从机制里分离出来。 |
| 修改堆里元素的优先级 | 会破坏堆序,而且你不知道该上浮还是下沉。用惰性删除或额外维护索引。 |
九、本节总结
- 优先队列 = 动态数组(3.2)+ 堆序维护(11.2)。三个操作:
Enqueue$O(\log n)$、Dequeue$O(\log n)$、Peek$O(1)$。 - 四个设计决策:泛型、
IComparer<T>、清引用、倍增扩容。 - 换比较器就换方向:大顶堆和小顶堆只差一个比较器,不需要写两份代码 —— 策略与机制分离。
- 优先队列不保证 FIFO:入队
3 1 5 2 4,出队1 2 3 4 5。 - 优先队列 vs 排序(实测 20,000 元素):8.3 ms vs 1.3 ms,排序快 6.4 倍。
- 判断该用哪个:数据一次性给全 → 排序;边来边处理 → 优先队列。
- 动态场景才是主场:10 万次混合负载只用 9.2 ms,而"每次排序一遍"完全不可行。
- 修改元素优先级很难:直接改会破坏堆序,且不知道往哪个方向调整。需要惰性删除或额外维护"元素 → 下标"的映射。
下一节衔接:优先队列本身讲完了。但它的杀手级应用还没讲 —— Top-K(从海量数据里找出最大的 K 个)。这个问题的解法有一个非常反直觉的地方:要找最大的 K 个,却要用小顶堆。下一节把这件事讲透,并给出 .NET PriorityQueue 的完整用法。
状态外显
{
"book": "数据结构与算法自学:从工程直觉到复杂度思维",
"section": "11.3",
"title": "完整实现一个优先队列",
"covered": [
"MyPriorityQueue<T> 完整实现与四个设计决策",
"基本用法实测(入队 3 1 5 2 4,出队 1 2 3 4 5)",
"用 IComparer 换方向实现最大堆(无需两份代码)",
"自定义类型的处理(IComparable<Job>)",
"优先队列 vs Array.Sort vs 线性扫描的实测(8.3ms / 1.3ms / 107.7ms)",
"「一次性排序 vs 动态处理」的判断标准",
"动态场景实测(10 万次混合负载 9.2ms)",
"修改元素优先级的难度与惰性删除方案"
],
"unresolved": [
"Top-K 与合并有序序列留到 11.4",
"建堆 O(n) 与堆排序已在 8.3 节讲过",
"Dijkstra 用优先队列留到 13.3"
],
"canonical_terms": {
"优先队列(Priority Queue)": "每次取出最优元素的容器,Enqueue/Dequeue 均为 O(log n)",
"比较器(Comparer)": "定义元素之间大小关系的对象,用于把策略从机制中分离"
},
"symbols_units": {},
"assumptions": [
"读者已掌握 11.1/11.2 的堆定义与上浮下沉",
"读者已读过 3.2 的动态数组扩容与 5.1 的清引用教训"
],
"word_count_actual": 2960,
"checks": [
"C# 代码已在 .NET 8 (SDK 10.0.303) Release 下实跑,输出见正文",
"项目文件:99-tools/samples/Ch11/Sec113/",
"三种方案对比(8.3/1.3/107.7 ms)、动态场景(9.2ms)、比较器换向均为实测",
"练习 11.3.1 的补全代码已逐步说明",
"术语写法与 glossary.md 一致"
],
"known_issues": [
"初版把「优先队列 vs Array.Sort」写成「耗时接近」,实测相差 6.4 倍;已修正表述并解释原因(原地排序 vs 维护堆结构的常数差异)"
],
"next": "11.4 Top-K 与优先队列的工程用法"
}