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

并查集

基本操作第 34 章已经讲完了,这一章只回答一个问题:它为什么快到几乎是 O(1)。★ 关键一步是路径压缩 + 按秩合并,而 O(α(n)) 是**均摊**出来的 —— 第 35 章那句「每个元素一辈子只能出去一次」是它的热身。⚠ 这一章还有一件前 35 章都没碰到的事:本章八种写法**答案完全相同**,对拍在它们身上一轮都抓不到。

例题:连通性查询(合并 + 查询 + 数连通块) 建议用时:130 分钟
这一章不重复讲用法 —— 以及它和前 35 章的一个根本区别

第 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 现在连不连通

每个查询输出一行 YN最后再输出一行:现在一共有多少个连通块。

★ 请留意题面的最后一行 —— 它不是凑数的

这道题本来只需要输出 Y / N。加上「最后输出连通块个数」这一行,是故意的:

★ 第 35 章那条教训(题面多问一句,对拍就多一条腿)在这一章有一个极干净的现场: 第 12 步那个「忘了判已经同族就 cnt--」的 bug, 只看 Y / N 那半是 0 / 300,把最后那一行算上就是 300 / 300。

一行输出,把一个 bug 从「测不到」抬到「第 1 轮就抓住」。

2 手算一遍:默认那组操作

★ 这 12 步里埋了三个东西,第 11、12 步会挨个用到
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 标准答案:一点历史都不攒

brute.cpp标准答案:每次查询现场建图 + BFS
输入(stdin)
输出
点「运行 ▶」看结果
★ 为什么标准答案要用 BFS,而不是「另写一份并查集」

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

所以这一份什么都不攒:合并只是把边记下来,查询时拿现有的边从零建一遍图、 从 a 出发 BFS,看 b 在不在里面。

★ 而且 BFS 里一次 break 都没有 —— 哪怕半路撞见 b,也要把 a 的连通块整个走完。 (第 31 章:「找到第一个就 break」会让暴力假装自己不慢; 第 35 章补的后半句:标准答案要挑「没有 break」的那个思路。)

4 实测:暴力有多慢

本机实测./genBig n 1 造的链式数据,n 个点、2(n−1) 条操作):

nbrute(每次查询重建图 + BFS)naive(并查集,不压缩)fast(压缩 + 按秩)
4 0000.08 秒0.02 秒0.00 秒
16 0001.16 秒0.30 秒0.00 秒
32 0005.48 秒1.28 秒0.00 秒
64 0005.03 秒0.00 秒
200 00049.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 秒
⚠ 那 0.03 秒里,算法一秒都没占 —— 全是读入
./count read < big.txt      # ★ 只把输入读完就退出,什么都不算

也是 0.03 秒。 也就是说:20 万个点、40 万条操作,正解跑完全程的时间和只读一遍输入一样, 算法那部分在秒表上根本量不出来。

★ 第 29、32、34、35 章那条「量之前先确认「你量的就是它」」的第五次。 ⚠ 所以 count.cpp 的读入必须和 fast.cpp 写得一模一样 (都是 cin + 关掉 sync_with_stdio),否则量出来的「读入耗时」不是它的读入耗时 (第 32 章那条:两份代码的 I/O 设置不一致,量的是读入速度不是算法)。

这就是这一章必须换尺子的直接原因:秒表在正解身上已经失灵了。

5 ★ 关键一步(一):按秩合并 —— 这一章唯一能完整证明的那条界

★★ 矮的挂到高的:秩为 r 的树,至少有 2^r 个点

合并两棵树时,只有一件事可以选:谁挂到谁下面

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) 那条只给结论加实测 —— 说清楚哪句证明了、哪句没证,比含糊过去要紧得多。

rankOnly.cpp只按秩合并(不压缩)—— 上面那条界说的就是它
★ 这条界不只是「证出来的」,它还被顶到过 —— 实测

拿最刁钻的合并顺序(./genBig n 2:两两配对、一层一层往上合)去打它:

nrankOnly 的最终树高log₂ n
1 00099.97
2 0001010.97
4 0001111.97
8 0001212.97
16 0001313.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 步那张表会让你亲眼看到三条曲线的增长率。

fast.cpp正解:路径压缩 + 按秩合并
输入(stdin)
输出
点「运行 ▶」看结果
★ 另外三份「也对」的写法
naive.cpp什么都不加(基准线)
compress.cpp只路径压缩 —— 竞赛里最常见的写法
half.cpp路径减半:一趟走完,边走边挂到爷爷身上

