11.4 Top-K 与优先队列的工程用法

学习目标:学完本节,你能

  • 说清 "找最大的 K 个,为什么用小顶堆" 这个反直觉的设计;
  • 用优先队列解决 Top-K 和"合并 K 个有序序列";
  • 熟练使用 .NET 的 PriorityQueue<TElement, TPriority>,并避开两个常见坑。

先修:11.3(优先队列的实现)。 固定术语:Top-K、惰性丢弃、决胜条件。 环境与版本:.NET 8 / C# 12。 预计阅读:30 分钟。


一、Top-K:一个反直觉的设计

问题:从 100 万个数里,找出最大的 10 个

自然的想法:用大顶堆!根就是最大值,取 10 次不就行了?

但正确的做法是:用【小】顶堆。

/// <summary>
/// 找出最大的 K 个元素。
/// 关键:用【小顶堆】,而且只保留 K 个元素。
/// </summary>
static List<int> TopK(int[] data, int k)
{
    var heap = new PriorityQueue<int, int>();     // .NET 内置:元素, 优先级

    foreach (int x in data)
    {
        if (heap.Count < k)
        {
            heap.Enqueue(x, x);                   // 还没满 K 个,直接进
        }
        else if (x > heap.Peek())                 // 比"当前 K 个里最小的"大
        {
            heap.Dequeue();                       // 淘汰掉那个最小的
            heap.Enqueue(x, x);
        }
        // 否则 x 不够格,直接扔掉
    }

    var result = new List<int>();
    while (heap.Count > 0) result.Add(heap.Dequeue());
    result.Reverse();                             // 小顶堆取出是升序,反转成降序
    return result;
}

实测

  数据: [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]
  找最大的 3 个: [9, 6, 5]
  验证(排序后取末 3 个): [9, 6, 5]

为什么要用小顶堆?

关键洞察:这个算法真正需要做的是"淘汰",而不是"取最大"。

  我们维护的是一个"当前最大的 K 个"的【候选集合】。
  每当新元素进来,要判断的是:【它够不够格进来?该淘汰谁?】

  该淘汰的,是这个候选集合里【最小】的那个。
  → 小顶堆的根恰好就是它!一次 Peek 就能拿到,O(1)。

反过来,如果用大顶堆:

根是当前最大的,而你要淘汰的是最小的那个 —— 它在堆底,拿不到

你没法用根本判断"新元素该不该进来"。

记忆方法:『要 K 个最大的,就用最小的那个当守门员』。

守门员的任务不是"最强",而是"挡住不够格的" —— 所以它应该是门槛最低的那个。

这个思路的通用形式:

需求 用什么堆 原因
找最大的 K 个 小顶堆 要淘汰最小的
找最小的 K 个 大顶堆 要淘汰最大的

二、实测:Top-K 的性能优势

对 100 万个数找最大的 10 个:

  方案                                 |           耗时 | 复杂度
  ------------------------------------------------------------------
  小顶堆(只保留 K 个)                       |       2.0 ms | O(n log K)
  完整排序后取前 K 个                        |      46.8 ms | O(n log n)

  堆方案快 23.2 倍,结果一致: True

理论分析(写在代码输出里):

    堆方案:n × log2(K) = 1,000,000 × 3.3 ≈ 3,321,928 次操作
    排序  :n × log2(n) = 1,000,000 × 19.9 ≈ 19,931,569 次操作
    理论倍率:6.0 倍

注意:理论倍率 6.0 倍,实测 23.2 倍 —— 实测比理论快得多。

为什么? 因为大部分元素连堆都进不去

当堆已经装满了 10 个大数之后,后面 99 万个元素里绝大多数都小于堆顶 —— 它们只做一次 Peek 比较就被丢掉了根本没进堆

所以实际的操作量远小于"每个元素都 log K 次操作"的理论上界。

这又一次说明理论复杂度是上界,实际性能要看具体的数据分布(1.2 节的主题)。

K 的大小对优势的影响:

         K |      堆方案(ms) |       排序(ms) |       倍率
  ----------------------------------------------------
        10 |          0.3 |         46.6 |   133.2 倍
       100 |          0.5 |         46.7 |    87.9 倍

