PriorityQueue<TElement,TPriority>:.NET 优先队列的语义、四叉堆与工程应用
系列 :C# 与常用数据结构源码剖析 · 栈与队列篇
阅读时间 :约 55 分钟
前置知识 :数组、完全树、比较器、渐进复杂度
版本边界 :
PriorityQueue<TElement, TPriority>在 .NET 6 加入 BCL。本文讲解 .NET 6 起就存在的核心语义;内部字段、分支因子和辅助 API 必须按具体dotnet/runtimetag 核验,不将当前主分支当成所有版本的永久契约。
一、优先队列不是"会自动排好序的队列"
普通 Queue<T> 保证先进先出;优先队列保证的是,每次观察或移除的都是当前"最小"优先级对应的元素。这里的"最小"由 IComparer<TPriority> 定义,不是由 TElement 定义。
var queue = new PriorityQueue<string, int>();
queue.Enqueue("low", 30);
queue.Enqueue("urgent", 1);
queue.Enqueue("normal", 10);
Console.WriteLine(queue.Dequeue()); // urgent
它不保证内部数组整体有序,也不保证遍历顺序为出队顺序。堆只维护局部偏序:父节点不大于它的子节点。这个条件足以保证根节点是全局最小值,却不要求兄弟节点或不同子树之间有序。
TElement 和 TPriority 分离很重要。任务对象可以很复杂,而优先级可以是整数、时间戳、距离,或者含主次键的值类型。队列不会监听元素属性的变化;它在入队时保存一个优先级值,以后只比较这个值。
二、先固定 tag,再谈"源码就是这样"
该类的源码位于 dotnet/runtime 仓库的 System.Collections.Generic.PriorityQueue 实现文件。但 main 会继续变化,不能用它解释已发布的 .NET 6 二进制。可复现的源码分析应记录:
- 目标运行时的完整版本,而不是只写".NET 6+"。
- 与发布版匹配的
dotnet/runtimetag 或 commit SHA。 - reference assembly 中的公共 API,用来区分契约和私有实现。
- 实现源码和运行时测试,用来解释边界行为。
已发布的 .NET 6 实现采用四叉最小堆,后续多个版本也能观察到这一形状。但"四叉"是私有实现选择,不是公共 API 对调用者的承诺。应用程序不应依赖堆的分支因子、私有字段名称或同优先级元素的偶然布局。
本文下面的"源码"有两种类型:带有确定 tag 的链接或引用才可称为原源码;为了教学而删去比较器快路径、异常资源和容量边界的代码,一律标为"结构化节选"。节选用来说明算法,不应被误认为可逐字对照某一版本的代码。
三、数组如何容纳一棵四叉完全树
目标版本的核心存储可概括为一个节点数组、有效元素数、优先级比较器与修改版本号。一个节点同时保存 Element 和 Priority。完全树按层从左到右填入数组,不需要对象节点和父子指针。
对分支因子 d = 4 的零基数组,索引关系为:
parent(i) = (i - 1) / 4 // i > 0,整数除法
firstChild(i) = 4 * i + 1
children(i) = 4*i+1, 4*i+2, 4*i+3, 4*i+4
前两层的布局是:
[0]
/ | | \
[1] [2] [3] [4]
/ / | \ / / | \
[5][6][7][8] [17][18][19][20]
数组中的索引不是优先级排名。例如索引 2 的优先级可能大于索引 10,只要 10 的所有祖先都不大于它就没有违反堆性质。因此 UnorderedItems 的名称就是契约:它适合诊断和查看,不能当作排序结果。若要得到完整的优先级顺序,必须在可以消耗的队列副本上反复出队,或者对独立快照排序。
四叉堆比二叉堆浅,一次下潜却要在最多四个子节点中找最小者。层数、每层比较次数、数组访存和分支预测之间存在权衡。这可以解释为什么工程实现会考虑 d-ary heap,但不能凭此捏造"d=4 必然快多少"的数字。
四、不变式:理解所有操作的钥匙
设节点 x 的优先级为 p(x),比较器为 cmp。最小堆的核心不变式是,对每一条父子边都有:
cmp(p(parent), p(child)) <= 0
另外还有三个工程不变式:有效节点占用数组的连续前缀;Count 不大于容量;每个有效节点中的元素和优先级始终成对移动。不变式说明了为什么入队只需修复一条向上路径,出队只需修复一条向下路径。
若比较器不满足稳定的全序关系,堆的正确性就会失去基础。例如比较结果随时间变化,或存在 a < b、b < c但 c < a 的循环,已建立的父子关系就无法保证后续操作的结果。
五、Enqueue:先追加,再向上修复
入队首先确保数组有空间,把新节点视为完全树的最后一个叶子,然后沿父链比较。当新优先级小于父节点时,父节点下移;直到根,或新节点不再小于父节点。
下面是结构化节选,它刻意省略了默认比较器的优化分支、数组上限和异常文本:
// 结构化节选:说明向上修复,不是任何 tag 的逐字源码。
private void MoveUp(Node node, int index)
{
while (index > 0)
{
int parent = (index - 1) / 4;
if (Compare(node.Priority, _nodes[parent].Priority) >= 0)
break;
_nodes[index] = _nodes[parent];
index = parent;
}
_nodes[index] = node;
}
实现使用"挖洞"方式搬移路径上的节点,最后只将新节点写入它的最终位置,而不是每层都做三次赋值的通用 swap。但二者的算法语义一样:只改变新叶子到根的一条路径。
六、Dequeue、Peek 和 Try 家族
Peek 返回根节点的元素而不移除它,Dequeue 返回并移除根。空队列上的两者都会抛出 InvalidOperationException。TryPeek 和 TryDequeue 则用 bool 表示成功,同时输出元素和优先级,适合"队列为空是正常控制流"的场景。
while (queue.TryDequeue(out WorkItem item, out long dueTicks))
{
Execute(item, dueTicks);
}
出队后,数组末尾节点被拿到根的空位。它可能比某个子节点大,因此每层先从最多四个子节点中选出最小者,再决定是否继续下潜。只有选最小子节点,才能在它上移后同时保持对其他兄弟的堆性质。
// 结构化节选:边界与比较器快路径均已简化。
private void MoveDown(Node node, int index, int size)
{
while (true)
{
int first = 4 * index + 1;
if (first >= size)
break;
int best = first;
int endExclusive = Math.Min(first + 4, size);
for (int child = first + 1; child < endExclusive; child++)
{
if (Compare(_nodes[child].Priority,
_nodes[best].Priority) < 0)
best = child;
}
if (Compare(node.Priority, _nodes[best].Priority) <= 0)
break;
_nodes[index] = _nodes[best];
index = best;
}
_nodes[index] = node;
}
Try* 并不意味着线程安全,也不意味着"检查与移除"能与其他线程原子协作。PriorityQueue 是普通可变集合;多线程共享时需要外部同步,或者由单一所有者线程串行处理命令。
七、EnqueueDequeue 和 DequeueEnqueue 不可互换
两个复合操作的名字很像,但语义不同。理解它们的最好方法是严格按名字中的顺序展开。
EnqueueDequeue(element, priority) 等价于"先加入,紧接着移除全局最小值",但实现可以避免不必要的先上浮再下潜。如果新优先级不大于旧根,新元素就是应被立即移除的最小值:队列可以保持不变,直接返回新元素。若队列为空,先入后出的结果同样是返回新元素,队列仍为空。
DequeueEnqueue(element, priority) 则等价于"先移除旧根,再加入新元素"。它必须返回操作前的根,并必须让新元素留在队列中;空队列没有可先移除的元素,因此会抛出异常。
| 操作 | 队列为空 | 新优先级小于旧根 | 新元素是否一定留下 |
|---|---|---|---|
EnqueueDequeue |
返回新元素,仍为空 | 返回新元素,旧堆可不变 | 否 |
DequeueEnqueue |
抛出异常 | 返回旧根,新元素留下 | 是 |
这些方法适合已经具有对应业务语义的场景,不应为了"少一次调用"强行替换两个独立操作。例如保留容量固定的较大候选集时,EnqueueDequeue 的"新候选若更小就立即移除"可能正是所需语义。而周期调度器取出已到期任务并用其下一次时间重新入队,才可能符合 DequeueEnqueue。
八、比较器决定"优先"的全部含义
未传入自定义比较器时,队列使用优先级类型的默认比较规则。要建立最大优先队列,可以反转比较规则,而不必将整数优先级简单取负。对 int.MinValue 取负会溢出,浮点优先级还涉及 NaN、无穷大和精度。显式比较器能更清楚地表达语义。
var highestScoreFirst = new PriorityQueue<Player, int>(
Comparer<int>.Create((left, right) => right.CompareTo(left)));
比较器不应读取会在元素入队后改变的外部状态。如果"当前时间"被放进比较过程,两个节点的相对顺序可能在没有任何堆操作时自行改变,从而破坏不变式。应在入队前计算稳定的截止时间或评分快照。
九、相同优先级不等于先进先出
PriorityQueue 不保证稳定性。两个节点的优先级比较结果为零时,其出队先后不是公共契约。某次实验看到的顺序可能因入队模式、扩容、堆修复或运行时实现改变。
若业务要求"优先级相同时先来先服务",应把单调递增序号作为次键。不要依赖不稳定堆的偶然行为。
public readonly struct StablePriority : IComparable<StablePriority>
{
public StablePriority(int level, long sequence)
{
Level = level;
Sequence = sequence;
}
public int Level { get; }
public long Sequence { get; }
public int CompareTo(StablePriority other)
{
int byLevel = Level.CompareTo(other.Level);
return byLevel != 0
? byLevel
: Sequence.CompareTo(other.Sequence);
}
}
序号的生成需要考虑线程模型和溢出边界。单线程调度器可以由所有者直接递增;多线程提交则需要在入队边界排序化,但这仍不会让队列本身自动变成线程安全集合。
十、容量、扩容与内存清理
Count 表示当前元素数,Capacity 表示内部数组在不重新分配时能容纳的节点数。已知批量规模时,可使用带初始容量的构造函数,或在目标版本公开 API 包含 EnsureCapacity 时提前保留,减少中途数组分配与复制。
不要将扩容规则写死为"永远翻倍"。具体实现还要处理小容量最小增长、调用者所需的最小容量、数组长度上限与整数溢出。一定比例增长只是常见主路径,不是应用可依赖的契约。
Clear 把逻辑元素数归零,但通常保留内部容量以便复用。对含引用的节点,实现需要清理有效区间的引用,否则元素可能被数组意外保活。TrimExcess 用于在存储明显过量时缩减容量,它可能分配和复制,不应放在每帧或每次出队后。
容量是时间与驻留内存的权衡。按不可能达到的峰值为每个队列预分配,会把扩容尖峰变成长期内存浪费。更好的估算来自场景上限、历史高分位和目标设备的内存预算。
十一、复杂度:要写 d,也要看比较成本
对含 n 个节点的 d 叉堆,树高是 O(log_d n)。因此核心操作的渐近界为:
| 操作 | 时间 | 备注 |
|---|---|---|
Peek / TryPeek |
O(1) | 读取根 |
Enqueue |
O(log_d n) | 最坏向上到根 |
Dequeue / TryDequeue |
O(d log_d n) | 每层选最优子节点 |
| 堆化 n 个节点 | O(n) | 自底向上修复,不是 O(n log n) |
| 查找任意元素 | O(n) | 堆不按元素组织索引 |
对固定 d = 4,大 O 记号中可以把 d 视为常数,于是入队和出队通常简写为 O(log n)。但实际成本还包含 TPriority 的比较、节点复制和扩容。如果比较器执行字符串文化比较或访问外部数据,"比较一次"就不再是廉价常数。可以在入队前计算紧凑、稳定的优先级快照。
扩容那一次入队需要分配新数组并复制已有节点,单次可达 O(n);由于容量按几何方式增长,连续入队的均摊成本仍然为 O(log n)。"均摊"并不会消除某一帧的尖峰,对实时系统仍需要预分配和实测。
十二、A* 中的正确用法:允许重复,出队时过滤
BCL 优先队列的核心 API 不提供通用的 DecreaseKey(handle, priority)。对 A* 或 Dijkstra,最简洁的策略通常是"懒惰重复":找到更好路径时更新最佳分数,再入队一个新条目;旧条目不从堆中搜索删除,它将来出队时根据最佳分数表判定为过期。
public static bool TryFindPath(
int start,
int goal,
IReadOnlyList<Edge>[] graph,
Func<int, int> heuristic,
out Dictionary<int, int> cameFrom)
{
var open = new PriorityQueue<OpenEntry, long>();
var bestG = new Dictionary<int, long> { [start] = 0 };
cameFrom = new Dictionary<int, int>();
open.Enqueue(new OpenEntry(start, 0), heuristic(start));
while (open.TryDequeue(out OpenEntry entry, out _))
{
if (bestG[entry.Node] != entry.G)
continue; // 这是旧路径留下的过期条目
if (entry.Node == goal)
return true;
foreach (Edge edge in graph[entry.Node])
{
if (edge.Cost < 0)
throw new ArgumentOutOfRangeException(nameof(graph));
long candidate = checked(entry.G + edge.Cost);
if (bestG.TryGetValue(edge.To, out long known) && candidate >= known)
continue;
bestG[edge.To] = candidate;
cameFrom[edge.To] = entry.Node;
long f = checked(candidate + heuristic(edge.To));
open.Enqueue(new OpenEntry(edge.To, candidate), f);
}
}
return false;
}
public readonly record struct Edge(int To, int Cost);
public readonly record struct OpenEntry(int Node, long G);
示例用 long 和 checked 暴露距离溢出,并把入队时的 g 一起存入元素,因此两个条目即使 f 相同,仍能准确判断旧路径。它仍假设启发函数和边权满足 A* 的正确性条件;若启发函数不一致,还要明确节点重开策略。
懒惰重复的优点是简单、不需要暴露堆索引;代价是队列可能同时容纳一个节点的多个版本。如果更新比例极高且内存成为瓶颈,可考虑自建带句柄的索引堆,用"元素 ID -> 堆索引"映射支持 decrease-key。但每次节点上移或下移都必须同步更新映射,正确性和测试成本更高。
十三、调度器案例:时间截止点与取消
定时任务调度器可以把绝对截止时间作为优先级。每次更新时先查看队首;若最早任务尚未到期就停止,若已到期就出队执行。不应枚举 UnorderedItems 查找"第一个到期任务",因为枚举的第一个只是存储细节,而根才是契约中的最小值。
public sealed class Scheduler
{
private readonly PriorityQueue<ScheduledWork, long> _queue = new();
private readonly HashSet<long> _cancelled = new();
public void Schedule(ScheduledWork work) =>
_queue.Enqueue(work, work.DueTimestamp);
public void Cancel(long id) => _cancelled.Add(id);
public void RunDue(long now)
{
while (_queue.TryPeek(out ScheduledWork work, out long due) && due <= now)
{
_queue.Dequeue();
if (_cancelled.Remove(work.Id))
continue;
work.Callback();
}
}
}
public readonly record struct ScheduledWork(
long Id,
long DueTimestamp,
Action Callback);
此例用惰性取消避免为查找任意任务做 O(n) 扫描。如果取消数量长期很大,已取消节点会在到达队首前占用内存;此时可在安全时点重建堆,或使用带位置索引的专用调度结构。
生产调度器还要定义时钟语义。持续时长调度通常应使用单调时钟,而不是会被系统校时向前或向后跳变的墙上时钟。回调是否允许重入调度器、异常如何处理、同一截止点是否需要稳定顺序,都应是明确契约。
十四、可变优先级与"删除任意元素"
已入队节点的优先级不会因 TElement 内部属性改变而自动更新。若入队时传入 enemy.Distance 作为浮点优先级,后来敌人移动,队列中保存的仍是旧距离。若优先级本身是可变引用对象,原地修改它更危险:堆不会获得通知并重建父子关系。
常用策略有三种:
- 入队不可变优先级快照,变更时追加新条目,出队时通过版本号或最佳表丢弃旧条目。
- 在数据量较小、更新很少时,移除或忽略旧节点并重新入队,接受线性查找成本。
- 对高频更新专门实现可定位堆,用句柄维护元素位置并修复向上或向下路径。
某些后续 .NET 版本可能扩展了公共 API,不能把当前文档里的便利方法倒推为 .NET 6 契约。即使目标版本提供了按元素查找或移除的方法,堆本身也没有按 TElement 建索引,因此应查其复杂度,不要假设是 O(1)。
十五、测试应从契约、不变式和差分对照三层展开
只测"1、2、3 依次出队"不足以捕获边界错误。一个完整测试集应包含:
- 空队列的
Peek、Dequeue、TryPeek、TryDequeue与两个复合操作。 - 单元素、刚好填满容量、跨越扩容边界,以及
Clear后复用。 - 严格递增、严格递减、大量相同优先级和随机优先级。
- 默认比较器、反向比较器、主次键比较器,以及比较器异常的传播。
EnqueueDequeue在新优先级小于、等于、大于旧根时的不同结果。- 枚举或
UnorderedItems的契约边界:不声明未保证的排序顺序。 - 元素和优先级成对保持,特别是多次上浮、下潜与扩容后。
对自研堆或试图理解 BCL 行为的测试,可使用差分对照:生成一串随机的入队和出队操作,同时在一个简单但显然正确的参考容器中执行,每次核对最小优先级、元素计数和返回值。参考实现可以用列表线性寻找最小值;它不快,但容易审查。
如果可以在测试中观察自研堆的内部节点,每一次修改后都可扫描所有非根节点,断言父优先级不大于子优先级。这种不变式测试往往能快速定位最后一层不满、子节点上界错一和相等比较处理错误。
十六、基准测试:测你的工作负载,不测传说
不能凭树高就断言四叉堆比二叉堆快,也不能从一个机器的整数测试推导到长字符串比较、大节点或另一个 CPU。可信基准至少要分开:
- 只入队,比较已排序、逆序和随机优先级。
- 先批量填充再全部出队,避免把扩容成本偷偷混入下潜比较。
- 稳态混合负载,例如每次一出一入,并分别测两个复合 API。
- A* 或调度器的端到端工作负载,包含字典、过期检查和回调管理。
- 典型数量和峰值数量,同时记录时间、分配与峰值内存。
基准应使用成熟框架隔离预热、多次迭代和计时器误差,并固定 SDK/runtime 完整版本、CPU、操作系统、发布配置、输入种子与队列初始容量。比较不同堆时要保证它们使用相同比较器、相同节点数据和相同输出验证。没有原始报告和可运行代码的"快 20%"不应进入教程结论。
十七、常见误区与选型边界
误区一:优先队列可以代替排序列表。 它只能廉价地给出当前最小值。若需要频繁按排名访问第 k 个元素、区间查询或同时从两端移除,可能需要排序集合、顺序统计树或双堆结构。
误区二:四叉堆的层数是二叉堆的一半,所以操作就快一倍。 下潜每层需要选出更多子节点中的最小者,且节点大小、比较器和 CPU 都会影响结果。只有对实际工作负载的基准能回答性能问题。
误区三:元素对象的优先属性变了,队列会自动重排。 队列比较的是入队节点保存的 TPriority。要更新就使用重复条目与过期检查,或者使用支持句柄更新的数据结构。
误区四:优先级相同时会按入队顺序出队。 BCL 没有这个稳定性承诺。业务需要 FIFO 就把序号放进次级优先键。
误区五:TryDequeue 是并发 API。 Try 只表示空队列不通过异常报告,不提供跨线程协调。并发生产者需要明确的锁、单所有者模型或适合的并发通道。
十八、源码审查清单
在阅读一个具体版本的 PriorityQueue 时,可按以下顺序审查:
- 当前文件属于哪个 tag 或 commit,reference assembly 公开了哪些 API?
- 节点数组如何存储
Element和Priority,有效前缀如何界定? - 目标版本的分支因子是多少,父子索引公式是否与之一致?
- 默认比较器与自定义比较器是否走不同快路径,但保持同一契约?
- 向上修复和向下修复如何减少交换,尾层不满时如何计算子节点上界?
- 空队列时抛异常 API 和
Try*API 的输出各是什么? EnqueueDequeue与DequeueEnqueue在空队列、相等优先级和新根更小时如何分支?- 扩容如何处理最小增长、需求容量、数组上限和溢出?
- 移除或清空节点时,引用是否被及时断开?
- 修改版本号保护了哪些枚举路径,哪些行为只是私有实现细节?
结语
PriorityQueue<TElement, TPriority> 的价值不只是向 BCL 补上一个容器。它展示了如何把教材中的最小堆变成工程组件:用紧凑数组表示完全树,用四叉布局在高度和每层比较之间权衡,用比较器分离算法和业务语义,并用 Try* 和复合操作覆盖不同控制流。
真正使用它时要牢记四个边界:它只保证队首最小,不保证整体排序;相同优先级不稳定;入队后优先级不会自动更新;四叉堆和内部字段是需要按 tag 核验的实现,不是应用可依赖的公共契约。建立这些边界后,A*、任务调度、事件模拟和 Top-K 才能在正确性、内存与性能之间做出可验证的选择。
延伸阅读 :阅读源码时请链接到与目标 SDK 对应的
dotnet/runtimetag,并同时查看 reference assembly 和运行时测试。下一篇:场景选型:Queue vs Stack vs Channel