阶段 7 · 数据结构 · 第 37 章

堆与 priority_queue

★ 关键一步是上浮 / 下沉各走一条「根到叶」的路,所以是 O(log n)。⚠ 而这一章的 log 和上一章的 α 正好构成一对:α **证不了、只能实测**,这个 log **数一数层数就证完了** —— 而且它的两条界(树高 ⌊log₂ n⌋、建堆 ≤ 2n)都能被实测顶到紧。

例题:维护一个小根堆(插入 / 查询最小 / 删除最小) 建议用时:130 分钟
上一章欠的那个对照,这一章要当场还上

第 36 章章末白纸黑字写的是这个:

★ 关键一步是上浮 / 下沉各 O(log n),而这次的 log 是证得死死的 (完全二叉树的高度就是 log₂ n)—— 正好和这一章那个「证不了、只能实测」的 α 形成对照。 ⚠ 顺带回收第 12 章「第 k 小」那道题的另一种解法。

两件都在下面:证明在第 5、11 步,第 12 章那笔账在第 12 步。

★ 而这个对照本身才是这一章真正的主题:

★★ 第 36 章的 α(n):结论给你,证明这本书不讲,只能拿三条实测曲线顶上; ★★ 这一章的两条界:都能当场证完(一条数层数、一条一行级数), 而且实测都被顶到了紧(树高正好 ⌊log₂ n⌋、建堆的比较次数正好收敛到 2n)。

能证的就证,证不了的就老实说证不了、然后拿实测顶上 —— 第 36 章立的规矩,这一章两边都占了。

1 一句话问题

维护一个小根堆,一开始是空的。接下来 n 次操作(n ≤ 2×10⁵):

  • 1 x:插入 x(|x| ≤ 10⁹);
  • 2:输出当前的最小值;★ 堆为空时输出一个 E
  • 3:删除当前的最小值;堆为空时什么都不做。

★ 全部操作做完之后,再输出一行:把堆里剩下的元素从小到大全部列出来(空堆输出一个 -)。

★ 题面里有两处是故意加的,第 13 步会拿数字说话

这道题的原型是洛谷 P3378,那道题只有前三条。这里多了两样东西:

  1. 「堆为空时输出 E」 —— 空堆是一个题目允许的状态,不是「不会发生」。 随手写的生成器最爱做的事就是替题目把它排除掉(第 27~34 章那条「顺手写法」)。
  2. 最后那一行「把剩下的从小到大列出来」 —— 第 35、36 章那条「题面多问一句,对拍就多一条腿」的第三次现场。

⚠ 而这次的账和第 36 章不一样,得老实分两半写:

顺手写的生成器(档位 0)调狠之后(最终档)
只比前面那几行5 / 19 / 11 / 76 / 0 / 200299 / 297 / 283 / 300 / 299 / 300
★ 加上最后那一行56 / 46 / 88 / 244 / 0 / 255299 / 297 / 290 / 300 / 299 / 300

★★ 多问的那一句,在弱数据上顶得上把生成器调狠一个数量级(11 → 88、76 → 244); 可数据本身够狠之后,它的边际价值就只剩 283 → 290 了。 「多要一行输出」不是万能药,它是给数据不够狠的时候兜底的 —— 这比第 36 章那个「0 → 300」更接近常态,所以两笔账都要摆出来。

2 手算一遍:默认那 15 步

★ 这 15 步里埋了四件事,第 13 步会挨个用到
15
2        → E   ★ 空堆查询
1 9
1 5
2        → 5
1 2
1 8
1 1      ★ 这个 1 要一路上浮两层才到根
2        → 1
3            删掉 1
3            ★ 这一次下沉要在两个儿子里挑小的那个
2        → 5
1 4
3            删掉 4
2        → 5
3            删掉 5

答案:E 5 1 5 5,最后一行 8 9

  • 第 1 步的空堆查询,是「忘了判空」的唯一现场;
  • 第 7 步那个 1(比祖上两代都小),是「上浮只上一层」的现场;
  • 第 10 步那次下沉右儿子比左儿子小,是「下沉只看左儿子」的现场;
  • 而最后那一行 8 9,是「下沉边界差一」和「a[--sz]唯一的现场 —— ★ 这两个 bug 的前五行输出一个字都不错

3 标准答案:一点结构都不攒

brute.cpp标准答案:数堆在 vector 里,每次要最小值就整个扫一遍
输入(stdin)
输出
点「运行 ▶」看结果
★ 为什么标准答案不能「再写一个堆」

正解维护的是一棵完全二叉树,把顺序信息一路攒下来。要是标准答案也维护一棵堆, 两份代码就是同一个思路写了两遍 —— 只能验出打字错误,验不出想法错误 (第 9、15、34、35、36 章那条)。

所以这一份什么都不攒:插入就是往后一放;要最小值就从头到尾扫一遍。

