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 一份代码支持所有类型,不用为 intstring、自定义类各写一遍
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) 错(这个说法很有误导性)。 优先队列不"排序" —— 它只保证"每次取出的是当前最优的"。

    两个关键差异

    1. 它不保证内部有序(11.1 节实测:堆的数组顺序不是有序的)
    2. 它不保证相同优先级元素的顺序(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.Sort 1.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)$,但需要额外的"索引"结构。

两个必要条件:

  1. 能从元素 $O(1)$ 找到它在数组里的下标 → 维护一个"元素 → 下标"的哈希表
  2. 每次交换时同步更新这个哈希表 → 在 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)$ ✓

代价是

  1. 额外 $O(n)$ 的内存(哈希表)
  2. 每次交换多两次哈希表操作 —— 常数变大
  3. 极易漏掉某次交换导致映射失效(这是最难排查的一类 bug)

.NET 的 PriorityQueue 提供了 RemoveEnqueueDequeue,但没有提供"修改优先级" —— 说明这个需求在通用库里被认为不值得付出上述代价。

工程建议:如果"修改优先级"是高频操作,考虑:

  • 惰性删除(简单,但要注意空间)
  • 或者换个数据结构 —— 比如 SortedDictionary(10.4 节),它天然支持"删除 + 重新插入"

八、常见错误

误区 纠正
认为"优先队列就是自动排序的队列" 不保证内部有序,也不保证同优先级元素的顺序。只保证"每次取出当前最优的"。
认为 Dequeue 是 $O(1)$ Peek 是 $O(1)$,Dequeue 是 $O(\log n)$(要下沉恢复堆序)。
一次性排序的场景用优先队列 实测 Array.Sort 快 6.4 倍。优先队列的价值在动态场景。
Dequeue 时忘记清引用 引用类型会内存泄漏(5.1 节的实测教训)。
为大顶堆/小顶堆各写一份代码 IComparer<T> 就行。把策略从机制里分离出来。
修改堆里元素的优先级 会破坏堆序,而且你不知道该上浮还是下沉。用惰性删除或额外维护索引。

九、本节总结

  1. 优先队列 = 动态数组(3.2)+ 堆序维护(11.2)。三个操作:Enqueue $O(\log n)$、Dequeue $O(\log n)$、Peek $O(1)$。
  2. 四个设计决策:泛型、IComparer<T>、清引用、倍增扩容。
  3. 换比较器就换方向:大顶堆和小顶堆只差一个比较器,不需要写两份代码 —— 策略与机制分离
  4. 优先队列不保证 FIFO:入队 3 1 5 2 4,出队 1 2 3 4 5
  5. 优先队列 vs 排序(实测 20,000 元素):8.3 ms vs 1.3 ms排序快 6.4 倍
  6. 判断该用哪个:数据一次性给全 → 排序;边来边处理 → 优先队列。
  7. 动态场景才是主场:10 万次混合负载只用 9.2 ms,而"每次排序一遍"完全不可行。
  8. 修改元素优先级很难:直接改会破坏堆序,且不知道往哪个方向调整。需要惰性删除或额外维护"元素 → 下标"的映射。

下一节衔接:优先队列本身讲完了。但它的杀手级应用还没讲 —— 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 与优先队列的工程用法"
}

results matching ""

    No results matching ""