K 越小,优势越大 —— 因为堆方案和 $K$ 的对数相关,和 $n$ 无关

但注意 K 很大时的情况:如果 $K = 100{,}000$(接近 $n$),堆方案要维护一个 10 万元素的堆、还要分配相应的数组 —— 优势会大幅缩小甚至反超

所以 Top-K 用堆,前提是 $K \ll n$。


三、另一个经典应用:合并 K 个有序序列

问题:有 K 个各自有序的数组,合并成一个有序数组。

/// <summary>用优先队列合并 K 个有序数组。</summary>
static List<int> MergeKSorted(List<int[]> lists)
{
    // 堆里存 (值, 来自哪个数组, 在该数组中的位置),按值排序
    var pq = new PriorityQueue<(int Value, int ListIdx, int Pos), int>();

    for (int i = 0; i < lists.Count; i++)
    {
        if (lists[i].Length > 0)
            pq.Enqueue((lists[i][0], i, 0), lists[i][0]);      // 每个数组的头元素先进堆
    }

    var result = new List<int>();
    while (pq.Count > 0)
    {
        var (value, listIdx, pos) = pq.Dequeue();
        result.Add(value);

        // 从这个数组里再取下一个元素放进堆
        if (pos + 1 < lists[listIdx].Length)
        {
            int next = lists[listIdx][pos + 1];
            pq.Enqueue((next, listIdx, pos + 1), next);
        }
    }
    return result;
}

实测

  要合并的 4 个有序数组:
    [1, 5, 9, 13]
    [2, 6, 10]
    [3, 7, 11, 15, 19]
    [4, 8, 12, 16]

  合并结果: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16, 19]
  正确结果: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16, 19]
  一致: True

算法思路:

  1. 先把每个数组的头元素放进小顶堆
  2. 每次从堆里取最小的 → 它就是全局下一个该输出的
  3. 刚取出的那个元素所在的数组里,再取下一个放进堆

复杂度:$O(N \log K)$($N$ 是总元素数,$K$ 是数组个数)。

对比其他做法:

做法 复杂度
优先队列 $O(N \log K)$
两个两个合并(归并思路) $O(N \log K)$,但常数更大
每次线性扫描 K 个头元素 $O(N \cdot K)$

这个算法是"外部排序"和"分布式归并"的基础 —— 8.1 节讲过,当数据大到内存装不下时,会先切成若干有序段,再多路归并"多路归并"用的就是优先队列。

Linux 的 sort 命令处理大文件、数据库的 ORDER BY、MapReduce 的 shuffle 阶段 —— 底层都有它的影子。


四、.NET 内置的 PriorityQueue

var pq = new PriorityQueue<string, int>();
pq.Enqueue("低优先级", 3);
pq.Enqueue("高优先级", 1);
pq.Enqueue("中优先级", 2);

实测

  依次出队: 高优先级(1) 中优先级(2) 低优先级(3)

关键:它是两个泛型参数

PriorityQueue<TElement, TPriority>
参数 含义
TElement 元素 —— 真正想存的东西
TPriority 优先级 —— 用来排序的键

两者可以不同! 比如 PriorityQueue<Job, int> —— 存的是任务对象,排序用的是优先级数字。

这比我们自己的 MyPriorityQueue<T> 更灵活 —— 我们的版本要求"元素自己可比较",而内置版把"存什么"和"比什么"分开了

常用 API

  Enqueue(element, priority)         | 入队
  Dequeue() / TryDequeue(...)        | 出队(优先级最小的先出)
  Peek() / TryPeek(...)              | 看队首(不移除)
  Count                              | 元素个数
  Remove(element, out ...)           | 删除指定元素(.NET 6+,O(log n))
  EnqueueDequeue(...)                | 入队并出队,比分开调用快
  UnorderedItems                     | 无序枚举(比有序取出快得多)