half.cpp 那句 fa[x] = fa[fa[x]] 每路过一个点就把它挂到爷爷身上,这条路当场短一半; 它只走一趟,均摊复杂度和「压到根」同一个量级。

★ 第 35 章那条「>=> 怎么写都对」的第二次,而这次「都对」的分量不一样: 那一章两种写法答案相同、过程不同;这一章两种写法连复杂度量级都相同,差的只是常数。 「都对」也分好几种,说清楚是哪一种才算说清楚。

7 ★ 换尺子:数一数 find 到底跳了多少步

count.cpp八种写法并排跑,数跳步数(附「只读入」开关)
输入(stdin)
输出
点「运行 ▶」看结果

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

  • 跳步数 = 执行 x = fa[x] 的总次数,路径压缩的第二趟也算 (那是实打实的开销,不因为它是「顺手做的好事」就白送);
  • 最终树高 = 全部操作做完后森林里最深的点有多深, ⚠ 量它的那趟遍历不计进跳步数(第 29 章那条:量之前先确认你量的就是它)。
⚠ 第 2 步那组数据上的结果,反直觉到值得单独摆一张表
写法find 跳步数单次最多最终树高
naive(什么都不加)1844
fakeCompress(✗ 压了个寂寞)1844
wrongOnce(✗ 只压了第一个点)1233
compress(只压缩)1552
half(路径减半)1023
rankOnly(只按秩)1522
wrongRank(✗ 方向反)1332
fast(正解)1632

正解在这组数据上是倒数第三名,比「什么都不加」只快 2 步。

这不是 bug,是复杂度这个词的定义:它说的是增长率,不是某一个数据点上的值。 n = 8 的时候,那条 O(n) 的曲线和那条 O(α(n)) 的曲线本来就还没分开; 而正解要多付「第二趟压缩」的钱,小数据上这笔钱甚至还没赚回来。

★★ 所以「三条曲线」必须真的是曲线 —— 一个数据点什么都证明不了。 ⚠ 这也顺带解释了第 11 步那件事:对拍用的都是这种小数据, 它连性能都区分不开,更别说抓 bug 了。

8 ★★ 三条曲线:把 n 翻倍,看谁跟着翻几倍

./genBig n 1 造链式合并顺序(这一章唯一能把曲线分开的形状,理由见第 9 步),种子固定:

nnaivewrongOncecompresshalffast
1 000663 88520 18210 0033 4812 496
2 0002 632 45057 47220 8827 0654 953
4 00010 639 853159 12445 00514 9909 961
8 00042 759 103464 07696 77531 02420 004
16 000170 598 0301 313 353204 30464 19739 984
n 翻倍,它翻几倍4.002.842.132.072.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 9587 7041.4 倍
0 ⚠ 均匀随机的点对863 35512 55468.8 倍
1 ★ 链式 1-2、2-3、3-4…10 639 8539 9611 068.2 倍
⚠ 又是「顺手写法」,而这次有两种长相完全不同的顺手写法
  • 形状 2(先两两配对,再把配好的成对合并)—— 这是很多人随手就会写出来的顺序, 可它自己就长成了一棵平衡树(高 ⌊log₂ n⌋,第 5 步那张表就是拿它量的)。 在这种数据上,「不压缩」和正解只差 1.4 倍 —— 三条曲线全挤在一起,什么都量不出来。
  • 形状 0(均匀随机的点对)看着最「公平」,也只把差距拉到 68.8 倍。
  • 只有形状 1(故意串成一条链) 才让 naive 露出它真实的 Θ(n²)。

要随机的是算法依赖的那个量。 并查集的复杂度依赖的是树能不能长深, 所以旋钮是合并的顺序(结构),不是 n、不是操作条数、更不是点的编号。 (第 26~33 章连着踩了八次的那条「顺手写法会悄悄给数据加一条题目里没有的性质」, 这一章又踩到了 —— 而这一次它加的那条性质是**「树是平衡的」**。)

⚠ 这三种形状的答案全都一样正确,三份代码也全都通过对拍。 藏起来的从来不是错误,是慢。

这张表的前提也得写成断言。 上面那句「点数、操作条数、查询序列完全相同」 是这张表能成立的全部依据,所以 check:viz 里专门有一条检查 三种形状的查询行逐字节相同 —— 而它第一次跑就把我抓了个正着: 形状 0 的合并本身要抽随机数,和查询共用一个随机数流,查询序列整个错位了。 修的是生成器(改成两个独立的流),不是那条断言。 ★ 「这两组数据只差一件事」这种话,不要凭代码看起来对就写进正文。

genBig.cpp(三种合并顺序)点数、操作条数、查询序列完全相同,只有合并顺序不同

