第 34 章讲 Kruskal 的时候,已经把并查集的基本操作讲完了(dsu.cpp 那一小节:
find 是「一路往上找祖宗」、unite 是「把两族的祖宗接起来」,
还顺带指出了「比爸爸不比祖宗」和「只挂点不挂族」这两个经典写法错在哪)。
这一章不再讲一遍(第 30 章不重复讲建图的同款)。
第 35 章章末白纸黑字预告的是这个:
★ 下一章专讲为什么它快到几乎是 O(1) —— 路径压缩 + 按秩合并的复杂度, 以及实测:不压缩 / 只压缩 / 压缩加按秩,三条曲线到底差多少。 ⚠ 顺带把均摊分析再推一步:
O(α(n))也是均摊出来的,而且比「每个元素进出各一次」难得多。
⚠ 于是这一章会撞上一件前 35 章都没碰到过的事:
★★ 这一章的主角(压不压缩、按不按秩、方向反没反)全都不影响答案。 八种写法给出的输出一模一样,300 轮对拍在它们身上 0 / 300。 所以这一章的尺子不是对拍,也不是秒表,而是计数器: find 一共往上跳了多少步。
1 一句话问题
有 n 个点(
n ≤ 2×10⁵),一开始谁跟谁都不通。接下来 m 条操作(m ≤ 4×10⁵):
1 a b:把 a 所在的那一族和 b 所在的那一族合并;2 a b:问 a 和 b 现在连不连通。每个查询输出一行
Y或N;最后再输出一行:现在一共有多少个连通块。
这道题本来只需要输出 Y / N。加上「最后输出连通块个数」这一行,是故意的:
★ 第 35 章那条教训(题面多问一句,对拍就多一条腿)在这一章有一个极干净的现场: 第 12 步那个「忘了判已经同族就
cnt--」的 bug, 只看 Y / N 那半是 0 / 300,把最后那一行算上就是 300 / 300。
一行输出,把一个 bug 从「测不到」抬到「第 1 轮就抓住」。
2 手算一遍:默认那组操作
8 12
1 1 2 合并 {1,2}
2 1 3 → N
1 2 3 合并 {1,2,3}
2 1 3 → Y
1 1 3 ★ 它俩已经是一族了 —— 这一步「什么都不该做」
1 4 5 合并 {4,5}
1 1 4 ★ 注意 a=1 此刻**不是**它那一族的祖宗
2 3 5 → Y
1 6 7 合并 {6,7}
1 5 6 合并成 {1,2,3,4,5,6,7}
2 1 7 → Y
2 8 1 → N(8 号一直是自己一族)答案:N Y Y Y N,最后一行 2({1..7} 和 {8})。
- 第 5 步那个重复合并,是「无条件
cnt--」这个 bug 的唯一现场; - 第 7 步那个「a 不是祖宗」,是「只挂点不挂族」和「比爸爸不比祖宗」的现场;
- 而第 8、11 步的查询,是把上面两件事读出来的地方 —— ★ bug 要现形,得「先埋下、后读出」两件事都发生,这正是第 12 步生成器调不动的根源。
3 标准答案:一点历史都不攒
点「运行 ▶」看结果
正解维护的是一片森林,把信息一路攒下来。要是标准答案也维护森林, 两份代码就是同一个思路写了两遍 —— 只能验出打字错误,验不出想法错误 (第 9、15、34、35 章那条)。
所以这一份什么都不攒:合并只是把边记下来,查询时拿现有的边从零建一遍图、 从 a 出发 BFS,看 b 在不在里面。
★ 而且 BFS 里一次 break 都没有 —— 哪怕半路撞见 b,也要把 a 的连通块整个走完。 (第 31 章:「找到第一个就 break」会让暴力假装自己不慢; 第 35 章补的后半句:标准答案要挑「没有 break」的那个思路。)
4 实测:暴力有多慢
本机实测(./genBig n 1 造的链式数据,n 个点、2(n−1) 条操作):
| n | brute(每次查询重建图 + BFS) | naive(并查集,不压缩) | fast(压缩 + 按秩) |
|---|---|---|---|
| 4 000 | 0.08 秒 | 0.02 秒 | 0.00 秒 |
| 16 000 | 1.16 秒 | 0.30 秒 | 0.00 秒 |
| 32 000 | 5.48 秒 | 1.28 秒 | 0.00 秒 |
| 64 000 | — | 5.03 秒 | 0.00 秒 |
| 200 000 | — | 49.56 秒 | 0.03 秒 |
g++ -O2 -std=c++17 -o genBig genBig.cpp && g++ -O2 -std=c++17 -o fast fast.cpp
./genBig 200000 1 > big.txt && time ./fast < big.txt # 0.03 秒
./genBig 200000 1 > big.txt && time ./naive < big.txt # 49.56 秒
./count read < big.txt # ★ 只把输入读完就退出,什么都不算也是 0.03 秒。 也就是说:20 万个点、40 万条操作,正解跑完全程的时间和只读一遍输入一样, 算法那部分在秒表上根本量不出来。
★ 第 29、32、34、35 章那条「量之前先确认「你量的就是它」」的第五次。 ⚠ 所以
count.cpp的读入必须和fast.cpp写得一模一样 (都是cin+ 关掉sync_with_stdio),否则量出来的「读入耗时」不是它的读入耗时 (第 32 章那条:两份代码的 I/O 设置不一致,量的是读入速度不是算法)。
★ 这就是这一章必须换尺子的直接原因:秒表在正解身上已经失灵了。
5 ★ 关键一步(一):按秩合并 —— 这一章唯一能完整证明的那条界
合并两棵树时,只有一件事可以选:谁挂到谁下面。
if (rk[ra] > rk[rb]) swap(ra, rb); // 保证 ra 那棵不比 rb 那棵高
fa[ra] = rb; // ★ 矮的挂到高的
if (rk[ra] == rk[rb]) rk[rb]++; // 只有一样高,接起来才真的高了一层为什么方向是这个?把 A 挂到 B 下面,A 里所有点到根的距离都 +1,B 里一个都不变 —— 那当然要让矮的那边去承担这个 +1。(第 35 章「弹出方向由你要问的问题决定」的同一句话。)
★ 证明(对 r 做归纳):秩为 r 的树至少有 2^r 个点。
- r = 0:一个孤立点,2⁰ = 1 个,成立。
- 秩什么时候会涨?只有两棵秩都是 r−1 的树合并时,新树的秩才变成 r。 而这两棵按归纳假设各自至少有 2^(r−1) 个点,合起来至少 2^r 个。∎
于是:树高 ≤ 秩 ≤ log₂ n。n = 2×10⁵ 时,这个上界只有 17。
★ 这是这一章唯一一条能当场证完的界。 后面 α(n) 那条只给结论加实测 —— 说清楚哪句证明了、哪句没证,比含糊过去要紧得多。
拿最刁钻的合并顺序(./genBig n 2:两两配对、一层一层往上合)去打它:
| n | rankOnly 的最终树高 | log₂ n |
|---|---|---|
| 1 000 | 9 | 9.97 |
| 2 000 | 10 | 10.97 |
| 4 000 | 11 | 11.97 |
| 8 000 | 12 | 12.97 |
| 16 000 | 13 | 13.97 |
每一行都正好是 ⌊log₂ n⌋ —— 界是紧的,而且要专门造数据才顶得到 (第 33 章那条:要证明一个下界是紧的,就得自己造出那个最坏情况)。
./genBig 16000 2 | ./count # 看 rankOnly 那一行的「最终树高」 6 ★ 关键一步(二):路径压缩,以及 α(n) 为什么是「均摊」
int find(int x) {
int r = x;
while (fa[r] != r) r = fa[r]; // 第一趟:找到祖宗
while (fa[x] != r) { // ★ 第二趟:路径压缩
int nx = fa[x]; fa[x] = r; x = nx;
}
return r;
}★ 它的账和第 35 章是同一种账,但难得多:
- 第 35 章:「每个元素一辈子只能出栈一次」—— 一句话就说完了,总量被一个常数管死;
- 这一章:一次 find 可以很贵(走一条长路),可它走过之后那条路就没了 —— 贵的那一次,把后面无数次变便宜了。
★ 这才是「均摊」的本意:不是「每次都便宜」,是「贵的那几次自己把账付了」。
两句话一起上,每次操作的均摊代价是 O(α(n))。α 是反阿克曼函数, n < 2^65536 时 α(n) ≤ 5 —— 所以它「几乎是常数」,但它不是常数。
⚠ 老实话写在这里:α 那条界这一章不证(完整证明要用势函数分层,超出这本书的范围)。 这一章用实测代替:第 8 步那张表会让你亲眼看到三条曲线的增长率。
点「运行 ▶」看结果
half.cpp 那句 fa[x] = fa[fa[x]] 每路过一个点就把它挂到爷爷身上,这条路当场短一半;
它只走一趟,均摊复杂度和「压到根」同一个量级。
★ 第 35 章那条「
>=和>怎么写都对」的第二次,而这次「都对」的分量不一样: 那一章两种写法答案相同、过程不同;这一章两种写法连复杂度量级都相同,差的只是常数。 「都对」也分好几种,说清楚是哪一种才算说清楚。
7 ★ 换尺子:数一数 find 到底跳了多少步
点「运行 ▶」看结果
口径写在代码开头,正文里也说一遍,否则这张表没法读:
- 跳步数 = 执行
x = fa[x]的总次数,路径压缩的第二趟也算 (那是实打实的开销,不因为它是「顺手做的好事」就白送); - 最终树高 = 全部操作做完后森林里最深的点有多深, ⚠ 量它的那趟遍历不计进跳步数(第 29 章那条:量之前先确认你量的就是它)。
| 写法 | find 跳步数 | 单次最多 | 最终树高 |
|---|---|---|---|
| naive(什么都不加) | 18 | 4 | 4 |
| fakeCompress(✗ 压了个寂寞) | 18 | 4 | 4 |
| wrongOnce(✗ 只压了第一个点) | 12 | 3 | 3 |
| compress(只压缩) | 15 | 5 | 2 |
| half(路径减半) | 10 | 2 | 3 |
| rankOnly(只按秩) | 15 | 2 | 2 |
| wrongRank(✗ 方向反) | 13 | 3 | 2 |
| fast(正解) | 16 | 3 | 2 |
★ 正解在这组数据上是倒数第三名,比「什么都不加」只快 2 步。
这不是 bug,是复杂度这个词的定义:它说的是增长率,不是某一个数据点上的值。 n = 8 的时候,那条 O(n) 的曲线和那条 O(α(n)) 的曲线本来就还没分开; 而正解要多付「第二趟压缩」的钱,小数据上这笔钱甚至还没赚回来。
★★ 所以「三条曲线」必须真的是曲线 —— 一个数据点什么都证明不了。 ⚠ 这也顺带解释了第 11 步那件事:对拍用的都是这种小数据, 它连性能都区分不开,更别说抓 bug 了。
8 ★★ 三条曲线:把 n 翻倍,看谁跟着翻几倍
./genBig n 1 造链式合并顺序(这一章唯一能把曲线分开的形状,理由见第 9 步),种子固定:
| n | naive | wrongOnce | compress | half | fast |
|---|---|---|---|---|---|
| 1 000 | 663 885 | 20 182 | 10 003 | 3 481 | 2 496 |
| 2 000 | 2 632 450 | 57 472 | 20 882 | 7 065 | 4 953 |
| 4 000 | 10 639 853 | 159 124 | 45 005 | 14 990 | 9 961 |
| 8 000 | 42 759 103 | 464 076 | 96 775 | 31 024 | 20 004 |
| 16 000 | 170 598 030 | 1 313 353 | 204 304 | 64 197 | 39 984 |
| n 翻倍,它翻几倍 | 4.00 | 2.84 | 2.13 | 2.07 | 2.00 |
for n in 1000 2000 4000 8000 16000; do ./genBig $n 1 | ./count csv; done
- naive 4.00 —— n 翻倍它翻四倍,这是 Θ(n²) 的签名。16 000 个点就要跳 1.7 亿步;
- compress 2.13、half 2.07 —— 略微超线性,那个「略微」就是 log 级别的东西;
- fast 2.00 —— 干干净净的线性。每次操作的代价在这段范围里根本没涨。
★ 请注意 fast 那一列的绝对值:39 984 ≈ 操作条数 × 1.25。 平均每次 find 只往上跳一步多一点点 —— 这就是「几乎是 O(1)」长的样子。
⚠ 但别把 α(n) 读成「常数」:这段实测跨了 16 倍的 n,而 α 在这个范围里根本没变过 (α 要涨 1,n 要翻的是指数塔)。实测看不出它不是常数,这恰恰是它的可怕之处。
9 ★ 旋钮是「合并的顺序」—— 两种顺手写法各自把 bug 藏了起来
同样是 n = 4 000、同样的操作条数、同一串随机查询,只换「哪两个点被合到一起」的顺序:
| 形状 | naive 跳步数 | fast 跳步数 | 差几倍 |
|---|---|---|---|
2 ⚠ 两两配对、一层层往上合 | 10 958 | 7 704 | 1.4 倍 |
0 ⚠ 均匀随机的点对 | 863 355 | 12 554 | 68.8 倍 |
1 ★ 链式 1-2、2-3、3-4… | 10 639 853 | 9 961 | 1 068.2 倍 |
- 形状 2(先两两配对,再把配好的成对合并)—— 这是很多人随手就会写出来的顺序, 可它自己就长成了一棵平衡树(高 ⌊log₂ n⌋,第 5 步那张表就是拿它量的)。 在这种数据上,「不压缩」和正解只差 1.4 倍 —— 三条曲线全挤在一起,什么都量不出来。
- 形状 0(均匀随机的点对)看着最「公平」,也只把差距拉到 68.8 倍。
- 只有形状 1(故意串成一条链) 才让 naive 露出它真实的 Θ(n²)。
★ 要随机的是算法依赖的那个量。 并查集的复杂度依赖的是树能不能长深, 所以旋钮是合并的顺序(结构),不是 n、不是操作条数、更不是点的编号。 (第 26~33 章连着踩了八次的那条「顺手写法会悄悄给数据加一条题目里没有的性质」, 这一章又踩到了 —— 而这一次它加的那条性质是**「树是平衡的」**。)
⚠ 这三种形状的答案全都一样正确,三份代码也全都通过对拍。 藏起来的从来不是错误,是慢。
⚠ 这张表的前提也得写成断言。 上面那句「点数、操作条数、查询序列完全相同」 是这张表能成立的全部依据,所以
check:viz里专门有一条检查 三种形状的查询行逐字节相同 —— 而它第一次跑就把我抓了个正着: 形状 0 的合并本身要抽随机数,和查询共用一个随机数流,查询序列整个错位了。 修的是生成器(改成两个独立的流),不是那条断言。 ★ 「这两组数据只差一件事」这种话,不要凭代码看起来对就写进正文。
10 ★ 动画:同一串操作,两片森林并排长
左边 naive(不压缩、不按秩),右边 fast(压缩 + 按秩)。高亮的是这一步 find 走过的那条路。
播一遍你会看到:
- 画面下方那行结论(Y / N / 合并 / 已同族)两边永远一模一样;
- 右上角那两个「累计跳步」越拉越开;
- 右边那棵树被压缩「拍扁」的那一刻很好认:某个点的爸爸突然直接变成了根。
把数据切到「★ 链式合并」那一档,左边会长成一条越来越长的链,右边始终是一棵扁扁的星形树。 再切到「⚠ 两两配对」那一档 —— 两边几乎一样扁,这就是第 9 步那句 「顺手写法会把差距藏起来」在画面上的样子。
⚠ 动画和
trace.cpp在check:viz里是逐步对的:每一步两边的 find 路径、 这一步跳了几步、累计跳了几步、fa数组、rk数组,全部逐字节比 (不只对最终答案 —— 第 21 章以来那条规矩)。
11 ★★ 五种「对拍一辈子也抓不到」的写法
它们不是「碰巧没被抓到」,而是原理上抓不到:它们根本不改变任何一个答案。
| 写法 | 错在哪 | 对拍 | n=16 000 链上的跳步数 |
|---|---|---|---|
naive.cpp | 什么都不加(不算 bug,是基准线) | 0 / 300 | 170 598 030 |
fakeCompress.cpp | ✗ fa[x] = x,压了个寂寞 | 0 / 300 | 170 598 030 |
wrongOnce.cpp | ✗ 压缩时两行写反了顺序 | 0 / 300 | 1 313 353 |
wrongRank.cpp | ✗ 按秩合并方向反 | 0 / 300 | 204 304 |
half.cpp | 没错,是另一种正确写法 | 0 / 300 | 64 197 |
点「运行 ▶」看结果
int find(int x) { while (fa[x] != x) x = fa[x]; fa[x] = x; return x; }
// ~~~~~~~~~ 此刻 x 早就是根了循环停下来时 x 已经变成了根,于是 fa[x] = x 是把根挂到根自己身上 —— 一句空话。
★ fakeCompress ≡ naive:不只是答案一样,跳步数一个数字都不差 (1 000 / 2 000 / 4 000 / 8 000 / 16 000 五个规模全部逐字节相同,钉在
check:viz里)。 这是本教材第十二条恒等式(前十一条在第 23~28、34、35 章)。
⚠ 而它和前十一条有一个本质区别:前面那些恒等式两边都是「答案」,这一条两边是「开销」。
点「运行 ▶」看结果
while (fa[x] != r) { fa[x] = r; x = fa[x]; } // ✗ x 直接跳到 r,循环立刻结束
while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; } // ✓ 先把「原来的爸爸」存下来它比上一个温和:每次 find 至少还是把起点挂到了根上。 代价是跳步数从 20 万涨到 131 万(n = 16 000),介于 naive 和 compress 之间 —— 温和正是它更难被发现的原因。
点「运行 ▶」看结果
if (rk[ra] < rk[rb]) swap(ra, rb); // ✗ 反了
if (rk[ra] > rk[rb]) swap(ra, rb); // ✓实测(链式数据):它的跳步数在五个规模上和「根本没写按秩合并」的 compress.cpp 一模一样
(10 003 / 20 882 / 45 005 / 96 775 / 204 304)。
★ 把方向写反,效果等于没写。 那句
swap白写了,rk数组也白维护了。 ⚠ 但这条「相等」是实测出来的,不是证出来的 —— 第 2 步那组小数据上它俩就不相等 (13 vs 15)。一个方向能证明,反过来只是实测没碰到反例 (第 35 章「忘弹队头 vs 出界差一」的同款分寸)。
⚠ 还要注意它没有慢到天上去:路径压缩很宽容,它会把按秩合并的错误一路补回来。 这正是「只写路径压缩」能在竞赛里活到今天的原因。
★★ 对拍验的是「两份代码想的是不是同一件事」,验不了「它跑得快不快」。
所有只影响复杂度、不影响答案的写法,对拍原理上全都看不见。
这是随机对拍的第四个盲区,而且它比前三个都要普遍:
- 第 20 章:对拍只能证伪,不能证明「对」;
- 第 31 章:验证器证明不了「答案存在时你没漏报」;
- 第 33 章:随机数据碰不到最坏情况(下界紧不紧,随机对拍答不了);
- ★ 第 36 章(本章):对拍看不见「慢」。第 35 章那个溢出是它的一个特例 (溢出改答案、只是小数据碰不到;而这一章的五份代码连答案都不改)。
想抓这一类 bug,只有一条路:换尺子。 数次数(count.cpp),别只看答案。
12 ★ 对拍与生成器:三个真 bug,和一个被实测打脸的预判
点「运行 ▶」看结果
第 2 步那组数据上它给 N Y N N N(正解 N Y Y Y N)。
⚠ 第 34 章已经讲过它为什么错,这一章只补那一章没做的事:量一量几轮能抓住。
点「运行 ▶」看结果
它给 N Y N Y N —— 只错在第 3 个查询上。
★ 它错得比上一个隐蔽:a 自己连过去了,a 的子孙也跟着过去了,掉队的是 a 的祖宗那一支。
点「运行 ▶」看结果
它的 Y / N 五个全对,只有最后一行是 1(正解 2)—— 第 2 步第 5 行那次重复合并多减了一次。
300 轮实测(种子 1..300,最终档):
| 故意写错的地方 | 被抓 | 第几轮 |
|---|---|---|
只挂点不挂族(wrongUnite) | 300 / 300 | 第 1 轮 |
无条件 cnt--(wrongCnt) | 300 / 300 | 第 1 轮 |
比爸爸不比祖宗(wrongFa) | 267 / 300 | 第 1 轮 |
| 上一节那五份「只影响速度」的 | ★ 0 / 300 | — |
gen.cpp 带了八个档位(./gen 种子 档位),种子固定 1..300:
| 档位 | 相对档位 0 改了什么 | 比爸爸 | 挂点不挂族 | 无条件 cnt— |
|---|---|---|---|---|
| 0(顺手写法) | n ∈ [8,14]、m ∈ [8,14]、合并/查询各半、两端随机 | 11 | 46 | 179 |
| 1 | 点数压小 n ∈ [4,8] | 21 | 87 | 253 |
| 2 | 合并的两头都从「已经合过的点」里挑 | ★ 0 | ★ 0 | 295 |
| 3 | 1/4 的合并重复用过的点对 | 5 | 30 | 249 |
| 4 | 操作数顶到 m ∈ [70,110] | 223 | 296 | 300 |
| 5 | 点数放大 n ∈ [12,20] | ★ 3 | 19 | 117 |
★ 档位 2 是这一章最响的一记耳光。 我的推理是这样的: 三个 bug 都要「深度 ≥ 2」才现形,那就让合并的两头都落在已经被合过的点上, 新树自然长在旧树上 —— 听起来无懈可击。
实测:两个 bug 一起变成 0 / 300。
原因一句话:两头都是老点,合并就几乎总是撞上同一族 —— 300 轮里 295 轮出现重复合并,可合并全成了空转,森林压根没长起来。 我以为自己在造深度,其实是在造「什么都不做」。
★ 第 31 章那条「某一支占得太多,会吃掉别人」的第六次, 而且这次是我亲手把那一支喂到 295 / 300 的。 ⚠ 档位 3(重复点对)是同一个毛病的轻症版:5 / 30 / 249。
★ 档位 5 更刺眼:单看这一行,「点数放大」是个灾难 —— 比爸爸从 11 掉到 3,挂点不挂族从 46 掉到 19,无条件 cnt— 从 179 掉到 117。 任何一个只看单行表的人都会当场把它扔掉。 别急,看下一张表。
| 档位 | 内容 | 比爸爸 | 挂点不挂族 | 无条件 cnt— |
|---|---|---|---|---|
| 4 | 只加「操作数顶到 70~110」 | 223 | 296 | 300 |
| 6(在用) | 4 + 5(操作数顶上去 + 点数放大) | 267 | 300 | 300 |
| 7(对照) | 6 +「重复用过的点对」 | 251 | 298 | 300 |
- 「点数放大」单独加是 11 → 3(灾难),加在「操作数顶上去」之后是 223 → 267。
道理其实很直白:点多了,就需要更长的操作序列才攒得出一棵深树;
序列不够长时点越多越稀,序列够长时点多才有地方长。
★★ 第 32、34、35 章那条「调优不可加:一处改动值不值得留,取决于其它旋钮此刻在哪」 的第四次,而这次的形状最刺眼:一处单独看是负分的改动,最后被留下了。
- 档位 7 是在最终环境里重新量「重复点对」(第 34 章那条):267 → 251,仍然有害,撤回。 ⚠ 但请注意它在档位 3 那里是 11 → 5(几乎全灭),到最终环境只剩 −16 —— 同一处改动的危害,也是随环境变的。
★ 最后定档的标准照旧:让最弱的那一支尽量强(不是平均分最高)—— 最弱的一直是「比爸爸不比祖宗」,档位 6 把它顶到了 267。
把同一批数据(最终档,300 轮)只比 Y / N 那半,把最后那行连通块个数去掉:
| 故意写错的地方 | 两问都比 | ★ 只比 Y / N |
|---|---|---|
| 比爸爸不比祖宗 | 267 | 267 |
| 只挂点不挂族 | 300 | 297 |
无条件 cnt-- | 300 | ★ 0 |
★ 第 35 章那条「题目多问一句,对拍就多一条腿」的第二次现场,而这次更干净: 一行输出把一个 bug 从 0 / 300 抬到 300 / 300。
⚠ 顺带看「只挂点不挂族」那一行:300 → 297,有 3 轮只有连通块个数那一行抓得住它。 多问的那一句,不只救了它自己那个 bug。
13 ⚠ 递归版 find 会爆栈吗 —— 一个比想象中拧巴的实测
| 写法 | 编译 | 结果 |
|---|---|---|
| 带路径压缩的递归 find | -O2 | 60 万层活,70 万层段错误 |
不压缩的三行版(return fa[x]==x ? x : find(fa[x])) | -O2 | 1 000 万层照样活 |
| 同一份不压缩的代码 | -O0 | 20 万层活,40 万层段错误 |
g++ -O2 -std=c++17 -o deep deep.cpp
./deep 600000 # 成功
./deep 700000 # Segmentation fault
./deep 10000000 0 # 不压缩的那份,一千万层也没事
g++ -O0 -std=c++17 -o deep0 deep.cpp # ★ 只换编译选项
./deep0 200000 0 # 成功
./deep0 400000 0 # Segmentation fault ← 同一份代码,同一个写法★ 两件意料之外的事:
- 爆的偏偏是「带路径压缩」的那一份。
不压缩的写法是尾调用(递归返回之后没活要干了),g++
-O2直接把它变成了循环; 带压缩的写法多了个fa[x] = …的赋值,尾调用没了,于是老老实实一层一帧。 - 所以「这段代码会不会爆栈」不是代码的属性,是代码 + 编译选项的属性 ——
同一份不压缩的代码,
-O0编译出来 40 万层就死。
★ 第 30 章那条「递归的深度上限是数据能拉出来的最长那条链」的第二次 (那一章是图上 DFS,一条路走掉了点数的 57%)。 ⚠ 而这一章多出来一句更实用的:竞赛里那些三行递归 find 从来没出过事, 靠的既不是运气也不是尾调用 —— 是「压缩之后链根本长不到那么深」。 你只会在第一次碰上一条几十万长的链时死掉,而那条链只有不按秩合并才造得出来。
14 这一章可以带走的五样东西
【1】★★ 对拍看不见「慢」。 本章八种写法答案完全相同,对拍 0 / 300。 所有只影响复杂度、不影响答案的写法,对拍原理上全都抓不到。 这是随机对拍的第四个盲区(前三个:只能证伪 / 验证器证不了没漏报 / 碰不到最坏情况)。 ★ 换尺子:数次数,别只看答案;而且次数可复现,秒数不可复现。
【2】★ 按秩合并那条界能证,α(n) 那条不证。 秩为 r 的树至少 2^r 个点 ⇒ 树高 ≤ log₂ n(n = 2×10⁵ 时只有 17)—— 三行归纳就完了, 而且实测能顶到 ⌊log₂ n⌋(要专门造数据)。 α(n) 这一章只给结论 + 三条实测曲线。说清楚哪句证了、哪句没证,比含糊过去要紧。
【3】★ 均摊的第二课:不是「每次都便宜」,是「贵的那几次自己把账付了」。 一次 find 可以走很长一条路,但它走过之后那条路就没了。 第 35 章那句「每个元素一辈子只能出去一次」是这件事的入门版。
【4】★ 复杂度是增长率,不是某一个数据点。 n = 8 的时候正解排倒数第三(16 步 vs naive 的 18 步); 要看清差别,必须让 n 翻倍着走:翻倍比 4.00 / 2.84 / 2.13 / 2.00 才是那三条曲线的本体。
【5】★ 这一章的生成器把「调优不可加」推到了新的高度。
- 「合并两头都从老点里挑」本以为在造深度,实际造出了 295 / 300 的空转合并, 两个真 bug 一起变成 0 / 300;
- 「点数放大」单独加是灾难(11 → 3),配上「操作序列拉长」却是最好的一档(223 → 267)—— 一处单独看是负分的改动,最后被留下了。
★ 所以:每一处改动都要在最终环境里重新量一遍,而且别指望旋钮能一条条叠加。
第 37 章:堆与 priority_queue(手写堆 → STL)。
★ 关键一步是上浮 / 下沉各 O(log n),而这次的 log 是证得死死的 (完全二叉树的高度就是 log₂ n)—— 正好和这一章那个「证不了、只能实测」的 α 形成对照。 ⚠ 顺带回收第 12 章「第 k 小」那道题的另一种解法。
15 自测
- 洛谷 P3367 【模板】并查集 —— 本章那道题去掉最后一行输出。写完拿 count.cpp 的思路给自己数一遍跳步数
- 洛谷 P1551 亲戚 —— 最裸的连通性查询,5 分钟的题 —— 用它确认你默写的版本是对的
- 洛谷 P1892 [BOI2003] 团伙 —— ★ 「敌人的敌人是朋友」:扩展域并查集,把点数翻倍。第一次见会觉得很妙
- 洛谷 P2024 [NOI2001] 食物链 —— ★★ 带权 / 扩展域并查集的经典题,难度上一个台阶。想清楚「三倍点」或者「到根的距离模 3」
- 洛谷 P1197 [JSOI2008] 星球大战 —— ★ 并查集只能合并、不能拆开 —— 所以要把时间倒过来跑。这条限制正是这一章那片森林的必然结果
- 洛谷 P1955 [NOI2015] 程序自动分析 —— ★ 先离散化再并查集;先做完所有「相等」再验「不等」—— 顺序错了就全错
- 洛谷 P3958 [NOIP2017 提高组] 奶酪 —— 把「两球相交」当成一条边,就是连通性。练的是「看出这是并查集」这一步