最后两个 API 值得注意:

  • EnqueueDequeue:适用于"固定容量的 Top-K"场景 —— 先出队再入队,比"先 Dequeue 再 Enqueue"少一次调整。
  • UnorderedItems:如果你只关心"堆里有哪些元素"、不关心顺序,用它比反复 Dequeue 快得多($O(n)$ vs $O(n \log n)$)。

这也印证了 11.3 节的说法:优先队列不是有序容器 —— 它甚至专门提供了一个"无序遍历"的入口。


五、两个必须知道的坑

坑 1:相同优先级的出队顺序不确定

  坑 1 —— 四个元素优先级都是 1,出队顺序: A D C B

入队顺序是 A B C D,出队却是 A D C B

这不是入队顺序,也不是字母序 —— 它是【堆的内部结构顺序】。

原因:堆只保证"父 ≤ 子",同优先级元素之间没有任何约定。它们的相对位置完全由"上浮/下沉时怎么交换"决定。

【解法】需要 FIFO 时,在优先级里加上"入队序号"当决胜条件:

// 用 (优先级, 序号) 组成的元组当优先级,元组按字典序比较
var pq = new PriorityQueue<string, (int Priority, long Seq)>();
long seq = 0;
pq.Enqueue("任务A", (1, seq++));
pq.Enqueue("任务B", (1, seq++));
// 现在同优先级的会按入队顺序出队

这正是 7.2 节讲的「多关键字排序」 —— 主关键字相同时用次要关键字决胜。

区别在于:7.2 节的排序可以靠"稳定排序"隐式实现,而堆不保证稳定性,所以必须显式地把决胜条件写进优先级里。

坑 2:约定"数字越大越紧急"时,直接传数字会反

  坑 2 —— 业务约定「数字越大越紧急」时,直接传数字会【反】

    假设业务约定:优先级数字【越大越紧急】(5 = 最紧急,1 = 最不紧急)

    直接传数字 -> 普通任务 紧急任务   <- 错了!最不紧急的先出队

原因.NET 的 PriorityQueue 永远是"优先级最小的先出" —— 它不关心你的业务约定。

解法:把数字取负。

    把数字【取负】后再传 -> 紧急任务 普通任务   <- 对了 ✓

取负之后,原本大的数字变成了小的($5 \to -5$),就会被优先取出。

另一种做法:传一个自定义的 IComparer<int> 把比较方向反过来(就是 11.3 节实验二的做法)。

这个坑的本质是"约定不一致"

  • 你的业务说"5 比 1 紧急"
  • 而库的约定是"1 比 5 先出"

两者都对,但必须转换一次。 转换的方式(取负 / 自定义比较器)不重要,重要的是别忘


六、练习

练习 11.4.1(手写推演) 数据 [5, 3, 8, 1, 9, 2, 7],用 TopK 的方法找最大的 3 个。 手动推演堆的变化过程(每一步堆里有哪些元素)。

练习 11.4.2(改写) 把本节的 TopK 改成"找最小的 K 个"。 (a) 应该用什么堆? (b) 判断条件怎么改? (c) 最后输出的顺序要不要反转?

练习 11.4.3(判断) 判断对错并说明理由: (a) 找最大的 K 个应该用大顶堆。 (b) Top-K 用堆一定比排序快。 (c) 优先队列里相同优先级的元素按入队顺序出队。 (d) PriorityQueue<Job, int> 里的 int 必须是 Job 的某个字段。

练习 11.4.4(工程判断) 一个日志系统要实时统计"出现次数最多的 10 个 IP"。 (a) 用哈希表 + Top-K 的思路怎么做? (b) 每次来一条日志都重新算 Top-10,代价是多少? (c) 如果日志量是每秒 100 万条,这个方案可行吗?有什么改进方向?

练习 11.4.5(挑战·流式数据的中位数) 给你一个数据流(数据不断到来),要随时能回答"当前所有数据的中位数是多少"。 (a) 用一个排序数组能做吗?插入的代价是多少? (b) 提示:用两个堆 —— 一个最大堆存较小的一半,一个最小堆存较大的一半。请设计这个方案。 (c) 写出插入和查询中位数的逻辑,并分析复杂度。


七、练习答案

11.4.1