10 ★ 动画:同一串操作,两片森林并排长

同一串操作,两片森林并排长 —— 答案永远一样,差的只是「跳了多少步」
两边答案 一样(NYYYN/2)
第 1 / 13 步
naive:不压缩、不按秩
这一步跳 0★ 累计跳步 0
1
2
3
4
5
6
7
8
fast:路径压缩 + 按秩合并
这一步跳 0★ 累计跳步 0
1
2
3
4
5
6
7
8
高亮 = 这一步 find 走过的路(含起点和祖宗) 缩进 = 这个点离根有多远 ★ 两边的结论永远相同:开局
开局:8 个点各自成族,谁的爸爸都是自己

左边 naive(不压缩、不按秩),右边 fast(压缩 + 按秩)。高亮的是这一步 find 走过的那条路。

★ 这个动画只有一件事要看:两个「累计跳步」

播一遍你会看到:

  • 画面下方那行结论(Y / N / 合并 / 已同族)两边永远一模一样
  • 右上角那两个「累计跳步」越拉越开
  • 右边那棵树被压缩「拍扁」的那一刻很好认:某个点的爸爸突然直接变成了根

把数据切到「★ 链式合并」那一档,左边会长成一条越来越长的链,右边始终是一棵扁扁的星形树。 再切到「⚠ 两两配对」那一档 —— 两边几乎一样扁,这就是第 9 步那句 「顺手写法会把差距藏起来」在画面上的样子。

⚠ 动画和 trace.cppcheck:viz 里是逐步对的:每一步两边的 find 路径、 这一步跳了几步、累计跳了几步、fa 数组、rk 数组,全部逐字节比 (不只对最终答案 —— 第 21 章以来那条规矩)。

trace.cpp动画照着它画:每一步两边的路径 / 跳步 / fa 数组

11 ★★ 五种「对拍一辈子也抓不到」的写法

⚠ 下面五份代码,在 300 轮对拍里全都是 0 / 300

它们不是「碰巧没被抓到」,而是原理上抓不到:它们根本不改变任何一个答案

写法错在哪对拍n=16 000 链上的跳步数
naive.cpp什么都不加(不算 bug,是基准线)0 / 300170 598 030
fakeCompress.cppfa[x] = x,压了个寂寞0 / 300170 598 030
wrongOnce.cpp✗ 压缩时两行写反了顺序0 / 3001 313 353
wrongRank.cpp✗ 按秩合并方向反0 / 300204 304
half.cpp没错,是另一种正确写法0 / 30064 197
✗ 一、压了个寂寞 —— ★ 本教材第十二条恒等式
fakeCompress.cpp✗ 自以为加了路径压缩
输入(stdin)
输出
点「运行 ▶」看结果
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 章)。

⚠ 而它和前十一条有一个本质区别:前面那些恒等式两边都是「答案」,这一条两边是「开销」。

✗ 二、只压了第一个点 —— 两行代码换个顺序而已
wrongOnce.cpp✗ 先改 fa[x] 再往上走
输入(stdin)
输出
点「运行 ▶」看结果
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 之间 —— 温和正是它更难被发现的原因。

✗ 三、按秩合并方向反 —— 一个不等号,等于白写
wrongRank.cpp✗ 高的挂到矮的下面
输入(stdin)
输出
点「运行 ▶」看结果
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 出界差一」的同款分寸)。

⚠ 还要注意它没有慢到天上去:路径压缩很宽容,它会把按秩合并的错误一路补回来。 这正是「只写路径压缩」能在竞赛里活到今天的原因。

★★ 这一节真正要带走的那句话

★★ 对拍验的是「两份代码想的是不是同一件事」,验不了「它跑得快不快」。

所有只影响复杂度、不影响答案的写法,对拍原理上全都看不见。

这是随机对拍的第四个盲区,而且它比前三个都要普遍:

  1. 第 20 章:对拍只能证伪,不能证明「对」;
  2. 第 31 章:验证器证明不了「答案存在时你没漏报」
  3. 第 33 章:随机数据碰不到最坏情况(下界紧不紧,随机对拍答不了);
  4. ★ 第 36 章(本章):对拍看不见「慢」。第 35 章那个溢出是它的一个特例 (溢出改答案、只是小数据碰不到;而这一章的五份代码连答案都不改)。

想抓这一类 bug,只有一条路:换尺子。 数次数(count.cpp),别只看答案。

12 ★ 对拍与生成器:三个真 bug,和一个被实测打脸的预判

★ 先看三个「对拍抓得到」的真 bug
wrongFa.cpp✗ 查询比的是爸爸不是祖宗
输入(stdin)
输出
点「运行 ▶」看结果

