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
算法思路:
- 先把每个数组的头元素放进小顶堆
- 每次从堆里取最小的 → 它就是全局下一个该输出的
- 从刚取出的那个元素所在的数组里,再取下一个放进堆
复杂度:$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 万条日志时,不可行。
两个瓶颈:
- 哈希表本身:每秒 100 万次
Dictionary更新 —— 单机勉强可以(6.4 节实测int键 200 万次操作约 96 ms),但内存会爆:如果 IP 种类很多(比如 $m = 1000$ 万),字典本身就要几百 MB。 - 每次重算 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)$ 查询。 |
九、本节总结
- Top-K 用小顶堆 —— 因为算法真正做的是"淘汰最小的",而小顶堆的根恰好是候选里最小的。
- 实测(100 万数据找 Top-10):堆方案 2.0 ms vs 完整排序 46.8 ms,快 23.2 倍。
- 实测比理论还快(理论 6 倍,实测 23.2 倍)—— 因为大部分元素只做一次
Peek就被丢弃,根本没进堆。 - 合并 K 个有序序列也用优先队列:$O(N \log K)$,是外部排序和分布式归并的基础。
- .NET 的
PriorityQueue<TElement, TPriority>把"存什么"和"比什么"分开了 —— 比我们自己的实现更灵活。 - 坑 1:相同优先级的出队顺序不确定(实测
A D C B)。要 FIFO 就得加"入队序号"当决胜条件。 - 坑 2:约定"数字越大越紧急"时必须取负(或传自定义比较器),因为 .NET 是最小优先。
- 流式中位数用两个堆:$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 图的基本概念与两种存储方式"
}