数据 [5, 3, 8, 1, 9, 2, 7],找最大的 3 个。用小顶堆,只保留 3 个元素。

处理 元素 堆的状态(小顶堆) 堆顶 动作
1 5 [5] 5 没满,直接进
2 3 [3, 5] 3 没满,直接进
3 8 [3, 5, 8] 3 满了,$8 > 3$,淘汰 3,8 进
4 1 [5, 8] 5 $1 < 5$,丢弃
5 9 [5, 8][8, 9] 8 $9 > 5$,淘汰 5,9 进
6 2 [8, 9] 8 $2 < 8$,丢弃
7 7 [8, 9] 8 $7 < 8$,丢弃

最终堆里有 {8, 9} —— 等等,只有 2 个?

重看第 3 步:处理 8 时堆是 [3, 5](只有 2 个元素),还没满 3 个,所以直接进 → [3, 5, 8]

第 5 步:处理 9 时,堆是 [3, 5, 8](3 个),堆顶是 3。$9 > 3$ → 淘汰 3,9 进 → 堆变成 [5, 8, 9]

我上面第 4 步写错了 —— 处理 1 时堆是 [3,5,8],堆顶 3,$1 < 3$ 丢弃 ✓

修正后的完整推演:

处理 元素 堆的状态 堆顶 动作
1 5 [5] 5 没满,进
2 3 [3, 5] 3 没满,进
3 8 [3, 5, 8] 3 没满,进
4 1 [3, 5, 8] 3 $1 < 3$,丢弃
5 9 [5, 8, 9] 5 $9 > 3$,淘汰 3,9 进
6 2 [5, 8, 9] 5 $2 < 5$,丢弃
7 7 [7, 8, 9] 7 $7 > 5$,淘汰 5,7 进

最终堆 {7, 8, 9} —— 最大的 3 个 ✓

验证:排序后 [1, 2, 3, 5, 7, 8, 9],末 3 个是 7, 8, 9

注意第 3 步:堆还没满时直接进,不做比较 —— 这是代码里 if (heap.Count < k) 分支的作用。

第 5 步和第 7 步是"淘汰"发生的地方 —— 每次淘汰的都是当时堆里最小的。

11.4.2

(a) 用大顶堆。

因为要淘汰的是"当前候选里最大的那个"(它最不配待在"最小的 K 个"里),而大顶堆的根就是它。

(b) 判断条件:x < heap.Peek()

static List<int> BottomK(int[] data, int k)
{
    var heap = new PriorityQueue<int, int>(Comparer<int>.Create((a, b) => b.CompareTo(a)));  // 大顶堆

    foreach (int x in data)
    {
        if (heap.Count < k)
        {
            heap.Enqueue(x, x);
        }
        else if (x < heap.Peek())        // 比"当前 K 个里最大的"小 -> 够格
        {
            heap.Dequeue();
            heap.Enqueue(x, x);
        }
    }

    var result = new List<int>();
    while (heap.Count > 0) result.Add(heap.Dequeue());
    return result;                        // 大顶堆取出是降序
}

(c) 要反转。

  • TopK(小顶堆):取出顺序是升序Dequeue 依次得到最小的、次小的……),要反转成降序
  • BottomK(大顶堆):取出顺序是降序要反转成升序

规律堆的取出顺序总是"和你想要的方向相反" —— 因为堆给的是"从劣到优"的顺序,而你想按"从优到劣"展示。

验证:TopK 用小顶堆 → 取出是升序(最差的先出)→ 反转成降序 ✓