★ 两处刻意为之的「不占便宜」:

  • 扫的时候一次 break 都没有(第 31 章:「找到第一个就 break」会让暴力假装自己不慢);
  • 删除用的是 erase(把后面整段往前搬),不许用「和末尾交换再 pop_back」那种小聪明 —— 那样就不是「什么都不攒」了,而且会打乱顺序,而最后那一行要按从小到大输出。

4 实测:暴力有多慢 —— 顺带打掉「那我维护一个有序数组」这个念头

本机实测./genBig n 0 造的数据:先插 n 个随机数,再一组组「查询 + 删除」倒空):

n(元素个数)brute(每次线性扫)fast(手写堆)stl(priority_queue)
16 0000.09 秒0.00 秒
32 0000.35 秒0.00 秒
64 0001.41 秒0.01 秒
128 0005.80 秒0.02 秒
200 00014.15 秒0.04 秒0.03 秒
g++ -O2 -std=c++17 -o genBig genBig.cpp && g++ -O2 -std=c++17 -o fast fast.cpp
./genBig 200000 0 > big.txt
time ./brute < big.txt      # 14.15 秒
time ./fast  < big.txt      #  0.04 秒
./count read < big.txt      # ★ 只把输入读完就退出:0.02 秒

那一列 brute 的翻倍比是 3.9 / 4.0 / 4.1 —— 干干净净的 Θ(n²)。

⚠ 那 0.04 秒里,有一半是读入 —— 秒表在正解身上已经快失灵了

./count read 只把 60 万条操作读完就退出,什么都不算,要 0.02 秒。 也就是说正解真正花在算法上的时间只有 0.02 秒左右,而且它随机器、随缓存乱跳。

★ 第 29、32、34、35、36 章那条「量之前先确认「你量的就是它」」的第六次。 ⚠ 所以 count.cpp 的读入写法必须和 fast.cpp 一字不差(都是 cin + 关掉 sync_with_stdio), 否则量出来的「读入耗时」根本不是它的读入耗时(第 32 章那条)。

★ 这就是这一章第 9 步要换尺子的直接原因:次数可复现,秒数不可复现。

★ 慢在哪:而且「维护一个有序数组」并没有解决问题

brute 慢的原因一句话:每次要最小值,都要把「谁最小」这件事从零算一遍。

于是很多人的第一反应是:那我维护一个有序数组不就行了?最小值永远在第 0 位,O(1)。

⚠ 实测(count.cpp 里那一行 sorted,n = 16 000):

写法比较次数移动次数
linear(每次扫一遍)2.56 亿6 384 万
sorted(维护有序数组)20 万1.92 亿
fast(堆)40 万26 万

★★ 两个 O(n²) 的做法,烂在完全不同的地方。 linear 烂在比较(每次都要全扫),sorted 烂在移动(插一个数要把后面整段往后挪)。 有序数组只是把工作量从一列搬到了另一列,一个数量级都没省下来。

★ 而堆之所以能赢,是因为它两列同时是 O(log n) —— 它维护的不是「全序」,而是刚好够用的那点顺序:只保证「爸爸 ≤ 儿子」。 要什么就只维护什么,这是这一章真正的思想。

sorted 那 20 万次比较是二分查插入位置来的 ≈ n log n; 翻倍比:linear 的比较 4.00、sorted 的移动 4.01,两个都是标准的平方。)

5 ★ 关键一步(一):一棵完全二叉树塞进数组 —— 树高就是 ⌊log₂ n⌋

★★ 这一章的 log 是「数出来的」,不是估出来的

堆是一棵完全二叉树:除了最后一层,每层都填满,最后一层的点靠左排。 把它按层从上到下、每层从左到右编号 1, 2, 3, …,塞进一个数组:

下标 i 的左儿子 = 2i        右儿子 = 2i + 1        爸爸 = i / 2(整除)

⚠ 下标从 1 开始,这三个公式才这么干净(0 基要写成 2i+1 / 2i+2 / (i−1)/2,能用但难记)。 根本不需要指针,一个数组就是一棵树。

★ 那它有多高?数一数就完了:

第 0 层  1 个        累计 1
第 1 层  2 个        累计 3
第 2 层  4 个        累计 7

第 h 层  2^h 个      累计 2^(h+1) − 1

前 h+1 层一共 2^(h+1) − 1 个点,所以 n 个点的完全二叉树高度就是 ⌊log₂ n⌋。∎

★★ 请把它和上一章摆在一起看: 第 36 章那条 O(α(n))均摊出来的,完整证明要用势函数分层,那一章明说了「不证」; 这一章这条界三行就数完了,而且它是最坏情况的界,不是均摊的。 两章的 log 长得很像,来路完全不同。

n = 2×10⁵ 时,这个高度只有 17

6 ★ 关键一步(二):上浮与下沉 —— 各走一条「根到叶」的路

★ 插入:先放到最后一格,再往上爬
a[++sz] = x;      // 完全二叉树的下一个空位只有一个,就是最后一格
up(sz);           // 再让它爬到该去的地方

为什么必须先放最后一格:完全二叉树的形状是死的,能加点的位置只有那一个。 先保住形状,再修顺序 —— 这是堆的两个不变量,任何一步都不能同时破坏两个。