第 2 步那组数据上它给 N Y N N N(正解 N Y Y Y N)。 ⚠ 第 34 章已经讲过它为什么错,这一章只补那一章没做的事:量一量几轮能抓住

wrongUnite.cpp✗ 只挂点不挂族(左边忘了 find)
输入(stdin)
输出
点「运行 ▶」看结果

它给 N Y N Y N —— 只错在第 3 个查询上。 ★ 它错得比上一个隐蔽:a 自己连过去了,a 的子孙也跟着过去了,掉队的是 a 的祖宗那一支

wrongCnt.cpp✗ 无条件 cnt--(少了「已同族就跳过」)
输入(stdin)
输出
点「运行 ▶」看结果

它的 Y / N 五个全对,只有最后一行是 1(正解 2)—— 第 2 步第 5 行那次重复合并多减了一次。

对拍器
★ 这个生成器我一共试了五个旋钮,其中两个把真 bug 打成了 0 / 300,一个单独看是灾难、最后却留下了。下面两张表把每一处的账都摆出来。

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

故意写错的地方被抓第几轮
只挂点不挂族wrongUnite300 / 300第 1 轮
无条件 cnt--wrongCnt300 / 300第 1 轮
比爸爸不比祖宗wrongFa267 / 300第 1 轮
上一节那五份「只影响速度」的0 / 300
★★ 第一张表:五个旋钮,一次只改一处(全部从「顺手写法」出发)

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

档位相对档位 0 改了什么比爸爸挂点不挂族无条件 cnt—
0(顺手写法)n ∈ [8,14]、m ∈ [8,14]、合并/查询各半、两端随机1146179
1点数压小 n ∈ [4,8]2187253
2合并的两头都从「已经合过的点」里挑00295
31/4 的合并重复用过的点对530249
4操作数顶到 m ∈ [70,110]223296300
5点数放大 n ∈ [12,20]319117

档位 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」223296300
6(在用)4 + 5(操作数顶上去 + 点数放大)267300300
7(对照)6 +「重复用过的点对」251298300
  • 「点数放大」单独加是 11 → 3(灾难),加在「操作数顶上去」之后是 223 → 267。 道理其实很直白:点多了,就需要更长的操作序列才攒得出一棵深树; 序列不够长时点越多越稀,序列够长时点多才有地方长。

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

  • 档位 7 是在最终环境里重新量「重复点对」(第 34 章那条):267 → 251,仍然有害,撤回。 ⚠ 但请注意它在档位 3 那里是 11 → 5(几乎全灭),到最终环境只剩 −16 —— 同一处改动的危害,也是随环境变的。

★ 最后定档的标准照旧:让最弱的那一支尽量强(不是平均分最高)—— 最弱的一直是「比爸爸不比祖宗」,档位 6 把它顶到了 267。

gen.cpp(八个档位)五处改动全部可重跑,包括两次「本以为聪明」的失败
★★ 这一章最该记住的一格:一整行输出值多少

把同一批数据(最终档,300 轮)只比 Y / N 那半,把最后那行连通块个数去掉

故意写错的地方两问都比★ 只比 Y / N
比爸爸不比祖宗267267
只挂点不挂族300297
无条件 cnt--3000

★ 第 35 章那条「题目多问一句,对拍就多一条腿」的第二次现场,而这次更干净: 一行输出把一个 bug 从 0 / 300 抬到 300 / 300

⚠ 顺带看「只挂点不挂族」那一行:300 → 297,有 3 轮只有连通块个数那一行抓得住它。 多问的那一句,不只救了它自己那个 bug。

13 ⚠ 递归版 find 会爆栈吗 —— 一个比想象中拧巴的实测

deep.cpp⚠ 只能在终端里跑:它靠真的把栈压爆来演示
⚠ 本机实测(栈 8 MB,`ulimit -s` = 8192)
写法编译结果
带路径压缩的递归 find-O260 万层活,70 万层段错误
不压缩的三行版(return fa[x]==x ? x : find(fa[x])-O21 000 万层照样活
同一份不压缩的代码-O020 万层活,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 ← 同一份代码,同一个写法

★ 两件意料之外的事:

  1. 爆的偏偏是「带路径压缩」的那一份。 不压缩的写法是尾调用(递归返回之后没活要干了),g++ -O2 直接把它变成了循环; 带压缩的写法多了个 fa[x] = … 的赋值,尾调用没了,于是老老实实一层一帧。
  2. 所以「这段代码会不会爆栈」不是代码的属性,是代码 + 编译选项的属性 —— 同一份不压缩的代码,-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 自测

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