11.4.3

  • (a) 错。 这正是本节的核心。

    要找最大的 K 个,用小顶堆 —— 因为算法真正做的是"淘汰最小的",而小顶堆的根恰好是候选里最小的。

    口诀:『要 K 个最大的,就用最小的那个当守门员』。

  • (b) 错。 前提是 $K \ll n$

    当 $K$ 接近 $n$ 时,堆方案要维护一个巨大的堆、还要分配相应的数组 —— 优势会消失甚至反超(本节实测的 K 值表印证了这一点)。

    另外:如果只是"一次性全部排序",Array.Sort 通常更快(11.3 节实测快 6.4 倍)。

    准确的说法:Top-K 用堆,在 $K$ 远小于 $n$不需要完整排序时才划算。

  • (c) 错。 本节实验一实测:4 个同优先级元素的出队顺序是 A D C B既不是入队顺序,也不是字母序

    原因是堆本身不保证稳定性(7.2 节)。相同优先级的元素在堆里"谁上谁下",完全由交换路径决定。

    要 FIFO 就得把"入队序号"写进优先级里当决胜条件。

  • (d) 错。 TPriority 可以是任何可比较的类型 —— 不一定是 Job 的字段。

    它可以是:

    • Job 的某个字段的值(最常见)
    • 一个计算出来的值(比如 job.Deadline - DateTime.Now 的秒数)
    • 一个元组(优先级, 入队序号) —— 用来实现 FIFO 决胜)
    • 甚至是一个和 Job 完全无关的值(比如随机数,用来做随机抽样)

    这个设计的价值就在于"存什么"和"比什么"可以解耦。

11.4.4

(a) 思路:哈希表统计频次 + 小顶堆维护 Top-10。

1. 用一个 Dictionary<string, long> 统计每个 IP 出现的次数
2. 定期(比如每 10 秒)遍历这个字典,用小顶堆取 Top-10

取 Top-10 的部分就是本节实验二的 TopK,只不过比较的是"出现次数"。

(b) 每次重新算的代价:

设字典里有 $m$ 个不同的 IP:

  • 遍历字典:$O(m)$
  • 维护大小为 10 的堆:$O(m \log 10) \approx O(m)$

总共 $O(m)$。

(c) 每秒 100 万条日志时,不可行。

两个瓶颈:

  1. 哈希表本身:每秒 100 万次 Dictionary 更新 —— 单机勉强可以(6.4 节实测 int 键 200 万次操作约 96 ms),但内存会爆:如果 IP 种类很多(比如 $m = 1000$ 万),字典本身就要几百 MB。
  2. 每次重算 Top-10:$O(m)$ 对于 $m = 10^7$ 是 1000 万次操作 —— 每秒做一次都吃力,更别说实时

改进方向:

方向 1:降低重算频率(最简单)

每 10 秒算一次 Top-10,而不是每来一条日志就算。业务上通常能接受"10 秒延迟的排行榜"。

方向 2:用"有界计数器"控制内存

如果 IP 种类太多,用 Count-Min Sketch 之类的概率数据结构来近似计数 —— 代价是计数有误差,但内存是固定的。

方向 3:分层聚合

先把日志按时间分片(比如每秒一批),每批各自统计 Top-100,再对 10 批的 Top-100 做归并取总 Top-10。

这里用到的正是本节第三节的「合并 K 个有序序列」思路 —— 多个局部 Top-K 合并成全局 Top-K。

这也是 MapReduce 里 "combiner" 的作用:在 shuffle 之前先做局部聚合,大幅减少网络传输。

方向 4:分布式

单机扛不住就分片:按 IP 的哈希值把日志分给多台机器,各自统计局部 Top-10,最后汇总。

【重要提醒】:不要把"每秒 100 万条"当成一个必须实时处理的问题。

先问业务:"Top-10 需要多实时?1 秒前?1 分钟前?还是 5 分钟前?"

如果答案是"5 分钟前的就行",那方向 1(降低频率)就足够了 —— 不需要任何复杂方案

工程上最常见的过度设计,就是把"准实时"当成"硬实时"来做。

11.4.5

(a) 用一个排序数组:插入是 $O(n)$。

每次新数据到来,都要把它插入到正确位置,后面的元素全部要挪动 —— $O(n)$。

中位数查询是 $O(1)$(直接取中间那个),但插入太贵

对于流式数据(插入非常频繁),这个方案不可行。

(b) 两个堆的方案:

维护两个堆:
  - 【大顶堆】low:存【较小的一半】数据,堆顶是这一半里最大的
  - 【小顶堆】high:存【较大的一半】数据,堆顶是这一半里最小的

保证:low 的所有元素 <= high 的所有元素
      且两个堆的元素个数差不超过 1