void up(int i) {
    long long x = a[i];                      // ★ 空穴法:把它抱在手上
    while (i > 1 && a[i >> 1] > x) {
        a[i] = a[i >> 1];                    // 爸爸落到空穴里
        i >>= 1;
    }
    a[i] = x;                                // 放下
}
★ 删除:把最后一格搬到根上,再往下沉
a[1] = a[sz--];   // 根被删了,形状要保住 —— 只能拿最后一格来填
if (sz) down(1);  // 再让它沉到该去的地方
void down(int i) {
    long long x = a[i];
    while ((i << 1) <= sz) {
        int c = i << 1;
        if (c < sz && a[c + 1] < a[c]) c++;  // ★ 两个儿子里挑**小**的那个
        if (a[c] >= x) break;                // 已经就位
        a[i] = a[c];
        i = c;
    }
    a[i] = x;
}

⚠ 那句「两个儿子里挑小的」是这一章最容易漏的一行,而且漏了不会报错: 下沉的目的是「把这一格换成它这棵子树里最小的」,只跟左儿子换的话, 右儿子可能比换上来的还小 —— 堆顶就不再是全局最小值了(第 13 步有现场)。

★ 于是 O(log n):因为它们走的都是一条「根到叶」的路
  • up 每次 i → i/2,只往上走;
  • down 每次 i → 2i2i+1,只往下走。

两者走过的下标序列,都是树上一条从根到叶的路的一段 —— 长度不超过树高,也就是 ⌊log₂ n⌋。

★ 这就是整条证明。不需要均摊、不需要势函数、不需要「大多数时候很便宜」这种话 —— 它是每一次都成立的最坏情况界。(对比第 36 章:那里的 α 只在均摊意义下成立, 单独某一次 find 完全可以很贵。)

fast.cpp正解:手写小根堆(空穴法)
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 为什么用「空穴法」而不是一路 swap

一次 swap 是三次赋值。可上浮 / 下沉路上那个被挪动的元素,最后总要落在某一格, 中间那些位置它只是路过 —— 没必要每层都把它搬进搬出。

空穴法就是:先把它抱在手上(int x = a[i]),沿路只把别人往空穴里搬,最后再放下。 每层一次赋值,不是三次。

★ 实测(n = 16 000):比较次数一次都不差(都是 403 988),移动次数 637 382 vs 265 793,差 2.4 倍。

⚠ 而这个差别对拍一辈子也看不见 —— 两种写法的答案完全一样。 这是第 36 章那个「随机对拍的第四个盲区」在这一章的现场,第 9 步用计数器把它照出来。

7 比赛里真正会写的那三行:priority_queue

stl.cpp同一道题,改用 STL —— 和上面那份 300 轮逐字节相同
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 默认是大根堆 —— 少写一个 greater 就是在回答另一道题
priority_queue<long long, vector<long long>, greater<long long>> q;   // ★ 小根堆
priority_queue<long long> q;                                          // ✗ 这是大根堆

三个模板参数要一起写全:元素类型、底层容器、比较器。 记不住的话有个笨办法:存的时候取负、取出来再取负(最大的 −x 就是最小的 x)。

⚠ 少写 greater<> 得到的不是一份坏代码,是一份好代码在答另一道题 —— 第 13 步那个 wrongMax.cpp 就是它,而且它是本教材第十三条恒等式

★ 还有一件容易忘的事:priority_queue 没有「遍历」这个操作。 堆只保证 top() 是最小的,底下那些在数组里是什么顺序,标准不作任何承诺 —— 所以最后那一行只能一个个 pop 出来,而那本身就是一次堆排序

8 ★ 动画:完全二叉树 ↔ 数组,高亮的那条路就是「log」

完全二叉树 ↔ 数组:高亮的那条路,就是「O(log n) 里的 log」
全程比较 16 次(上浮 8 / 下沉 8)
第 1 / 16 步
这一步比较 0移动 0★ 累计比较 0累计移动 0堆里 0 个
(堆是空的)
数组视图(下标从 1 开始:儿子是 2i 和 2i+1,爸爸是 i/2)
高亮 = 这一步那个元素走过的下标(一条根到叶的路) ★ 结论:开局
开局:堆是空的

上面是完全二叉树,下面是同一片数据的数组视图 —— 它们本来就是同一个东西。 高亮的是这一步那个元素走过的下标。

★ 这个动画只有一件事要看:那条高亮的路有多长

播一遍你会看到:

  • 插入时高亮从最后一格往上,删除时从根往下 —— 无论哪种,它都是一条根到叶的路;
  • 右上角的「累计比较」涨得非常慢:默认那 15 步一共只比了 16 次(上浮 8、下沉 8);
  • 把数据切到「★ 递减插入 12,11,…,1」那一档 —— 每个新来的数都要一路爬到根, 那是第 10 步要说的「上界唯一被顶紧」的时候;
  • 再切到「⚠ 递增插入」和「⚠ 全都一样」两档 —— 上浮一步都不走, 因为判断是 a[爸爸] > x,相等就停。

