第 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:删除当前的最小值;堆为空时什么都不做。★ 全部操作做完之后,再输出一行:把堆里剩下的元素从小到大全部列出来(空堆输出一个
-)。
这道题的原型是洛谷 P3378,那道题只有前三条。这里多了两样东西:
- 「堆为空时输出 E」 —— 空堆是一个题目允许的状态,不是「不会发生」。 随手写的生成器最爱做的事就是替题目把它排除掉(第 27~34 章那条「顺手写法」)。
- 最后那一行「把剩下的从小到大列出来」 —— 第 35、36 章那条「题面多问一句,对拍就多一条腿」的第三次现场。
⚠ 而这次的账和第 36 章不一样,得老实分两半写:
| 顺手写的生成器(档位 0) | 调狠之后(最终档) | |
|---|---|---|
| 只比前面那几行 | 5 / 19 / 11 / 76 / 0 / 200 | 299 / 297 / 283 / 300 / 299 / 300 |
| ★ 加上最后那一行 | 56 / 46 / 88 / 244 / 0 / 255 | 299 / 297 / 290 / 300 / 299 / 300 |
★★ 多问的那一句,在弱数据上顶得上把生成器调狠一个数量级(11 → 88、76 → 244); 可数据本身够狠之后,它的边际价值就只剩 283 → 290 了。 「多要一行输出」不是万能药,它是给数据不够狠的时候兜底的 —— 这比第 36 章那个「0 → 300」更接近常态,所以两笔账都要摆出来。
2 手算一遍:默认那 15 步
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 标准答案:一点结构都不攒
点「运行 ▶」看结果
正解维护的是一棵完全二叉树,把顺序信息一路攒下来。要是标准答案也维护一棵堆, 两份代码就是同一个思路写了两遍 —— 只能验出打字错误,验不出想法错误 (第 9、15、34、35、36 章那条)。
所以这一份什么都不攒:插入就是往后一放;要最小值就从头到尾扫一遍。
★ 两处刻意为之的「不占便宜」:
- 扫的时候一次 break 都没有(第 31 章:「找到第一个就 break」会让暴力假装自己不慢);
- 删除用的是
erase(把后面整段往前搬),不许用「和末尾交换再 pop_back」那种小聪明 —— 那样就不是「什么都不攒」了,而且会打乱顺序,而最后那一行要按从小到大输出。
4 实测:暴力有多慢 —— 顺带打掉「那我维护一个有序数组」这个念头
本机实测(./genBig n 0 造的数据:先插 n 个随机数,再一组组「查询 + 删除」倒空):
| n(元素个数) | brute(每次线性扫) | fast(手写堆) | stl(priority_queue) |
|---|---|---|---|
| 16 000 | 0.09 秒 | 0.00 秒 | — |
| 32 000 | 0.35 秒 | 0.00 秒 | — |
| 64 000 | 1.41 秒 | 0.01 秒 | — |
| 128 000 | 5.80 秒 | 0.02 秒 | — |
| 200 000 | 14.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²)。
./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⌋
堆是一棵完全二叉树:除了最后一层,每层都填满,最后一层的点靠左排。 把它按层从上到下、每层从左到右编号 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 步有现场)。
up每次i → i/2,只往上走;down每次i → 2i或2i+1,只往下走。
两者走过的下标序列,都是树上一条从根到叶的路的一段 —— 长度不超过树高,也就是 ⌊log₂ n⌋。
★ 这就是整条证明。不需要均摊、不需要势函数、不需要「大多数时候很便宜」这种话 —— 它是每一次都成立的最坏情况界。(对比第 36 章:那里的 α 只在均摊意义下成立, 单独某一次 find 完全可以很贵。)
点「运行 ▶」看结果
一次 swap 是三次赋值。可上浮 / 下沉路上那个被挪动的元素,最后总要落在某一格,
中间那些位置它只是路过 —— 没必要每层都把它搬进搬出。
空穴法就是:先把它抱在手上(int x = a[i]),沿路只把别人往空穴里搬,最后再放下。
每层一次赋值,不是三次。
★ 实测(n = 16 000):比较次数一次都不差(都是 403 988),移动次数 637 382 vs 265 793,差 2.4 倍。
⚠ 而这个差别对拍一辈子也看不见 —— 两种写法的答案完全一样。 这是第 36 章那个「随机对拍的第四个盲区」在这一章的现场,第 9 步用计数器把它照出来。
7 比赛里真正会写的那三行:priority_queue
点「运行 ▶」看结果
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」
上面是完全二叉树,下面是同一片数据的数组视图 —— 它们本来就是同一个东西。 高亮的是这一步那个元素走过的下标。
播一遍你会看到:
- 插入时高亮从最后一格往上,删除时从根往下 —— 无论哪种,它都是一条根到叶的路;
- 右上角的「累计比较」涨得非常慢:默认那 15 步一共只比了 16 次(上浮 8、下沉 8);
- 把数据切到「★ 递减插入 12,11,…,1」那一档 —— 每个新来的数都要一路爬到根, 那是第 10 步要说的「上界唯一被顶紧」的时候;
- 再切到「⚠ 递增插入」和「⚠ 全都一样」两档 —— 上浮一步都不走,
因为判断是
a[爸爸] > x,相等就停。
⚠ 动画和
trace.cpp在check:viz里是逐步对的:每一步走过的下标、 这一步比了几次、搬了几次、累计多少、以及那一刻的整个数组,全部逐字节比 (不只对最终答案 —— 第 21 章以来那条规矩)。
9 ★ 换尺子:数比较、数移动 —— 秒表看不见的东西它都看得见
点「运行 ▶」看结果
口径写在代码开头,正文里也说一遍,否则这张表没法读:
- 比较次数只数元素之间的比较(
a[x] < a[y]),下标之间的(i > 1、c < sz)不算 —— 它们和数据规模无关,是循环的记账; - 移动次数数的是给数组格子赋值的次数,一次
swap记 3 次; - ⚠ 最后那一行「把剩下的倒出来」也是实打实的开销,照样计进去 (第 29、36 章那条:别把开销偷偷藏起来)。
| n | linear 比较 | sorted 移动 | swapHeap 移动 | fast 比较 | fast 移动 |
|---|---|---|---|---|---|
| 1 000 | 999 000 | 742 566 | 27 575 | 17 143 | 12 524 |
| 2 000 | 3 998 000 | 2 984 742 | 61 513 | 38 432 | 27 170 |
| 4 000 | 15 996 000 | 11 988 153 | 135 236 | 84 943 | 58 411 |
| 8 000 | 63 992 000 | 47 997 871 | 294 880 | 185 973 | 124 959 |
| 16 000 | 255 984 000 | 192 167 869 | 637 382 | 403 988 | 265 793 |
| n 翻倍它翻几倍 | 4.00 | 4.01 | 2.15 | 2.20 | 2.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 493 | 2.28 | 367 495 |
1 递增 1,2,3,… | 15 999 | 1.00 | 368 388 |
2 ★ 递减 n,n−1,… | 191 631 | 11.98 | 359 263 |
3 全相等 | 15 999 | 1.00 | 31 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。
上面那张表能成立的全部依据,是「四种形状的操作条数、种类、先后顺序完全一样」。
第 36 章在这件事上被抓过一次(生成器里合并和查询共用了一个随机数流),所以这一章直接补了断言:
check:viz 里有一条检查 四份数据把「插入的那个数」抹掉之后逐字节相同。
★ 「这两组数据只差一件事」这种前提,不要凭代码看起来对就写进正文。
11 ★ 关键一步(三):建堆只要 O(n) —— 而且这条界也是紧的
给你 n 个数,要把它们变成一个堆。最直白的办法是一个个 push,O(n log n)。
但有一个几乎白送的办法:
for (int i = n / 2; i >= 1; i--) down(i); // ★ 就这一行
- 从 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)」不矛盾。
点「运行 ▶」看结果
拿递减的数组(./genArr n 2,一个个 push 的最坏情况):
| n | 一个个 push 的比较 | ÷ n | ★ 自底向上的比较 | ÷ n |
|---|---|---|---|---|
| 1 000 | 7 987 | 7.99 | 1 982 | 1.98 |
| 2 000 | 17 964 | 8.98 | 3 980 | 1.99 |
| 4 000 | 39 917 | 9.98 | 7 978 | 1.99 |
| 8 000 | 87 822 | 10.98 | 15 976 | 2.00 |
| 16 000 | 191 631 | 11.98 | 31 974 | 2.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 章第四个盲区)。 想看见它们,只有一条路:换尺子,数次数。
12 ★ 还第 12 章的账:第 k 小的第三种解法
第 12 章讲分治时给了两种解法:排序 O(n log n)、快速选择平均 O(n)。
第 36 章章末答应过「讲完堆再回来补第三种」(那一章的预告里白纸黑字写着),这就是那一份:
维护一个大小为 k 的「大根堆」:堆里始终装着「到目前为止最小的那 k 个数」。 新来一个 x,堆满了就和堆顶(这 k 个里最大的)比 —— x 更小就换掉堆顶,否则直接丢。 扫完之后堆顶就是第 k 小。
O(n log k)。
点「运行 ▶」看结果
记法:堆顶是守门员,它盯着的是「目前排第 k 名的那个」—— 所以门口站的必须是这 k 个里最大的那一个,新来的只要比守门员小就能挤进来。
★ 输入输出和第 12 章那两份完全一样,所以三份可以直接互相对拍 ——
而且这是跨章节对拍:第 12 章的生成器 genSelect.cpp 一个字都没改就拿来用了
(check:viz 里 300 轮全绿)。
本机实测(./genKth 5000000 k,n = 500 万):
| k | 排序(第 12 章) | 快速选择(第 12 章) | ★ 堆(本章) |
|---|---|---|---|
| 10 | 0.52 秒 / 42 752 KB | 0.23 秒 / 42 872 KB | 0.18 秒 / 3 940 KB |
| 1 000 | 0.51 秒 / 42 932 KB | 0.23 秒 / 42 872 KB | 0.18 秒 / 4 028 KB |
| 100 000 | 0.51 秒 / 42 932 KB | 0.23 秒 / 42 904 KB | 0.23 秒 / 4 916 KB |
| 2 500 000 | 0.51 秒 / 42 936 KB | 0.22 秒 / 42 872 KB | 1.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,十个档位,和两个被实测打脸的直觉
| 故意写错的地方 | 默认数据上的输出 | 错在哪一行 |
|---|---|---|
wrongDown 下沉只看左儿子 | E 5 1 8 8 + 5 9 | 第 4、5 行和最后一行 |
wrongUp 上浮只上一层(while 写成 if) | E 5 2 5 4 + 8 9 | 第 3、5 行 |
wrongLast 下沉边界 <= sz 写成 < sz | E 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 步那个设计的直接兑现。
点「运行 ▶」看结果
★★ 把输入里所有的数取负,交给正解跑,再把输出里所有的数取负 —— 和这一份逐字节相同。 (最大的 x 就是最小的 −x;最后那一行「从大到小」也正好是「从小到大」取负。)
check:viz里 300 轮钉着这条,一轮不差。
前十二条恒等式在第 23~28、34、35、36 章。这一条和它们的形状一样:
一个 bug 精确地解了另一道题 —— 而这一次那「另一道题」就是少写一个 greater<> 的后果。
⚠ 请注意它和第 36 章第十二条的区别:那一条两边是开销(跳步数相同), 这一条两边是答案(逐字节相同,只是要先做一次取负)。
300 轮实测(种子 1..300,最终档 8):
| 故意写错的地方 | 被抓 | 第几轮 |
|---|---|---|
wrongPop(a[--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,⚠ 顺手保证不会碰到空堆 | 56 | 46 | 88 | 244 | ★ 0 | 255 |
| 1 | 值域压到 [1,6] | 41 | 35 | 47 | 206 | 0 | 251 |
| 2 | 值域拉到 [1,10⁹] | 58 | 41 | 81 | 242 | 0 | 256 |
| 3 | 操作序列拉长 n ∈ [300,500] | 300 | 300 | 153 | 300 | 0 | 300 |
| 4 | ★ 放开那个「顺手」:删除照发 | 45 | 31 | 65 | 209 | 171 | 218 |
| 5 | 删除比例提到 45%(插 35 / 查 20 / 删 45) | ★ 31 | ★ 25 | 62 | 209 | 0 | 224 |
★ 档位 0 那个 0 是这一章的起点。 我随手写生成器时做了一件完全没过脑子的事: 堆空的时候就改成插入 —— 听起来很合理(「不然删什么呢」), 可题面白纸黑字写着「堆为空时输出 E」。
★ 第 27~34 章那条「生成器里那个不假思索的顺手写法,会悄悄给数据加一条题目里没有的性质」, 这一章第九次。而这次加的那条性质是:「堆永远非空」。 写生成器前先问一遍:题目到底允不允许?我是不是替它做了主?
★ 档位 1 是第二记耳光:我照第 22 章的经验先把值域压到 [1,6](那一章「值域小才是灵魂」),
实测六列全线下降(88 → 47 最惨)。原因在第 10 步已经量过了:
值一并列,下沉那句 a[c] >= x 立刻 break,路径变短,堆根本长不起来。
★ 档位 4 和 5 那两列刺眼的下降,是这张表最值钱的地方,下一张表见分晓。
| 档位 | 内容 | 下沉 | 上浮 | 边界 | a[--sz] | 判空 | 方向 |
|---|---|---|---|---|---|---|---|
| 6 | 3 + 4(拉长 + 允许空堆) | 300 | 300 | 161 | 300 | 218 | 300 |
| 7 | 6 + 5(删除比例提上去) | 297 | 299 | 278 | 300 | 299 | 300 |
| 8(最终档) | 7 + 2(值域也拉开) | 299 | 297 | 290 | 300 | 299 | 300 |
| 9(对照) | 8 + 1(把「值域压小」放回来) | 296 | 299 | ★ 224 | 300 | 299 | 300 |
- 档位 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。
把同一批数据只比前面那几行(把最后那行剩余元素去掉):
| 档位 | 下沉 | 上浮 | 边界 | a[--sz] | 判空 | 方向 |
|---|---|---|---|---|---|---|
| 0(顺手写法) 只比前几行 | 5 | 19 | 11 | 76 | 0 | 200 |
| 0 ★ 加上最后那一行 | 56 | 46 | 88 | 244 | 0 | 255 |
| 8(最终档) 只比前几行 | 299 | 297 | 283 | 300 | 299 | 300 |
| 8 ★ 加上最后那一行 | 299 | 297 | 290 | 300 | 299 | 300 |
★★ 在弱数据上,多问的那一行把抓获率抬了 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 自测
- 洛谷 P3378 【模板】堆 —— 本章那道题去掉最后一行输出。先手写一遍,再用 priority_queue 写一遍,两份互相对拍
- 洛谷 P1090 [NOIP2004 提高组] 合并果子 —— ★ 堆 + 贪心的入门题(哈夫曼)。回头看第 19、20 章:这个贪心为什么对,交换论证还在不在
- 洛谷 P1177 【模板】排序 —— ★ 用堆排序过一遍:建堆 O(n) + n 次 pop。顺带体会「原地排序、不要额外空间」
- 洛谷 P1801 黑匣子 —— ★★ 对顶堆:一个大根堆 + 一个小根堆,中间卡着第 k 小。想清楚两边什么时候要互相倒一个过去
- 洛谷 P1168 中位数 —— ★★ 对顶堆最经典的用法,和上一题是同一招。做完这两道,堆才算真的会用
- 洛谷 P2085 最小函数值 —— ★ 多路归并:n 个递增序列里取前 m 小。堆里永远只放 n 个候选 —— 和本章「大小为 k 的堆」同源
- 洛谷 P1631 序列合并 —— ★ 上一题的双序列版。想清楚「为什么不用把 n² 个和全造出来」