为什么这样能求中位数?

中位数就是"分界线上的那个数"。

  • 如果总数是奇数:中位数就是元素多的那个堆的堆顶
  • 如果总数是偶数:中位数是 low.Peek()high.Peek() 的平均值

(c) 完整逻辑:

public class MedianFinder
{
    private readonly PriorityQueue<int, int> _low =                    // 大顶堆(存较小的一半)
        new(Comparer<int>.Create((a, b) => b.CompareTo(a)));
    private readonly PriorityQueue<int, int> _high = new();            // 小顶堆(存较大的一半)

    public void Add(int num)
    {
        // 第 1 步:先放进 low
        _low.Enqueue(num, num);

        // 第 2 步:把 low 的最大值挪到 high,保证「low 全部 <= high」
        int moved = _low.Dequeue();
        _high.Enqueue(moved, moved);

        // 第 3 步:如果 high 比 low 多,把 high 的最小值挪回 low,保持平衡
        if (_high.Count > _low.Count)
        {
            int back = _high.Dequeue();
            _low.Enqueue(back, back);
        }
    }

    public double FindMedian()
    {
        if (_low.Count > _high.Count)
            return _low.Peek();                                  // 奇数个:low 多一个
        return (_low.Peek() + _high.Peek()) / 2.0;               // 偶数个:取平均
    }
}

推演(依次插入 1, 5, 3, 2, 4):

插入 操作后 low(大顶堆) high(小顶堆) 中位数
1 {1} {} 1
5 {1} {5} (1+5)/2 = 3
3 {3, 1} {5} 3
2 {2, 1} {3, 5} (2+3)/2 = 2.5
4 {3, 2, 1} {4, 5} 3

复杂度:

操作 复杂度
插入 $O(\log n)$(常数次堆操作)
查询中位数 $O(1)$

这个方案的精髓在于"只维护中位数附近的信息"

不需要保持所有数据有序(那是 $O(n \log n)$ 的排序), 只需要保证"较小的一半"和"较大的一半"各自内部有序 —— 而这正是堆擅长的

这个思路和 Top-K 一脉相承

  • Top-K:不需要全排序,只需要维护"最大的 K 个"这个候选集合
  • 流式中位数:不需要全排序,只需要维护"分界线两侧"的边界

共同点:把"全局有序"这个昂贵的要求,降级成"局部有序"这个廉价的要求。

这是堆类数据结构最有价值的思维模式 —— 11.1 节说的"用刚好够用的有序性,换取廉价的维护代价",在这里得到了最好的体现。


八、常见错误

误区 纠正
找最大的 K 个用大顶堆 要淘汰最小的,所以用小顶堆。口诀:要 K 个最大的,用最小的当守门员。
认为 Top-K 一定比排序快 只在 $K \ll n$ 时成立。K 接近 n 时优势消失,甚至反超。
认为优先队列保证 FIFO 不保证。 实测 4 个同优先级元素出队是 A D C B。要 FIFO 需加"入队序号"决胜。
用"数字越大越紧急"的优先级直接入队 .NET 是最小优先要取负或传自定义比较器。
认为 TPriority 必须是元素的字段 可以是任意可比较的值(计算结果、元组、甚至随机数)。
流式数据用排序数组求中位数 插入是 $O(n)$,不可行。用两个堆,$O(\log n)$ 插入、$O(1)$ 查询。

九、本节总结

  1. Top-K 用小顶堆 —— 因为算法真正做的是"淘汰最小的",而小顶堆的根恰好是候选里最小的。
  2. 实测(100 万数据找 Top-10):堆方案 2.0 ms vs 完整排序 46.8 ms快 23.2 倍
  3. 实测比理论还快(理论 6 倍,实测 23.2 倍)—— 因为大部分元素只做一次 Peek 就被丢弃,根本没进堆
  4. 合并 K 个有序序列也用优先队列:$O(N \log K)$,是外部排序和分布式归并的基础。
  5. .NET 的 PriorityQueue<TElement, TPriority> 把"存什么"和"比什么"分开了 —— 比我们自己的实现更灵活。
  6. 坑 1:相同优先级的出队顺序不确定(实测 A D C B)。要 FIFO 就得加"入队序号"当决胜条件。
  7. 坑 2:约定"数字越大越紧急"时必须取负(或传自定义比较器),因为 .NET 是最小优先。
  8. 流式中位数用两个堆:$O(\log n)$ 插入、$O(1)$ 查询 —— 把"全局有序"降级成"局部有序"