⚠ 动画和 trace.cppcheck:viz 里是逐步对的:每一步走过的下标、 这一步比了几次、搬了几次、累计多少、以及那一刻的整个数组,全部逐字节比 (不只对最终答案 —— 第 21 章以来那条规矩)。

trace.cpp动画照着它画:每一步的路径 / 比较 / 移动 / 数组

9 ★ 换尺子:数比较、数移动 —— 秒表看不见的东西它都看得见

count.cpp四种写法并排跑,数比较和移动(附「只读入」开关)
输入(stdin)
输出
点「运行 ▶」看结果

口径写在代码开头,正文里也说一遍,否则这张表没法读:

  • 比较次数只数元素之间的比较(a[x] < a[y]),下标之间的(i > 1c < sz)不算 —— 它们和数据规模无关,是循环的记账;
  • 移动次数数的是给数组格子赋值的次数,一次 swap 记 3 次
  • ⚠ 最后那一行「把剩下的倒出来」也是实打实的开销,照样计进去 (第 29、36 章那条:别把开销偷偷藏起来)。
★★ 四条曲线(`./genBig n 0`,随机数据)
nlinear 比较sorted 移动swapHeap 移动fast 比较fast 移动
1 000999 000742 56627 57517 14312 524
2 0003 998 0002 984 74261 51338 43227 170
4 00015 996 00011 988 153135 23684 94358 411
8 00063 992 00047 997 871294 880185 973124 959
16 000255 984 000192 167 869637 382403 988265 793
n 翻倍它翻几倍4.004.012.152.202.15
for n in 1000 2000 4000 8000 16000; do ./genBig $n 0 | ./count csv; done
  • 4.00 / 4.01 是 Θ(n²) 的签名;2.20 / 2.15 是 n log n 的签名(略微超线性)。
  • swapHeap 和 fast 的比较次数五个规模逐字节相同(403 988 = 403 988)—— 一路 swap 只是多搬东西,一次都没多比。

★★ 而这整张表,对拍一个数字都看不见:四种写法在这五个规模上答案完全一样。 这是第 36 章那个「随机对拍的第四个盲区:它看不见慢」的第二次现场。 ⚠ 差别在于上一章那五份代码是「同一个算法的不同写法」, 这一章的 linear / sorted 干脆是另外两个算法 —— 盲区比想象的还宽。

10 ★ 那条 log 上界,只有「递减插入」才顶得到

同一个正解、同样的 16 000 个元素、同样的操作条数和顺序,只换「插入的是哪些数」:

形状上浮比较上浮 ÷ 插入次数下沉比较
0 随机36 4932.28367 495
1 递增 1,2,3,…15 9991.00368 388
2 ★ 递减 n,n−1,…191 63111.98359 263
3 全相等15 9991.0031 995
for s in 0 1 2 3; do ./genBig 16000 $s | ./count csv fast; done
★★ 上界证出来了,可随机数据永远碰不到它

log₂(16000) = 13.97。看那一列「上浮 ÷ 插入次数」:

  • 递减插入 11.98 —— 每个新数都是当前最小,每次都要一路爬到根。 第 i 个元素插在深度 ⌊log₂ i⌋,平均下来正好是 log₂ n − 2 左右。上界在这里被顶紧了。
  • 随机 2.28 —— ★ 平均只爬两步多一点,而且这个数几乎不随 n 变 (1 000 到 16 000 只从 2.16 涨到 2.28)。 道理很直白:完全二叉树一半的格子在最后一层,随机来的数十有八九停在原地。
  • 递增 / 全相等 1.00 —— 正好是 n−1 次比较(第一个元素不比,其余每个比一下就停)。

★★ 第 33 章那条「要证明一个下界是紧的,就得自己造出那个最坏情况」的第二次现场, 而这一次的反差更刺眼:随机数据上,push 的实测代价是一个常数 —— 光看那一列你会以为 push 是 O(1)。 ⚠ 这就是随机对拍的第三个盲区:下界紧不紧,随机数据答不了。

★ 再看「全相等」那一列的下沉:31 995 ≈ 2n,比另外三档少了十倍 —— 因为 a[c] >= x 遇到相等就 break,整道题退化成 O(1)。

⚠ 顺带划一条边界:值域压小在这一章是「退化端」,不是灵魂。 第 22 章值域小是灵魂(那个 bug 依赖相等)、第 26 章正好反过来 —— 每道题都得重新问一遍「这个 bug / 这条界依赖的到底是什么」。 第 13 步那张表会用数字兑现这句话:把值域压小,抓获率反而从 290 掉到 224。

genBig.cpp(四种形状)操作条数、顺序完全相同,只有「插入的是哪些数」不同
⚠ 「这四组数据只差一件事」这句话,本身也写成了断言

上面那张表能成立的全部依据,是「四种形状的操作条数、种类、先后顺序完全一样」。 第 36 章在这件事上被抓过一次(生成器里合并和查询共用了一个随机数流),所以这一章直接补了断言: check:viz 里有一条检查 四份数据把「插入的那个数」抹掉之后逐字节相同

