阶段 7 · 数据结构 · 第 37 章普及组 J

堆与 priority_queue

这一章把「每次取最小」做到 O(log n)。★ 关键一步是上浮 / 下沉各只走一条「根到叶」的路 —— 而这个 log 和上一章的 α 不同:数一数层数就证完了。

需要先学:第 10 章 排序:冒泡 → 归并 → 快排例题:维护一个小根堆(插入 / 查询最小 / 删除最小)建议用时: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 / 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 步里埋了四件事,第 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 里,每次要最小值就整个扫一遍
// 标准答案 —— 完全不建堆:把数原样堆在一个 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 为什么标准答案不能「再写一个堆」

正解维护的是一棵完全二叉树,把顺序信息一路攒下来。要是标准答案也维护一棵堆, 两份代码就是同一个思路写了两遍 —— 只能验出打字错误,验不出想法错误 (第 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²)。

⚠ 那 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 → 2i 或 2i+1,只往下走。

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

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

fast.cpp正解:手写小根堆(空穴法)
// 正解 —— 手写小根堆(完全二叉树塞进数组,上浮 / 下沉各 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;
}
点一下即可编辑
输入(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 轮逐字节相同
// 同一道题,改用 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;
}
点一下即可编辑
输入(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.cpp 在 check:viz 里是逐步对的:每一步走过的下标、 这一步比了几次、搬了几次、累计多少、以及那一刻的整个数组,全部逐字节比 (不只对最终答案 —— 第 21 章以来那条规矩)。

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

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

count.cpp四种写法并排跑,数比较和移动(附「只读入」开关)
// ★ 这一章的尺子:**数一数元素之间比了多少次、搬了多少次**
//
// 为什么它存在,有两个理由,而且是两件不同的事:
//
// ① 秒表在这一章会失灵(第 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

  • 比较次数只数元素之间的比较(a[x] < a[y]),下标之间的(i > 1、c < sz)不算 —— 它们和数据规模无关,是循环的记账;
  • 移动次数数的是给数组格子赋值的次数,一次 swap 记 3 次;
  • ⚠ 最后那一行「把剩下的倒出来」也是实打实的开销,照样计进去 (第 29、36 章那条:别把开销偷偷藏起来)。
★★ 四条曲线(`./genBig n 0`,随机数据)
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。

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

上面那张表能成立的全部依据,是「四种形状的操作条数、种类、先后顺序完全一样」。 第 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 开始:下标大于 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两种建法并排:比较 / 移动 / 建出来的数组 / 排序结果
// ★ 建堆:一个个 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 实测:那条「≤ 2n」被顶到了 2.00

拿递减的数组(./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 章第四个盲区)。 想看见它们,只有一条路:换尺子,数次数。

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 小,用的却是大根堆 —— 堆顶是「守门员」
// ★ 还第 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 要「第 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,十个档位,和两个被实测打脸的直觉

★ 六个错误版本,以及它们在默认那 15 步上的样子
故意写错的地方 默认数据上的输出 错在哪一行
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 步那个设计的直接兑现。

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

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

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

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

对拍器
★ 这个生成器一共五个旋钮,其中两个单独加是负分、合起来却是最好的一档,还有一个照着第 22 章的经验加进来、量完只能撤回。下面两张表把每一处的账都摆出来。
// 正解 —— 手写小根堆(完全二叉树塞进数组,上浮 / 下沉各 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。

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

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

档位 下沉 上浮 边界 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自测

自测清单0 / 14
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)