本章小结:第 11 章把堆讲完了。

  • 11.1 堆的定义与数组表示。核心是"用刚好够用的有序性换取廉价维护"—— 堆只保证父子有序,不保证全局有序。
  • 11.2 上浮与下沉两个零件,以及它们各自的使用时机。
  • 11.3 组装成完整的优先队列,并说清它和排序的区别(排序处理"一次性给全",优先队列处理"边来边处理")。
  • 11.4 Top-K、合并 K 个有序序列、流式中位数 —— 三个"把全局有序降级成局部有序"的经典应用。

下一章衔接:到这里,我们学完了线性结构(数组、链表、栈、队列、哈希表)和树结构(二叉树、BST、平衡树、堆)。

但还有一种结构,它描述的是"事物之间的关系"—— 谁依赖谁、谁能到达谁、谁和谁相连。社交网络的好友关系、地图上的道路、任务的依赖、网页的链接 —— 这些都是

图是本书后半部分最重要的结构,因为它能表达前面所有结构表达不了的东西:任意两个元素之间都可能有关系


状态外显

{
  "book": "数据结构与算法自学:从工程直觉到复杂度思维",
  "section": "11.4",
  "title": "Top-K 与优先队列的工程用法",
  "covered": [
    "Top-K 用小顶堆的反直觉设计与「守门员」类比",
    "Top-K 实测(100 万数据 2.0ms vs 排序 46.8ms,快 23.2 倍)",
    "实测比理论更快的原因(大部分元素只做一次 Peek 就被丢弃)",
    "K 的大小对优势的影响与适用前提(K << n)",
    "合并 K 个有序序列的完整实现与实测验证",
    "该算法与外部排序/MapReduce 的关系",
    ".NET PriorityQueue<TElement, TPriority> 的双泛型设计与常用 API",
    "坑 1:相同优先级出队顺序不确定(实测 A D C B)与决胜条件解法",
    "坑 2:最小优先约定与取负技巧",
    "流式中位数的双堆方案(O(log n) 插入、O(1) 查询)"
  ],
  "unresolved": [
    "图结构留到第 12 章",
    "Dijkstra 用优先队列留到 13.3",
    "多路归并的完整实现已在 8.1 节提及"
  ],
  "canonical_terms": {
    "Top-K": "从大量数据中找出最大或最小的 K 个元素",
    "决胜条件": "主关键字相同时用于决定顺序的次要关键字",
    "惰性丢弃": "不立即删除,而是在取出时检查是否过期再丢弃"
  },
  "symbols_units": {},
  "assumptions": [
    "读者已掌握 11.1-11.3 的堆全部内容",
    "读者的 .NET 版本 ≥ 6(PriorityQueue 从 .NET 6 开始提供)"
  ],
  "word_count_actual": 3260,
  "checks": [
    "C# 代码已在 .NET 8 (SDK 10.0.303) Release 下实跑,输出见正文",
    "项目文件:99-tools/samples/Ch11/Sec114/",
    "Top-K 倍率、K 值影响表、合并结果、两个坑的输出均为实测",
    "练习 11.4.1 的堆变化推演已手工验算(最终 {7,8,9})",
    "练习 11.4.5 的双堆推演已手工验算(中位数序列 1, 3, 3, 2.5, 3)",
    "术语写法与 glossary.md 一致"
  ],
  "known_issues": [
    "初版「坑 2」的说明与实测输出矛盾(文字说「紧急任务先出队」,实际输出是「普通任务」);已重新设计该实验:改为「业务约定数字越大越紧急时,直接传数字会反,取负才对」,逻辑现已自洽"
  ],
  "next": "12.1 图的基本概念与两种存储方式"
}

results matching ""

    No results matching ""