「这两组数据只差一件事」这种前提,不要凭代码看起来对就写进正文。

11 ★ 关键一步(三):建堆只要 O(n) —— 而且这条界也是紧的

给你 n 个数,要把它们变成一个堆。最直白的办法是一个个 pushO(n log n)。 但有一个几乎白送的办法:

for (int i = n / 2; i >= 1; i--) down(i);     // ★ 就这一行
★★ 为什么从 n/2 开始、为什么倒着走
  • 从 n/2 开始:下标大于 n/2 的格子没有儿子,它们本身就是合法的堆(一个点的树)。 ★ 一半的点白送 —— 这就是 O(n) 的来源。
  • 倒着走down(i) 要求它的两棵子树已经是堆了。 「依赖谁,就先填谁」——第 21、26、27、28 章那句话在这里第八次登场。

为什么是 O(n):一行级数就完了。 高度为 h 的点最多 `⌈n / 2^(h+1)⌉ 个,每个最多往下走 h 层:

Σ_{h≥0}  h · n / 2^(h+1)  =  (n/2) · Σ_{h≥0} h / 2^h  =  (n/2) · 2  =  n

Σ h/2^h = 1/2 + 2/4 + 3/8 + … = 2,那个经典的级数。) 每层最多 2 次比较 ⇒ 比较次数 ≤ 2n。∎

★ 直觉版更好记:大多数点很矮。 真正要走 log n 层的只有根那一个点, 而离叶子只有一两层的点占了绝大多数 —— 「树高 log n」和「平均高度 O(1)」不矛盾。

★ 自底向上建堆:i 从 n/2 倒着数到 1 —— 灰色那一半一次都没被碰过
一个个 push 19 次比较 ★ 自底向上 17 次
第 1 / 7 步
这一步 down()本步比较 0★ 累计比较 0对照:一个个 push 要 19
8
3
11
6
1
9
4
12
7
2
10
5
8
1
3
2
11
3
6
4
1
5
9
6
4
7
12
8
7
9
2
10
10
11
5
12
灰色 = 下标 > 6,没有儿子,一次都没被碰过(一半的点白送,这就是 O(n) 的来源)。 ★ 两种建法造出来的数组不一样, 但堆排序倒出来完全相同 —— 所以对拍看不见这个差别。
开局:12 个数原样倒进数组。下标大于 6 的格子没有儿子,它们本来就是合法的堆
build.cpp两种建法并排:比较 / 移动 / 建出来的数组 / 排序结果
输入(stdin)
输出
点「运行 ▶」看结果
★★ 实测:那条「≤ 2n」被顶到了 2.00

递减的数组(./genArr n 2,一个个 push 的最坏情况):

n一个个 push 的比较÷ n★ 自底向上的比较÷ n
1 0007 9877.991 9821.98
2 00017 9648.983 9801.99
4 00039 9179.987 9781.99
8 00087 82210.9815 9762.00
16 000191 63111.9831 9742.00
for n in 1000 2000 4000 8000 16000; do ./genArr $n 2 | ./build csv; done

★★ 左边那一列 n 每翻一倍就正好 +1.00 —— 这就是 log 在实测里长的样子, 干净得像是编出来的(⌊log₂ n⌋ 每翻倍也正好 +1)。 ★★ 右边那一列收敛到 2.00 —— 正好是上面那条 Σ h/2^h = 2 算出来的界。 证出来的常数,和实测出来的常数,是同一个 2。

⚠ 但换成随机数组,两种建法几乎打平(2.16→2.28 对 1.84→1.88)—— 又是第 10 步那句话:上界要专门造数据才顶得到。

⚠ 而「用了哪种建法」,对拍一个字都看不见

默认那 12 个数(8 3 11 6 1 9 4 12 7 2 10 5):

一个个 push 建出来的:  1 2 4 7 3 5 9 12 8 6 10 11      (19 次比较)
★ 自底向上建出来的:    1 2 4 6 3 5 11 12 7 8 10 9      (17 次比较)

两个数组不一样 —— 它们是两棵不同的树,但都是合法的小根堆。 而把它们分别堆排序倒出来:完全相同check:viz 里在十组数据上钉着这条)。

★★ 所以「建堆用了哪种方法」和「空穴法还是 swap」是同一类东西: 只影响开销、不影响答案,对拍原理上全都抓不到(第 36 章第四个盲区)。 想看见它们,只有一条路:换尺子,数次数。

genArr.cpp建堆那一节的数组生成器(四种形状)

12 ★ 还第 12 章的账:第 k 小的第三种解法

第 12 章讲分治时给了两种解法:排序 O(n log n)、快速选择平均 O(n)。 第 36 章章末答应过「讲完堆再回来补第三种」(那一章的预告里白纸黑字写着),这就是那一份:

维护一个大小为 k 的「大根堆」:堆里始终装着「到目前为止最小的那 k 个数」。 新来一个 x,堆满了就和堆顶(这 k 个里最大的)比 —— x 更小就换掉堆顶,否则直接丢。 扫完之后堆顶就是第 k 小O(n log k)

heapKth.cpp★ 要第 k 小,用的却是大根堆 —— 堆顶是「守门员」
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 要「第 k 小」却用「大根堆」,这一步最容易想反

记法:堆顶是守门员,它盯着的是「目前排第 k 名的那个」—— 所以门口站的必须是这 k 个里最大的那一个,新来的只要比守门员小就能挤进来。

★ 输入输出和第 12 章那两份完全一样,所以三份可以直接互相对拍 —— 而且这是跨章节对拍:第 12 章的生成器 genSelect.cpp 一个字都没改就拿来用了 (check:viz 里 300 轮全绿)。

★★ 三种解法的真正分工:不是「谁更快」,是「它需要什么条件」

本机实测./genKth 5000000 k,n = 500 万):

k排序(第 12 章)快速选择(第 12 章)★ 堆(本章)
100.52 秒 / 42 752 KB0.23 秒 / 42 872 KB0.18 秒 / 3 940 KB
1 0000.51 秒 / 42 932 KB0.23 秒 / 42 872 KB0.18 秒 / 4 028 KB
100 0000.51 秒 / 42 932 KB0.23 秒 / 42 904 KB0.23 秒 / 4 916 KB
2 500 0000.51 秒 / 42 936 KB0.22 秒 / 42 872 KB1.19 秒 / 36 728 KB
./genKth 5000000 10 > k.txt
/usr/bin/time -f "%e 秒  %M KB" ./heapKth < k.txt

交叉点在 k ≈ 10 万(第 24 章「二进制拆分 vs 单调队列交叉点在 k ≈ 100」的同款分寸):

  • k 小的时候堆又快又省内存(内存差十倍:3.9 MB vs 42.8 MB);
  • k = n/2 时堆是三份里最慢的一份(1.19 秒),因为它那个 log k 已经等于 log n 了。

⚠ 但真正让堆不可替代的不是这张表,是它需要的条件最少

★★ 它从头到尾只看每个数一眼,不需要回头。 快速选择做不到这件事 —— 它必须把整个数组抓在手里反复划分。 所以数据是一个个流过来、来了就扔(在线 / 数据流)的时候,只有堆能做。

顺带一个漂亮的数字:n = 500 万、k = 10 时,真正进过堆的只有 143 个数./heapKth cnt)—— 越往后「比守门员还小」越难发生。

「哪个更快」不是唯一的问题,「它需要什么条件」同样是问题。

13 ★ 对拍与生成器:六个 bug,十个档位,和两个被实测打脸的直觉

★ 六个错误版本,以及它们在默认那 15 步上的样子
故意写错的地方默认数据上的输出错在哪一行
wrongDown 下沉只看左儿子E 5 1 8 8 + 5 9第 4、5 行和最后一行
wrongUp 上浮只上一层(while 写成 ifE 5 2 5 4 + 8 9第 3、5 行
wrongLast 下沉边界 <= sz 写成 < szE 5 1 5 5 + 9 8只有最后一行
wrongPop a[sz--] 写成 a[--sz]E 5 1 5 5 + 5 5只有最后一行
wrongEmpty 忘了判空堆0 5 1 5 5 + 8 9只有第一行
wrongMax 比较方向全反E 9 9 5 4 + 2 1除了第 4 行全错

★★ 六个里有三个只错在一行上,而其中两行正是题面「多问的那一句」。 这不是巧合,是第 1 步那个设计的直接兑现。

wrongDown.cpp✗ 下沉时只和左儿子比
wrongUp.cpp✗ 上浮只上一层(while 写成了 if)
wrongLast.cpp✗ 下沉边界差一 —— 前五行输出一个字都不错
wrongPop.cpp✗ a[sz--] 写成 a[--sz],每删一次凭空少一个元素
wrongEmpty.cpp✗ 忘了判空堆 —— 生成器的照妖镜
★★ 第十三条恒等式:比较方向写反 ≡「维护最大值」那道题
wrongMax.cpp✗ 方向全反 —— 它其实是一份完全正确的大根堆
输入(stdin)
输出
点「运行 ▶」看结果

★★ 把输入里所有的数取负,交给正解跑,再把输出里所有的数取负 —— 和这一份逐字节相同。 (最大的 x 就是最小的 −x;最后那一行「从大到小」也正好是「从小到大」取负。) check:viz 里 300 轮钉着这条,一轮不差。

前十二条恒等式在第 23~28、34、35、36 章。这一条和它们的形状一样: 一个 bug 精确地解了另一道题 —— 而这一次那「另一道题」就是少写一个 greater<> 的后果。

⚠ 请注意它和第 36 章第十二条的区别:那一条两边是开销(跳步数相同), 这一条两边是答案(逐字节相同,只是要先做一次取负)。

对拍器
★ 这个生成器一共五个旋钮,其中两个单独加是负分、合起来却是最好的一档,还有一个照着第 22 章的经验加进来、量完只能撤回。下面两张表把每一处的账都摆出来。

300 轮实测(种子 1..300,最终档 8):

故意写错的地方被抓第几轮
wrongPopa[--sz]300 / 300第 1 轮
wrongMax(方向全反)300 / 300第 1 轮
wrongDown(只看左儿子)299 / 300第 1 轮
wrongEmpty(忘了判空)299 / 300第 1 轮
wrongUp(只上浮一层)297 / 300第 1 轮
wrongLast(边界差一)290 / 300第 1 轮
第 9、11 步那些「只影响开销」的写法0 / 300
★★ 第一张表:五个旋钮,一次只改一处(全部从「顺手写法」出发)

gen.cpp 带了十个档位(./gen 种子 档位),种子固定 1..300:

档位相对档位 0 改了什么下沉上浮边界a[--sz]判空方向
0(顺手写法)n ∈ [6,12]、值域 [1,100]、插 40/查 30/删 30,⚠ 顺手保证不会碰到空堆5646882440255
1值域压到 [1,6]4135472060251
2值域拉到 [1,10⁹]5841812420256
3操作序列拉长 n ∈ [300,500]3003001533000300
4放开那个「顺手」:删除照发453165209171218
5删除比例提到 45%(插 35 / 查 20 / 删 45)3125622090224

档位 0 那个 0 是这一章的起点。 我随手写生成器时做了一件完全没过脑子的事: 堆空的时候就改成插入 —— 听起来很合理(「不然删什么呢」), 可题面白纸黑字写着「堆为空时输出 E」。

★ 第 27~34 章那条「生成器里那个不假思索的顺手写法,会悄悄给数据加一条题目里没有的性质」, 这一章第九次。而这次加的那条性质是:「堆永远非空」。 写生成器前先问一遍:题目到底允不允许?我是不是替它做了主?

档位 1 是第二记耳光:我照第 22 章的经验先把值域压到 [1,6](那一章「值域小才是灵魂」), 实测六列全线下降(88 → 47 最惨)。原因在第 10 步已经量过了: 值一并列,下沉那句 a[c] >= x 立刻 break,路径变短,堆根本长不起来。

档位 4 和 5 那两列刺眼的下降,是这张表最值钱的地方,下一张表见分晓。

★★ 第二张表:两个「单独看是负分」的旋钮,合起来是最好的一档
档位内容下沉上浮边界a[--sz]判空方向
63 + 4(拉长 + 允许空堆)300300161300218300
76 + 5(删除比例提上去)297299278300299300
8(最终档)7 + 2(值域也拉开)299297290300299300
9(对照)8 + 1(把「值域压小」放回来)296299224300299300
  • 档位 4(允许空堆)单独加的账要老实写:它把「忘了判空」从 0 抬到 171, 代价是另外五列一起掉(56→45、46→31、88→65、244→209、255→218)。

    ★ 第 30 章那条:把 0 变成非 0,掉多少抓获率都划算 —— 0 / 300 是「完全测不到」,171 / 300 是「第 2 轮就抓住」,两者不是量的差别。

  • 档位 5(多删)单独加是全场最差的一档(31 / 25 / 62 / 209 / 0 / 224), 可它加在档位 6 上是 161 → 278。 道理其实很直白:序列不够长时多删只会让堆一直空着(空堆上谁都输出 E, 第 31 章那条「某一支占得太多会吃掉别人」);序列够长了,多删才等于让堆的形状多变几次形

    ★★ 第 32、34、35、36 章那条「调优不可加:一处改动值不值得留,取决于其它旋钮此刻在哪」 的第五次,而且这次和第 36 章那次一模一样刺眼:一处单独看是负分的改动,最后被留下了。

  • 档位 2(值域拉开)单独加几乎一个数没变(56→58、46→41、88→81), 加在档位 7 上却是 278 → 290。同一条规律的另一面。
  • 档位 9 是在最终环境里重新量「值域压小」(第 34、35 章那条):290 → 224, 在裸环境里有害,在最终环境里还是有害 —— 撤回。 ⚠ 但它值得留成一个档位:「有害」这件事也要可复现。

★ 最后定档的标准照旧:让最弱的那一支尽量强(不是平均分最高)—— 最弱的一直是 wrongLast,档位 8 把它顶到了 290。

gen.cpp(十个档位)五处改动全部可重跑,包括两次「本以为聪明」的失败
★ 最后那一行输出值多少 —— 两笔账都要摆

把同一批数据只比前面那几行(把最后那行剩余元素去掉):

档位下沉上浮边界a[--sz]判空方向
0(顺手写法) 只比前几行51911760200
0 ★ 加上最后那一行5646882440255
8(最终档) 只比前几行299297283300299300
8 ★ 加上最后那一行299297290300299300

★★ 在弱数据上,多问的那一行把抓获率抬了 5~11 倍(11 → 88、76 → 244); 在已经很狠的数据上,它只值 283 → 290。

⚠ 这和第 36 章那个「0 → 300」不一样,而不一样的地方才是新学到的东西「题面多问一句」是给数据不够狠的时候兜底的,不是万能药。 而现实里你的生成器多半就是档位 0 那个样子 —— 所以它照样值得加。

14 这一章可以带走的六样东西

★ 关键的一步

【1】★★ 一棵完全二叉树,一个数组就够了。 下标 i 的儿子是 2i / 2i+1,爸爸是 i/2 —— 不需要指针。 而它的高度数一数层数就证完了2^(h+1) − 1 ≥ n ⇒ 树高 = ⌊log₂ n⌋(n = 2×10⁵ 时只有 17)。 ★ 上浮 / 下沉都只走一条根到叶的路,所以各是 O(log n) —— 这是最坏情况的界,不是均摊的。

【2】★★ 这一章的 log 和上一章的 α,是「能证」和「证不了」的一对。 第 36 章:α(n) 只给结论 + 三条实测曲线,明说「这一章不证」; 这一章:两条界都当场证完(数层数 / 一行级数),而且实测都被顶到了紧 (树高正好 ⌊log₂ n⌋、建堆的比较次数正好收敛到 2n)。

能证的就证,证不了的就老实说证不了、然后拿实测顶上。

【3】★ 建堆自底向上只要 O(n),因为「大多数点很矮」。 for (i = n/2; i >= 1; i--) down(i) —— 从 n/2 开始(一半的点没有儿子,白送), 倒着走(「依赖谁,就先填谁」第八次登场)。 Σ h/2^h = 2 ⇒ 比较次数 ≤ 2n,实测递减数据上正好是 2.00 n

【4】★★ 上界证出来了,不等于随机数据碰得到它。 push 的 O(log n),随机数据上实测只花 2.28 次比较(而且几乎不随 n 变), 只有递减插入才顶到 11.98 ≈ log₂n − 2。

第 33 章那条「要证明一个下界是紧的,就得自己造出那个最坏情况」的第二次现场。

【5】★★ 对拍看不见「慢」—— 这一章的现场比上一章还宽。 空穴法 vs 一路 swap(移动次数差 2.4 倍)、两种建堆方式(比较次数差 6 倍)、 甚至 linear / sorted 这两个完全不同的算法 —— 答案全都一模一样。 ★ 尺子只能是计数器(比较次数 / 移动次数):次数可复现,秒数不可复现, 而秒表在正解身上已经快失灵了(20 万元素跑完全程 0.04 秒,其中 0.02 秒是读入)。

【6】★ 生成器:这一章又有两个直觉被实测打脸。

  • 「顺手保证堆不为空」让「忘了判空」拿到 0 / 300(顺手写法第九次);
  • 照第 22 章的经验「把值域压小」,六列全线下降 —— 「值域小才是灵魂」不是普适规律
  • 「多删」单独加是全场最差(最弱支 25),配上「序列拉长」却把最弱支从 161 抬到 278 —— 调优不可加,第五次。

★ 而「题面多问一行」这件事,这一章给了一笔比第 36 章更接近常态的账: 弱数据上它值 5~11 倍,强数据上只值 283 → 290。两笔都要写。

下一章预告

第 38 章:树状数组

★ 关键一步是 lowbit 管辖的那一段区间,以及一句话:它就是「可以修改的前缀和」 (接第 6 章那张选择表 —— 那一章的前缀和一旦要改就得整段重算)。 ⚠ 对拍会做两件事:拿第 6 章的前缀和 + 暴力修改当标准答案; 再用它重做一遍第 11 章的逆序对,和第 11 章那份归并版互相对拍 —— 这一章的「第 k 小」已经开了跨章节对拍的头,下一章会连着两次。

15 自测

自测清单0 / 14
配套练习
  • 洛谷 P3378 【模板】堆 —— 本章那道题去掉最后一行输出。先手写一遍,再用 priority_queue 写一遍,两份互相对拍
  • 洛谷 P1090 [NOIP2004 提高组] 合并果子 —— ★ 堆 + 贪心的入门题(哈夫曼)。回头看第 19、20 章:这个贪心为什么对,交换论证还在不在
  • 洛谷 P1177 【模板】排序 —— ★ 用堆排序过一遍:建堆 O(n) + n 次 pop。顺带体会「原地排序、不要额外空间」
  • 洛谷 P1801 黑匣子 —— ★★ 对顶堆:一个大根堆 + 一个小根堆,中间卡着第 k 小。想清楚两边什么时候要互相倒一个过去
  • 洛谷 P1168 中位数 —— ★★ 对顶堆最经典的用法,和上一题是同一招。做完这两道,堆才算真的会用
  • 洛谷 P2085 最小函数值 —— ★ 多路归并:n 个递增序列里取前 m 小。堆里永远只放 n 个候选 —— 和本章「大小为 k 的堆」同源
  • 洛谷 P1631 序列合并 —— ★ 上一题的双序列版。想清楚「为什么不用把 n² 个和全造出来」
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)