第 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标准答案:一点结构都不攒
// 标准答案 —— 完全不建堆:把数原样堆在一个 vector 里,每次要最小值就**整个扫一遍**//// 为什么它存在:正解(二叉堆)想的是「维护一棵完全二叉树,把顺序信息一路攒下来」。// 要是标准答案也维护一棵堆,两份代码就是同一个思路写了两遍 ——// 只能验出打字错误,验不出想法错误(第 9、15、34、35、36 章那条)。//// 所以这一份**一点结构都不攒**:插入就是往后一放,查询/删除就是从头到尾扫一遍找最小的那个。//// ★ 扫的时候**一次 break 都没有**:哪怕第一个就是最小的,也要把整个数组走完。// (第 31 章:「找到第一个就 break」会让暴力假装自己不慢;// 第 35 章补的后半句:标准答案要挑「没有 break」的那个思路。)//// ⚠ 「删除最小值」这里用的是 erase(把后面整段往前搬),也是实打实的 O(n) ——// 不许用「和末尾交换再 pop_back」那种小聪明,那样就不是「什么都不攒」了// (交换会打乱顺序,而顺序是这份代码唯一还剩的东西:最后一行要按从小到大输出)。//// 复杂度 O(n²)。//// 输入:第一行 n(操作条数);接下来 n 行,每行是// "1 x"(插入 x)/ "2"(输出当前最小值)/ "3"(删除当前最小值)。// 输出:每个 "2" 一行 —— 当前最小值,★ 堆为空时输出一个 E;// ★ 最后再输出一行:堆里剩下的元素**从小到大**全部列出来(空堆输出一个 -)。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0;
vector<long long> a; // 就是一堆数,谁前谁后毫无意义 string out;
auto minPos = [&]() { // ★ 从头扫到尾,中途绝不 break int p = 0; for (int i = 1; i < (int)a.size(); i++) if (a[i] < a[p]) p = i; return p; };
for (int i = 0; i < n; i++) { int op; cin >> op; if (op == 1) { long long x; cin >> x; a.push_back(x); } else if (op == 2) { if (a.empty()) out += "E\n"; // ⚠ 空堆是题面允许的,不是「不会发生」 else { out += to_string(a[minPos()]); out += '\n'; } } else { if (a.empty()) continue; // 空堆删除:什么都不做 a.erase(a.begin() + minPos()); // O(n) 的搬运,一点便宜都不占 } }
sort(a.begin(), a.end()); // ★ 最后那一行:剩下的全列出来 if (a.empty()) out += "-\n"; else { for (size_t i = 0; i < a.size(); i++) { if (i) out += ' '; out += to_string(a[i]); } out += '\n'; }
cout << out; return 0;}点「运行 ▶」看结果
正解维护的是一棵完全二叉树,把顺序信息一路攒下来。要是标准答案也维护一棵堆, 两份代码就是同一个思路写了两遍 —— 只能验出打字错误,验不出想法错误 (第 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 完全可以很贵。)
// 正解 —— 手写小根堆(完全二叉树塞进数组,上浮 / 下沉各 O(log n))//// ============ 这一章的 log,是**证得死死的** ============//// 第 36 章那个 O(α(n)) 只能给结论 + 实测(完整证明超出这本书的范围)。// 这一章正好反过来:两句话就证完了,而且**每一步都能在数组上数出来**。//// ① 堆是一棵**完全二叉树**:除了最后一层,每层都填满,最后一层靠左。// 于是它有多高?第 0 层 1 个、第 1 层 2 个、第 2 层 4 个……// 前 h 层一共 2^h − 1 个点,所以 n 个点的完全二叉树高度就是 **⌊log₂ n⌋**。// —— 不是估的,是**数出来的**。// ② 上浮只往上走(每次 i → i/2),下沉只往下走(每次 i → 2i 或 2i+1),// 两者走的都是**一条根到叶的路** —— 长度不超过树高。//// ⇒ 每次 push / pop 都是 O(log n)。n = 2×10⁵ 时这个数只有 **17**。//// ============ 数组下标:为什么从 1 开始 ============//// 下标 i 的两个儿子是 2i 和 2i+1,爸爸是 i/2(整除)。// ⚠ 从 0 开始的话就得写成 2i+1 / 2i+2 和 (i−1)/2,能用但难记 ——// 这一章统一 1 基(和区间类题目那条约定同源:下标从 1 开始,公式才干净)。//// ============ 为什么用「空穴法」而不是一路 swap ============//// 一次 swap 是三次赋值(tmp = a; a = b; b = tmp)。// 可上浮 / 下沉的路上,那个被挪动的元素**最后总要落在某一格**,中间那些位置它只是路过。// 所以正确的写法是:先把它抱在手上(int x = a[i]),沿路只把别人往它腾出来的空穴里搬,// 最后再把它放下 —— **每层只有一次赋值,不是三次**。//// ★ 这个改动**不影响任何一个答案**,只影响移动次数(实测差三倍,见 count.cpp)。// ⚠ 也就是说:对拍在它身上一辈子抓不到 —— 第 36 章那个「第四个盲区」在这一章又见了一次。//// 复杂度 O(n log n)。
#include <bits/stdc++.h>using namespace std;
const int MAXN = 200005;long long a[MAXN]; // ★ a[1..sz] 才是堆,a[0] 空着不用int sz = 0;
/** 上浮:a[i] 比爸爸小就往上,直到爸爸不比它大(或者它已经是根) */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[i] 比儿子大就往下,★ 两个儿子里要挑**小的**那个换 */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++; // ⚠ c < sz 才有右儿子(c == sz 时只有左儿子) if (a[c] >= x) break; // 已经就位,不用再往下了 a[i] = a[c]; i = c; } a[i] = x;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0;
string out; for (int i = 0; i < n; i++) { int op; cin >> op; if (op == 1) { long long x; cin >> x; a[++sz] = x; // 先放到最后一格(完全二叉树的下一个空位) up(sz); // 再上浮到该去的地方 } else if (op == 2) { if (sz == 0) out += "E\n"; else { out += to_string(a[1]); out += '\n'; } // 堆顶就是最小值,O(1) } else { if (sz == 0) continue; a[1] = a[sz--]; // ★ 拿最后一格填到根上(完全二叉树的形状不能破) if (sz) down(1); // 再把它沉到该去的地方 } }
// ★ 最后那一行:把堆里剩下的全部按从小到大倒出来 —— 就是一次堆排序 vector<long long> rest; while (sz) { rest.push_back(a[1]); a[1] = a[sz--]; if (sz) down(1); } if (rest.empty()) out += "-\n"; else { for (size_t i = 0; i < rest.size(); i++) { if (i) out += ' '; out += to_string(rest[i]); } out += '\n'; }
cout << out; return 0;}点「运行 ▶」看结果
一次 swap 是三次赋值。可上浮 / 下沉路上那个被挪动的元素,最后总要落在某一格,
中间那些位置它只是路过 —— 没必要每层都把它搬进搬出。
空穴法就是:先把它抱在手上(int x = a[i]),沿路只把别人往空穴里搬,最后再放下。
每层一次赋值,不是三次。
★ 实测(n = 16 000):比较次数一次都不差(都是 403 988),移动次数 637 382 vs 265 793,差 2.4 倍。
⚠ 而这个差别对拍一辈子也看不见 —— 两种写法的答案完全一样。 这是第 36 章那个「随机对拍的第四个盲区」在这一章的现场,第 9 步用计数器把它照出来。
7比赛里真正会写的那三行:priority_queue
// 同一道题,改用 STL 的 priority_queue —— 比赛里真正会写的那 3 行//// 为什么手写完还要写这一份:**手写是为了知道它里面在干什么,比赛里用的是这一份。**// (第 8 章 fast.cpp / stl.cpp 那一对同款:先手写二分,再给 lower_bound。)//// ⚠ priority_queue 默认是**大根堆**(top() 是最大的),要小根堆得把三个模板参数写全://// priority_queue<long long, vector<long long>, greater<long long>> q;// ~~~~~~~~~ ~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~// 元素类型 底层容器(几乎总是 vector) ★ 比较器//// 记不住的话有个更笨但绝不会错的办法:**存进去的时候取负,取出来再取负** ——// 最大的 −x 就是最小的 x。(负数溢出要小心,这题 |x| ≤ 1e9 没问题。)//// ⚠ 它**没有**「遍历」这个操作:堆只保证 top() 是最小的,// 底下那 n−1 个元素在数组里是什么顺序,标准不作任何承诺。// 所以最后那一行「从小到大列出剩下的」只能一个个 pop 出来 —— 这本身就是堆排序。//// 三个操作:q.push(x) / q.top() / q.pop(),还有 q.empty()、q.size()。// ★ 这一份和 fast.cpp 在 300 轮对拍里必须**逐字节相同**(check:viz 里钉着这条)。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0;
priority_queue<long long, vector<long long>, greater<long long>> q; // ★ 小根堆 string out;
for (int i = 0; i < n; i++) { int op; cin >> op; if (op == 1) { long long x; cin >> x; q.push(x); } else if (op == 2) { if (q.empty()) out += "E\n"; else { out += to_string(q.top()); out += '\n'; } } else { if (!q.empty()) q.pop(); } }
if (q.empty()) out += "-\n"; else { bool first = true; while (!q.empty()) { // 一个个倒出来,天然就是从小到大 if (!first) out += ' '; first = false; out += to_string(q.top()); q.pop(); } out += '\n'; }
cout << out; return 0;}点「运行 ▶」看结果
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★ 换尺子:数比较、数移动 —— 秒表看不见的东西它都看得见
// ★ 这一章的尺子:**数一数元素之间比了多少次、搬了多少次**//// 为什么它存在,有两个理由,而且是两件不同的事://// ① 秒表在这一章会失灵(第 36 章那条的第二次现场):正解在 20 万个元素上跑完全程// 和「只把输入读完」几乎一样快 —— `./count read` 就是用来确认这一点的。// ② 这张表里有一对写法**答案完全一样**(空穴法 vs 一路 swap),// 对拍在它们身上一辈子抓不到(第 36 章那个「第四个盲区」)。// ★ 次数可复现,秒数不可复现(第 21 章 stairsCount.cpp 以来的老规矩)。//// ============ 口径(正文里也要写清楚,否则这张表没法读)============//// · **比较次数** = 两个**元素**之间比大小的次数(`a[x] < a[y]` 这种)。// 下标之间的比较(`i > 1`、`c < sz`)不算 —— 它们和数据规模无关,是循环的记账。// · **移动次数** = 给数组格子赋值的次数(`a[i] = …`)。// 一次 swap 记 **3** 次(tmp = a; a = b; b = tmp),空穴法沿路每层只记 1 次。// · **上浮比较 / 下沉比较** 是比较次数的两半,专门拆开是因为它们的曲线**形状不同**:// 下沉在四种数据上都要走到底,上浮只有「递减插入」那一档才顶得到 log n。// · ⚠ 最后那一行「把剩下的从小到大倒出来」也是实打实的开销,**照样计进去**// (第 29、36 章那条:量之前先确认你量的就是它,别把开销偷偷藏起来)。//// 用法:./count < 数据 打印那张对比表// ./count read < 数据 ★ 只把输入读完就退出,什么都不算// ./count csv < 数据 同一批数字,一行一种写法,给脚本和 check:viz 读// ./count csv fast < 数据 ⚠ 只跑指定的那一种写法// (量形状那张表要开到十万级,linear 是 O(n²),跑不动)
#include <bits/stdc++.h>using namespace std;
// 按显示宽度补空格:ASCII 算 1 格,汉字算 2 格(setw 数的是字节,对不齐)static string padDisp(const string& s, int width) { int disp = 0; for (unsigned char c : s) { if ((c & 0xC0) == 0x80) continue; disp += (c < 0x80) ? 1 : 2; } return s + string(max(0, width - disp), ' ');}
enum Mode { LINEAR, SORTED, SWAPH, FAST };
struct Bag { Mode mode; vector<long long> a; // LINEAR/SORTED 用它;堆用 h[1..sz] vector<long long> h; int sz = 0; long long cmpUp = 0, cmpDown = 0, cmpOther = 0, mov = 0;
void init(int cap) { h.assign(cap + 2, 0); } long long cmp() const { return cmpUp + cmpDown + cmpOther; }
/* ---------- 堆:空穴法(正解) ---------- */ void upHole(int i) { long long x = h[i]; while (i > 1) { cmpUp++; if (!(h[i >> 1] > x)) break; h[i] = h[i >> 1]; // 只搬别人,x 还抱在手上 mov++; i >>= 1; } h[i] = x; mov++; } void downHole(int i) { long long x = h[i]; while ((i << 1) <= sz) { int c = i << 1; if (c < sz) { cmpDown++; if (h[c + 1] < h[c]) c++; } // 两个儿子里挑小的 cmpDown++; if (h[c] >= x) break; h[i] = h[c]; mov++; i = c; } h[i] = x; mov++; }
/* ---------- 堆:一路 swap(答案完全一样,只是搬得多) ---------- */ void upSwap(int i) { while (i > 1) { cmpUp++; if (!(h[i >> 1] > h[i])) break; swap(h[i], h[i >> 1]); mov += 3; // ★ 一次 swap = 三次赋值 i >>= 1; } } void downSwap(int i) { while ((i << 1) <= sz) { int c = i << 1; if (c < sz) { cmpDown++; if (h[c + 1] < h[c]) c++; } cmpDown++; if (h[c] >= h[i]) break; swap(h[i], h[c]); mov += 3; i = c; } }
void push(long long x) { if (mode == LINEAR) { a.push_back(x); mov++; return; } if (mode == SORTED) { // 有序数组:二分找位置,再把后面整段往后挪 int lo = 0, hi = (int)a.size(); while (lo < hi) { int mid = (lo + hi) >> 1; cmpOther++; if (a[mid] < x) lo = mid + 1; else hi = mid; } a.insert(a.begin() + lo, x); mov += (int)a.size() - lo; // 插入点后面的每一个都往后搬了一格 return; } h[++sz] = x; mov++; if (mode == FAST) upHole(sz); else upSwap(sz); }
int minPos() { // LINEAR 专用:从头扫到尾,★ 不 break int p = 0; for (int i = 1; i < (int)a.size(); i++) { cmpOther++; if (a[i] < a[p]) p = i; } return p; }
bool empty() const { return (mode == LINEAR || mode == SORTED) ? a.empty() : sz == 0; }
long long top() { if (mode == LINEAR) return a[minPos()]; if (mode == SORTED) return a[0]; return h[1]; }
void pop() { if (mode == LINEAR) { int p = minPos(); mov += (int)a.size() - p - 1; a.erase(a.begin() + p); return; } if (mode == SORTED) { mov += (int)a.size() - 1; a.erase(a.begin()); return; } h[1] = h[sz--]; mov++; if (sz) { if (mode == FAST) downHole(1); else downSwap(1); } }};
int main(int argc, char** argv) { // ⚠ 读入的写法必须和 fast.cpp 一字不差,否则 `./count read` 量出来的「读入要多久」 // 和它对不上(第 32 章那条:两份代码的 I/O 设置不一致,量的是读入速度不是算法)。 // 输出只用 printf、不用 cout,所以关掉 sync 是安全的(第 26 章那条混用的坑)。 ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; vector<pair<int, long long>> ops(n); int nPush = 0, nAsk = 0, nPop = 0; for (auto& o : ops) { cin >> o.first; o.second = 0; if (o.first == 1) { cin >> o.second; nPush++; } else if (o.first == 2) nAsk++; else nPop++; }
if (argc > 1 && string(argv[1]) == "read") { printf("只读入:%d 条操作(插入 %d,查询 %d,删除 %d)—— 一次比较都没做\n", n, nPush, nAsk, nPop); return 0; }
struct Row { Mode mode; const char* name; const char* note; }; vector<Row> rows = { {LINEAR, "linear", "不建堆,每次扫一遍"}, {SORTED, "sorted", "维护有序数组"}, {SWAPH, "swapHeap", "堆,一路 swap"}, {FAST, "fast", "堆 + 空穴法(正解)"}, }; // ⚠ 只跑指定的那一种(linear / sorted 是 O(n²),量形状那张表时开不到十万级) if (argc > 2) { string only = argv[2]; vector<Row> keep; for (auto& r : rows) if (only == r.name) keep.push_back(r); if (!keep.empty()) rows = keep; } const size_t gold = rows.size() - 1; // 最后一行永远是当基准的那一份
vector<long long> cmpAll(rows.size()), movAll(rows.size()), upAll(rows.size()), downAll(rows.size()); vector<string> ans(rows.size());
for (size_t k = 0; k < rows.size(); k++) { Bag b; b.mode = rows[k].mode; b.init(nPush + 2); string out; for (auto& [op, x] : ops) { if (op == 1) b.push(x); else if (op == 2) { if (b.empty()) out += "E|"; else { out += to_string(b.top()); out += '|'; } } else if (!b.empty()) b.pop(); } // 最后那一行:把剩下的从小到大倒出来(★ 这一段的开销照样计进去) if (b.empty()) out += "-"; else { bool first = true; while (!b.empty()) { if (!first) out += ' '; first = false; out += to_string(b.top()); b.pop(); } } cmpAll[k] = b.cmp(); movAll[k] = b.mov; upAll[k] = b.cmpUp; downAll[k] = b.cmpDown; ans[k] = out; }
if (argc > 1 && string(argv[1]) == "csv") { for (size_t k = 0; k < rows.size(); k++) printf("%s,%lld,%lld,%lld,%lld,%s\n", rows[k].name, cmpAll[k], movAll[k], upAll[k], downAll[k], ans[k] == ans[gold] ? "同正解" : "不同"); return 0; }
printf("%d 条操作(插入 %d,查询 %d,删除 %d)\n\n", n, nPush, nAsk, nPop); printf("%s %s %s %s %s %s %s\n", padDisp("写法", 10).c_str(), padDisp("说明", 22).c_str(), padDisp("比较次数", 12).c_str(), padDisp("移动次数", 12).c_str(), padDisp("其中上浮比较", 14).c_str(), padDisp("其中下沉比较", 14).c_str(), padDisp("答案", 8).c_str()); for (size_t k = 0; k < rows.size(); k++) printf("%s %s %s %s %s %s %s\n", padDisp(rows[k].name, 10).c_str(), padDisp(rows[k].note, 22).c_str(), padDisp(to_string(cmpAll[k]), 12).c_str(), padDisp(to_string(movAll[k]), 12).c_str(), padDisp(to_string(upAll[k]), 14).c_str(), padDisp(to_string(downAll[k]), 14).c_str(), padDisp(ans[k] == ans[gold] ? "同正解" : "✗ 不同", 8).c_str());
bool allSame = true; for (auto& s : ans) allSame &= (s == ans[gold]); printf("\n★ 这 %d 种写法的答案%s。\n", (int)rows.size(), allSame ? "**完全一样**" : "有不一样的"); printf(" —— 所以「swapHeap 比正解多搬了多少」这件事,对拍**一个字都看不见**。\n"); if (nPush > 0) printf("★ log2(%d) = %.2f;对照上面 fast 那一行的「其中下沉比较 ÷ 删除次数」。\n", nPush, log2((double)nPush)); return 0;}点「运行 ▶」看结果
口径写在代码开头,正文里也说一遍,否则这张表没法读:
- 比较次数只数元素之间的比较(
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)」不矛盾。
// ★ 建堆:一个个 push 是 O(n log n),自底向上只要 O(n) —— 而且这条 O(n) 也**证得死死的**//// ============ 两种建法 ============//// ① 一个个 push:把 n 个数依次插进空堆,每次上浮。// 最坏 Σ log i = O(n log n)。// ② ★ 自底向上:把 n 个数原样倒进数组,然后 **从 n/2 往 1 倒着 down 一遍**。// for (int i = n / 2; i >= 1; i--) down(i);// 为什么从 n/2 开始:下标 > n/2 的格子没有儿子,它们本来就是合法的堆(一个点的树)。// 为什么**倒着**:down(i) 要求它的两棵子树已经是堆了 ——// 「依赖谁,就先填谁」,第 21、26、27 章那句话在这里第八次登场。//// ============ ② 为什么是 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 = 2 是那个经典的级数:1/2 + 2/4 + 3/8 + … = 2。)// 每层最多 2 次比较 ⇒ **比较次数 ≤ 2n**。//// ★ 直觉上的那句话更好记:**一半的点在最后一层,它们一步都不用走**;// 真正要走 log n 层的只有根那一个点。**「大多数点很矮」才是 O(n) 的来源。**//// ============ ⚠ 这一节最该记住的一件事 ============//// ★ 两种建法造出来的**数组通常不一样**(下面会逐格打出来给你看),// 可它们都是合法的小根堆,**堆排序倒出来的结果一模一样** ——// ⚠ 于是「用了哪种建法」这件事,对拍**一个字都看不见**(第 36 章那个第四盲区)。// 要看见差别,只能数次数。//// 用法:./build < 数据 打印对比(n ≤ 30 时连两个数组一起打出来)// ./build csv < 数据 一行一种建法:名字,比较次数,移动次数,堆排序结果的哈希// 输入:第一行 n,第二行 n 个整数(用 ./genArr n 形状 造)。
#include <bits/stdc++.h>using namespace std;
static string padDisp(const string& s, int width) { int disp = 0; for (unsigned char c : s) { if ((c & 0xC0) == 0x80) continue; disp += (c < 0x80) ? 1 : 2; } return s + string(max(0, width - disp), ' ');}
struct Heap { vector<long long> h; // h[1..sz] int sz = 0; long long cmp = 0, mov = 0;
void reset(int cap) { h.assign(cap + 2, 0); sz = 0; cmp = 0; mov = 0; }
void up(int i) { long long x = h[i]; while (i > 1) { cmp++; if (!(h[i >> 1] > x)) break; h[i] = h[i >> 1]; mov++; i >>= 1; } h[i] = x; mov++; } void down(int i) { long long x = h[i]; while ((i << 1) <= sz) { int c = i << 1; if (c < sz) { cmp++; if (h[c + 1] < h[c]) c++; } cmp++; if (h[c] >= x) break; h[i] = h[c]; mov++; i = c; } h[i] = x; mov++; }
/** ① 一个个 push */ void buildByPush(const vector<long long>& a) { reset((int)a.size()); for (long long x : a) { h[++sz] = x; mov++; up(sz); } } /** ② ★ 自底向上:先原样倒进去,再从 n/2 倒着 down */ void buildHeapify(const vector<long long>& a) { reset((int)a.size()); for (long long x : a) { h[++sz] = x; mov++; } for (int i = sz / 2; i >= 1; i--) down(i); }
/** 堆排序:一个个倒出来(★ 这一段的开销不计进上面那两个计数器) */ vector<long long> drain() const { Heap t = *this; vector<long long> r; while (t.sz) { r.push_back(t.h[1]); t.h[1] = t.h[t.sz--]; if (t.sz) t.down(1); } return r; }};
static string join(const vector<long long>& v, int limit) { string s; for (int i = 0; i < (int)v.size() && i < limit; i++) { if (i) s += ' '; s += to_string(v[i]); } if ((int)v.size() > limit) s += " …"; return s;}
int main(int argc, char** argv) { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; vector<long long> a(n); for (auto& x : a) cin >> x;
Heap p, q; p.buildByPush(a); q.buildHeapify(a);
vector<long long> sp = p.drain(), sq = q.drain(); bool sameArray = true; for (int i = 1; i <= n; i++) sameArray &= (p.h[i] == q.h[i]); bool sameSorted = (sp == sq);
if (argc > 1 && string(argv[1]) == "csv") { printf("push,%lld,%lld\n", p.cmp, p.mov); printf("heapify,%lld,%lld\n", q.cmp, q.mov); printf("same,%d,%d\n", sameArray ? 1 : 0, sameSorted ? 1 : 0); return 0; }
printf("n = %d\n\n", n); printf("%s %s %s %s\n", padDisp("建法", 10).c_str(), padDisp("比较次数", 12).c_str(), padDisp("移动次数", 12).c_str(), padDisp("比较 ÷ n", 12).c_str()); printf("%s %s %s %s\n", padDisp("一个个 push", 10).c_str(), padDisp(to_string(p.cmp), 12).c_str(), padDisp(to_string(p.mov), 12).c_str(), padDisp(to_string((double)p.cmp / n).substr(0, 5), 12).c_str()); printf("%s %s %s %s\n", padDisp("★ 自底向上", 10).c_str(), padDisp(to_string(q.cmp), 12).c_str(), padDisp(to_string(q.mov), 12).c_str(), padDisp(to_string((double)q.cmp / n).substr(0, 5), 12).c_str()); printf("\nlog2(%d) = %.2f\n", n, log2((double)max(n, 1)));
if (n <= 30) { printf("\n一个个 push 建出来的数组: %s\n", join(vector<long long>(p.h.begin() + 1, p.h.begin() + 1 + n), 30).c_str()); printf("★ 自底向上建出来的数组: %s\n", join(vector<long long>(q.h.begin() + 1, q.h.begin() + 1 + n), 30).c_str()); } printf("\n两个数组%s;堆排序倒出来的结果%s。\n", sameArray ? "**完全相同**" : "★ **不一样**(都是合法的小根堆,但长得不同)", sameSorted ? "**完全相同**" : "✗ 不同(那就有一份错了)"); printf(" —— 所以「用了哪种建法」对拍看不见,只有比较次数看得见。\n"); return 0;}点「运行 ▶」看结果
拿递减的数组(./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)。
// ★ 还第 12 章的账:第 k 小,用堆再做一遍//// 第 12 章给了两种解法:排序 O(n log n)、快速选择平均 O(n),// 并且在章末留了一句「还有别的做法,等讲完堆再回来」。这就是那一份。//// ============ 做法:维护一个大小为 k 的**大根堆** ============//// 扫过去,堆里始终装着「到目前为止最小的那 k 个数」:// · 堆里不足 k 个 → 直接放进去;// · 已经有 k 个 → 拿新来的 x 和**堆顶**(这 k 个里最大的那个)比:// x 更小就把堆顶扔掉、把 x 放进来;否则 x 连前 k 名都进不去,直接丢。// 扫完之后,堆里正好是最小的 k 个数,而**堆顶就是它们里最大的** —— 也就是第 k 小。//// ⚠ 要的是**第 k 小**,用的却是**大根堆** —— 这一步是最容易想反的。// 记法:**堆顶是「守门员」**,它盯着的是「目前排第 k 名的那个」,// 所以门口站的必须是这 k 个里最大的那一个。//// 复杂度 O(n log k)。//// ============ ★ 它凭什么值得单独存在(三种解法的真正分工)============//// · 排序 O(n log n):写起来最短,n 不大时怎么都够用。// · 快速选择平均 O(n):**渐进最快**,但它要**把整个数组抓在手里反复划分**。// · 堆 O(n log k):渐进上比快速选择差一个 log,可它有两样别人没有的东西 ——// ① **只占 O(k) 的内存**(k 远小于 n 时差距是数量级的);// ② ★ **数据可以一个个来、来了就扔**(数据流 / 在线):// 它从头到尾只需要看每个数**一眼**,不需要回头。// 快速选择做不到这件事 —— 它必须能反复访问整个数组。//// > ★ **「哪个更快」不是唯一的问题,「它需要什么条件」同样是问题。**// (第 24 章那条「二进制拆分 vs 单调队列,交叉点在 k ≈ 100」的同款分寸。)//// ★ 输入输出和第 12 章的 selectBrute.cpp / selectFast.cpp **完全一样**,// 所以这三份可以直接互相对拍(check:viz 里就是这么钉的)——// ⚠ 而且这是**跨章节**对拍:第 12 章的生成器一个字都没改就拿来用了。//// 输入:第一行 n k,第二行 n 个整数。// 输出:从小到大数,第 k 个是多少。
#include <bits/stdc++.h>using namespace std;
int main(int argc, char** argv) { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, k; if (!(cin >> n >> k)) return 0; if (n <= 0 || k < 1 || k > n) { cout << 0 << "\n"; return 0; }
priority_queue<long long> q; // ★ 默认就是大根堆,这里要的正是它 long long pushes = 0; // 有多少个数真的进过堆(给 ./heapKth cnt 用)
for (int i = 0; i < n; i++) { long long x; cin >> x; // ★ 每个数只看一眼,看完就可以扔 if ((int)q.size() < k) { q.push(x); pushes++; } else if (x < q.top()) { q.pop(); q.push(x); pushes++; } }
if (argc > 1 && string(argv[1]) == "cnt") { // 「入堆次数」是可复现的尺子(秒数不是)——正文里那张表用它说明 // ★ 「x < 堆顶」这件事随着扫描越来越难发生,所以入堆次数远小于 n。 printf("n = %d, k = %d, 入堆次数 = %lld\n", n, k, pushes); return 0; }
cout << q.top() << "\n"; // 堆顶 = 最小的 k 个里最大的 = 第 k 小 return 0;}点「运行 ▶」看结果
记法:堆顶是守门员,它盯着的是「目前排第 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 步那个设计的直接兑现。
// ✗ 错误版本六:比较方向全写反了 —— 而它其实是**另一道题的正解**//// 正确(小根堆): while (i > 1 && a[i >> 1] > x) … if (a[c] >= x) break; 两个儿子挑**小**的// 这里(大根堆): while (i > 1 && a[i >> 1] < x) … if (a[c] <= x) break; 两个儿子挑**大**的//// ★ 这不是一个「乱七八糟的错」,它是一份**完全正确的大根堆** ——// 只不过题目要的是最小值,它给的是最大值。//// > ★★ **本教材第十三条恒等式**:// > 把输入里所有的数取负,交给正解跑,再把输出里所有的数取负 —— **和这一份逐字节相同**。// > (最大的 x 就是最小的 −x;最后那一行「从大到小」也正好是「从小到大」取负。)// > check:viz 里 300 轮钉着这条。//// ⚠ 这就是为什么 STL 那三个模板参数一定要写全:`priority_queue` **默认是大根堆**,// 少写一个 `greater<>` 得到的就是这一份 —— 它不会报错、不会崩,只会安静地回答另一道题。// (第 23~28 章那一串「一个 bug 精确地解了另一道题」的又一次。)
#include <bits/stdc++.h>using namespace std;
const int MAXN = 200005;long long a[MAXN];int sz = 0;
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;}
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;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; if (!(cin >> n)) return 0; string out; for (int i = 0; i < n; i++) { int op; cin >> op; if (op == 1) { long long x; cin >> x; a[++sz] = x; up(sz); } else if (op == 2) { if (sz == 0) out += "E\n"; else { out += to_string(a[1]); out += '\n'; } } else { if (sz == 0) continue; a[1] = a[sz--]; if (sz) down(1); } } vector<long long> rest; while (sz) { rest.push_back(a[1]); a[1] = a[sz--]; if (sz) down(1); } if (rest.empty()) out += "-\n"; else { for (size_t i = 0; i < rest.size(); i++) { if (i) out += ' '; out += to_string(rest[i]); } out += '\n'; } cout << out; return 0;}点「运行 ▶」看结果
★★ 把输入里所有的数取负,交给正解跑,再把输出里所有的数取负 —— 和这一份逐字节相同。 (最大的 x 就是最小的 −x;最后那一行「从大到小」也正好是「从小到大」取负。)
check:viz里 300 轮钉着这条,一轮不差。
前十二条恒等式在第 23~28、34、35、36 章。这一条和它们的形状一样:
一个 bug 精确地解了另一道题 —— 而这一次那「另一道题」就是少写一个 greater<> 的后果。
⚠ 请注意它和第 36 章第十二条的区别:那一条两边是开销(跳步数相同), 这一条两边是答案(逐字节相同,只是要先做一次取负)。
// 正解 —— 手写小根堆(完全二叉树塞进数组,上浮 / 下沉各 O(log n))//// ============ 这一章的 log,是**证得死死的** ============//// 第 36 章那个 O(α(n)) 只能给结论 + 实测(完整证明超出这本书的范围)。// 这一章正好反过来:两句话就证完了,而且**每一步都能在数组上数出来**。//// ① 堆是一棵**完全二叉树**:除了最后一层,每层都填满,最后一层靠左。// 于是它有多高?第 0 层 1 个、第 1 层 2 个、第 2 层 4 个……// 前 h 层一共 2^h − 1 个点,所以 n 个点的完全二叉树高度就是 **⌊log₂ n⌋**。// —— 不是估的,是**数出来的**。// ② 上浮只往上走(每次 i → i/2),下沉只往下走(每次 i → 2i 或 2i+1),// 两者走的都是**一条根到叶的路** —— 长度不超过树高。//// ⇒ 每次 push / pop 都是 O(log n)。n = 2×10⁵ 时这个数只有 **17**。//// ============ 数组下标:为什么从 1 开始 ============//// 下标 i 的两个儿子是 2i 和 2i+1,爸爸是 i/2(整除)。// ⚠ 从 0 开始的话就得写成 2i+1 / 2i+2 和 (i−1)/2,能用但难记 ——// 这一章统一 1 基(和区间类题目那条约定同源:下标从 1 开始,公式才干净)。//// ============ 为什么用「空穴法」而不是一路 swap ============//// 一次 swap 是三次赋值(tmp = a; a = b; b = tmp)。// 可上浮 / 下沉的路上,那个被挪动的元素**最后总要落在某一格**,中间那些位置它只是路过。// 所以正确的写法是:先把它抱在手上(int x = a[i]),沿路只把别人往它腾出来的空穴里搬,// 最后再把它放下 —— **每层只有一次赋值,不是三次**。//// ★ 这个改动**不影响任何一个答案**,只影响移动次数(实测差三倍,见 count.cpp)。// ⚠ 也就是说:对拍在它身上一辈子抓不到 —— 第 36 章那个「第四个盲区」在这一章又见了一次。//// 复杂度 O(n log n)。
#include <bits/stdc++.h>using namespace std;
const int MAXN = 200005;long long a[MAXN]; // ★ a[1..sz] 才是堆,a[0] 空着不用int sz = 0;
/** 上浮:a[i] 比爸爸小就往上,直到爸爸不比它大(或者它已经是根) */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[i] 比儿子大就往下,★ 两个儿子里要挑**小的**那个换 */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++; // ⚠ c < sz 才有右儿子(c == sz 时只有左儿子) if (a[c] >= x) break; // 已经就位,不用再往下了 a[i] = a[c]; i = c; } a[i] = x;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0;
string out; for (int i = 0; i < n; i++) { int op; cin >> op; if (op == 1) { long long x; cin >> x; a[++sz] = x; // 先放到最后一格(完全二叉树的下一个空位) up(sz); // 再上浮到该去的地方 } else if (op == 2) { if (sz == 0) out += "E\n"; else { out += to_string(a[1]); out += '\n'; } // 堆顶就是最小值,O(1) } else { if (sz == 0) continue; a[1] = a[sz--]; // ★ 拿最后一格填到根上(完全二叉树的形状不能破) if (sz) down(1); // 再把它沉到该去的地方 } }
// ★ 最后那一行:把堆里剩下的全部按从小到大倒出来 —— 就是一次堆排序 vector<long long> rest; while (sz) { rest.push_back(a[1]); a[1] = a[sz--]; if (sz) down(1); } if (rest.empty()) out += "-\n"; else { for (size_t i = 0; i < rest.size(); i++) { if (i) out += ' '; out += to_string(rest[i]); } out += '\n'; }
cout << out; return 0;}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² 个和全造出来」