阶段 6 · 图论 · 第 34 章

最小生成树:Kruskal 与 Prim

问题从「两点之间最短」换成「把所有点连起来,总代价最小」。两个算法看着完全不像,其实是同一条性质的两个推论 —— 而那条性质的证明里,「边权非负」一次都没出现,所以这一章的贪心不怕负权。顺带还账:两种算法长出的树可能不一样,那对拍到底该比什么。

例题:最小生成树(可能有负权边,图不保证连通) 建议用时:130 分钟
上一章欠下的两笔账,这一章一起还

第 33 章结尾白纸黑字写了两件事:

★ 关键一步是切割性质:横跨任意一个切割的最小边,一定在某棵最小生成树里 —— Kruskal 和 Prim 都是它的推论,只是「怎么选那个切割」不一样。 ⚠ 两种算法给出的树可能长得不一样,但权值和必须相同 —— 那么对拍该比什么?

第 4、5 步还①(而且第 5 步把那个证明做成了画面),第 10 步还②。

顺带一件计划里的事:Kruskal 要用并查集,而并查集本来排在第 36 章。 照第 27 章的先例(树形 DP 自带一小节邻接表),这一章自带一小节并查集(第 6 步), 第 36 章改成讲它的复杂度 —— 路径压缩 + 按秩合并为什么几乎是 O(1)

1 一句话问题

给一张无向带权图(n 个点、m 条边,-100 ≤ w ≤ 100)。 选出若干条边,使得所有点连通、且总权值最小 —— 输出这个最小总权值。 如果整张图本来就不连通(生成树根本不存在),输出一行 IMPOSSIBLE

⚠ 可能有重边、自环,不保证连通,而且边权可以是负数

★ 「生成树」这三个字里有两个限制,少看一个就写错
  • 生成:所有 n 个点都得连上(不是「连上一部分」);
  • :恰好 n−1 条边、不成环。

这两条其实是一件事的两面:n 个点、n−1 条边、连通 ⇔ 是一棵树。 所以代码里只要盯住一个数字:选中的边数有没有到 n−1。 到不了,就说明图本来不连通 —— 第 9 步那个错误版本漏的就是这一句。

⚠ 为什么「不连通」用 IMPOSSIBLE,而不是 -1 或者 0

因为边权可以是负数,权值和完全可能正好等于 −1,也完全可能是 0。

答案的记号和数据的取值范围是一对,改了一边就得对一遍另一边。

第 33 章刚为这件事把「走不到」的记号从 -1 改成了 x(那一章距离可以是负的), 这是同一条规矩的第二次登场。它不会报错,只会让对拍在某些数据上莫名其妙地红。

2 手算一遍:默认那张图

★ 图 A:左右两个三角,中间两条权值并列的桥
6 10
1 2 1      ┐
2 3 1      ├ 左边一个三角 1-2-3,★ 其中 1—3 是 -2(负权边)
1 3 -2     ┘
3 4 5      ← 桥一
4 5 2      ┐
5 6 3      ├ 右边一个三角 4-5-6
4 6 4      ┘
2 4 5      ← 桥二,★ 和桥一**权值并列**(都是 5)
1 1 -7     ← ★ 一条**负的自环**
3 4 9      ← ★ 3—4 的**重边**(更贵的那条)

手算(把边从小到大排一遍,能连就连):

收不收
1—1−7✗ 自环,两端本来就是同一个点
1—3−2
1—21
2—31✗ 1、2、3 已经连通了,再连成环
4—52
5—63
4—64✗ 成环
2—45✓ 左右两块接上了,第 5 条 —— 够 n−1 了

答案 9(= −2+1+2+3+5)。

★ 那条 −7 的自环是这张图的第一个陷阱:它是全图最小的边,可它一个新点都连不上, 那点「白送的负权」根本拿不到。第 9 步有一份代码就栽在这儿。

⚠ 顺带对照一下第 33 章:那一章一条负的自环就是一个负环,是灾难; 这一章它完全无害。同一样东西在两道题里的分量可以差得非常远。

★ 图 B:把中间那两条桥撤掉 —— 图就碎了
6 7
1 2 1
2 3 1
1 3 -2
4 5 2
5 6 3
4 6 4
1 1 -7

左边一块、右边一块,中间一条边都没有。答案:IMPOSSIBLE

⚠ 但请注意:Kruskal 在这张图上照样跑得欢 —— 它长出来的是一片 最小生成森林(每块各一棵,权值和 4)。不崩溃、不越界、数还挺像话。 第 9 步那个错误版本报的就是这个 4。

3 标准答案:把「生成树」的定义直接翻译成代码

brute.cpp标准答案:枚举所有边的子集
输入(stdin)
输出
点「运行 ▶」看结果
★ 为什么标准答案非要用一个和贪心毫无关系的思路

Kruskal 和 Prim 都是贪心,而且是同一条性质的两个推论。 拿它们互相验,只能验出「两处打字错误不一样」,验不出「那条性质本身是不是被我理解错了」。

所以这一份直接照着定义做:生成树 = 选 n−1 条边 + 所有点连通, 把 2^m 个子集全枚举一遍,合法的里面取最小。

第 9 章用 DP 验贪心、第 15 章用迭代加深验 BFS —— ★ 标准答案要用完全不同的思路写,同一个思路写两遍只能验出打字错误。

而「一个集合就是一个整数」也是第三次登场了:第 3 章拿它当枚举手段, 第 28 章升级成状态,这里又变回枚举手段 —— 只不过枚举的是

4 ★ 关键一步:切割性质

★★ 切割性质:横跨任意一个切割的最小边,一定在某棵最小生成树里

把 n 个点任意分成两半:S 和 V∖S(这就叫一个切割)。 一端在 S、另一端在 V∖S 的边,叫横跨这个切割的边。

横跨它的边里最小的那一条 e,一定属于某一棵最小生成树。

证明只有三句话,而且是第 19 章那个交换论证的原样重演:

  1. 随便拿一棵最小生成树 T。如果 e 已经在里面,收工。
  2. 如果不在:把 e 加进 T,n 个点 n 条边必定出现一个环。 这个环从 S 出去、又回到 S,所以环上至少还有另一条横跨切割的边 f。
  3. 而 e 是横跨的边里最小的,所以 w(e) ≤ w(f)。 把 f 换成 e,还是一棵生成树,权值和 原来 —— T 已经是最小的了, 所以新的这棵也是最小的,而它含 e。∎

请数一数这三句话里用到了什么:加进去会成环(图论)、环上必有第二条横跨边(数数)、 w(e) ≤ w(f)(e 是最小的)。

★★ 「边权非负」一次都没有出现。

★ 对照第 32 章:同样是贪心,那一个怕负权,这一个不怕

第 32 章证明 Dijkstra 时,反证的第三句是「后面那一段路的长度 ≥ 0,所以绕远只会更远」—— 「边长非负」恰好用在那一个不等号上,负权一来,那句话就断了,反例就长在那儿。

这一章的三句反证里没有那个位置可断。所以:

贪心证明里用到 w ≥ 0 吗负权
第 32 章 Dijkstra取最近的未定点,当场定死✓ 用在一个不等号上✗ 断
本章 Kruskal / Prim取横跨切割的最小边一次都没用到✓ 完全没事

★ 第 20 章那句「证明断在哪一步,反例就长在哪里」,这一章给出的是它的反面证明里压根没用到的条件,放开它也不会有反例。 所以这一章的对拍数据里必须有负权边 —— 它验的就是这句话。

⚠ 但要说准:不怕负权 ≠ 什么都不怕。负的自环照样拿不到(第 2 步那条 −7), 因为「树」那个限制还在。

★ 两个算法都是它的推论 —— 只是「选哪个切割」不一样
它每一步用的那个切割 S 是什么
Kruskal当前这条边左端所在的那个连通块。比它小的边都已经处理过了,所以它就是横跨的最小边
Prim已经长进树里的那堆点。每次取横跨它的最小边,切割性质直接就是算法本身

★ 所以这一章不是「两个算法」,是一条性质 + 两种挑切割的方式。 Kruskal 到处开花(一堆连通块慢慢并成一棵),Prim 只有一棵树、一直在长。

5 ★ 动画一:把那三句反证变成画面

每一步都当场对质:我选的是不是横跨切割的最小边
第 1 / 7 步
11-252345-79123456
绿实心 = 切割这一侧(树里的点)。橙虚线 = 横跨切割的边。绿粗 = 这一步选中的那条
★ 切割性质被推翻的次数
0一次都没有
横跨这个切割的边(按权值排)
(这一帧没有要对质的)
★ 切割性质:横跨任意一个切割的最小边,一定属于某棵最小生成树。 它的反证里,「边权非负」一次都没用到 —— 所以图 A 那条 −2 一点影响都没有。
把点分成两半:树里的(一开始只有 1 号)和树外的。★ 切割性质说:横跨这个分法的边里最小的那条,一定属于某棵最小生成树。所以每一步只要取那条最小的,就不会错。

每往树里加一条边,画面就把当时那个切割摆出来(绿实心 = 树里的点), 把横跨它的边全部列在右边、按权值排好,然后当场核对一句话: 我选的,是不是最小的那条?

右边那个计数器是这个动画的灵魂:★ 切割性质被推翻的次数

★★ 它在图 A 上是 0 —— 而图 A 里有一条 −2 的负权边

把写法切成「✗ 写成 Dijkstra(key[v] = key[u] + w)」,它立刻变成 1 次: 那一份比的不是「那条边本身」,而是「从 1 号一路走过来的总长」, 于是它选的边不是横跨切割里最小的那条。

⚠ 请把这个画面和第 32 章那个 DijkstraProof 摆在一起看 —— 两个动画的形状是一样的(每一步都暴力枚举、当场对质),结论正好相反:

正权图负权图
第 32 章 Dijkstra 的「定死」0 次被推翻第 2 步就被推翻
本章 Prim 的「取最小横跨边」0 次还是 0 次

这就是第 4 步那张表的画面版:差别不在代码里,在证明里。

6 正解一:Kruskal(顺带把并查集讲了)

kruskal.cpp正解:按权排序,能连就连
输入(stdin)
输出
点「运行 ▶」看结果

主循环只有两行,真正需要动脑的是那个并查集:它只回答一个问题 —— 「这两个点现在是不是已经连在一起了?」

★ 并查集:一片森林,fa[x] 是 x 的「爸爸」
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }   // 一路往上找祖宗,顺手压缩

bool unite(int a, int b) {
    a = find(a), b = find(b);
    if (a == b) return false;      // 本来就连通 —— 再连就成环
    fa[a] = b;                     // ⚠ 接的是两个**祖宗**
    return true;
}

路径压缩就是 fa[x] = find(fa[x]) 那半句:回来的路上,把这一路的点 全部直接挂到祖宗身上,下次一步到位。 (它为什么快到「几乎 O(1)」,第 36 章会算给你看 —— 这一章只用它。)

★ 用它之后,「自环」和「重边」根本不用特殊处理: 自环两端本来同族,find 一比就自己跳过了;重边里更小的那条先被收下, 更大的那条之后自然成环。第 29 章那两个必须小心的东西,在这里是白送的。

dsu.cpp逐条边看一遍:谁的祖宗是谁,收下还是跳过
输入(stdin)
输出
点「运行 ▶」看结果

这张表的最后两列是并排跑的两种写法。请看「一样」那一列 ——

⚠ 初学者最常写错的两处,都出在同一个误解上:fa[x] 是「爸爸」,不是「祖宗」
if (fa[u] != fa[v]) { ans += w; fa[u] = fa[v]; }
if (find(u) != find(v)) { ans += w; fa[find(u)] = find(v); }
  • 比较:两个点的爸爸不一样,完全可能爷爷是同一个 —— 于是它以为不连通,收下,成了环;
  • 合并fa[u] = fa[v] 只把 u 这一个点挂了过去,u 那一族的其他人原地不动。

图 A 上这两种写法在 3 条边上给出了不同的结论(表里那三行 ★ 不), 最后:正确写法收 5 条、权值和 9;不 find 那份收了 8 条、权值和 19

wrongFa.cpp✗ 不 find,直接比 fa
输入(stdin)
输出
点「运行 ▶」看结果

它输出 IMPOSSIBLE —— 而图 A 明明是连通的。

★ 因为它收了 8 条边,cnt != n-1 那一句就把它判成了「图不连通」。 一个 bug 同时污染两种输出(第 33 章那条的第三次)—— 看到 IMPOSSIBLE 千万别只盯着连通性去查,毛病在并查集里。

★ 第十条恒等式:把排序反过来,它精确地解了「最大生成树」
wrongSort.cpp✗ 排序反了(从大到小)
输入(stdin)
输出
点「运行 ▶」看结果

图 A 上它给 22(正解 9)。而它不是「随机地错」——

maxst.cpp用 Prim 写的最大生成树
输入(stdin)
输出
点「运行 ▶」看结果

也是 22。而且 300 组随机数据,一组不差(钉在 check:viz 里)。

排序反了 ≡ 最大生成树。 这是本教材第十条这样的恒等式 (前九条在第 23、24、25、26、27、28 章)。 ⚠ 验法照旧讲究:两份程序思路必须不同(一份 Kruskal、一份 Prim), 否则只是把同一个错抄了两遍。

顺带一句:最大生成树也是切割性质的推论 —— 把三句反证里的「最小」全换成「最大」, 一个字都不用改。贪心的方向可以反过来,性质的形状不变。

7 正解二:Prim —— 它和第 32 章那份代码只差一个字

prim.cpp朴素 Prim:从 1 号点开始,让树一点点长大
输入(stdin)
输出
点「运行 ▶」看结果
★★ 把它和第 32 章的朴素 Dijkstra 并排放:差别只有一截
// 第 32 章 naive.cpp(朴素 Dijkstra)
if (dist[u] + g[u][v] < dist[v]) dist[v] = dist[u] + g[u][v];

// 这一章 prim.cpp
if (          g[u][v] < key[v] ) key[v]  =           g[u][v];
//      ↑ 少了「dist[u] +」这一截

一句话解释这个差别:

那个数组记的是什么
Dijkstra 的 dist[v]从起点走到 v 有多远 —— 所以要一路累加
Prim 的 key[v]从树上够到 v 要花多少 —— 只看那一条边

★ 而这正好解释了第 4 步那张表:Dijkstra 要累加,所以它的贪心需要「路越走越长」; Prim 压根不累加,切割性质的证明里一次都没用到 w ≥ 0

第 33 章说「SPFA 就是第 32 章那份堆优化,只差用什么容器」; 这一章说的是另一半:★ 容器可以一模一样,差的是往 key 里放什么。

wrongDij.cpp✗ Prim 手滑写成了 Dijkstra
输入(stdin)
输出
点「运行 ▶」看结果

图 A 上它给 10(正解 9)。

★ 它给出的是一棵真的树,只是选错了那棵

它算出来的是最短路径树:从 1 号出发,每个点都用「最短路」连过来。 那是一棵完全合法的生成树 —— 只是通常不是最小的那棵。

「它给了一棵合法的树」和「它给了最小的那棵」是两件事。 第 32 章那句「它给了对的答案 ≠ 这个算法成立」的同一个形状。

⚠ 写这个错误版本时我特意补了一句 g[u][v] < INF 的守卫。不补的话, 有负权时 key[u] + INF 会比 INF 小,「够不着」也被刷成一个数 —— 那正是第 33 章 wrongInf.cpp 那个坑。★ 错误版本也要干净:一份只错一件事, 否则量出来的抓获率说不清是谁的功劳。

★ 堆优化:还是第 32 章那份 fast.cpp,还是只改那一个字
primHeap.cpp堆优化 Prim:O(m log n)
输入(stdin)
输出
点「运行 ▶」看结果
// 第 32 章 fast.cpp                        这一份
if (d + w < dist[v]) {                     if (!in[v] && w < key[v]) {
    dist[v] = d + w;                           key[v] = w;          // ★ 就是这一个字
    q.push({dist[v], v});                      q.push({key[v], v});
}                                          }

⚠ 判重那一句这里用 if (in[u]) continue; 而不是照抄 if (d > key[u]) continue; —— 两种写法在这道题上都对,但 in[] 说的正是「已经进树的点不能再进第二次」, 和朴素版里那个 in[] 是同一个东西。 (第 33 章那条:这个标记到底在记什么,比它叫什么重要一百倍。)

顺带一件可以量出来的事:Prim 从哪个点开始,答案都一样
primAny.cpp从每个点各长一遍
输入(stdin)
输出
点「运行 ▶」看结果

6 个起点,6 个 9。理由在证明里:切割性质对任何切割都成立, 起点只决定第一个 S 是 {谁},它从头到尾没进过任何一个不等号。

⚠ 对照第 32 章:那里「起点写死 1 号」是真 bug(300 轮抓 252)—— 因为最短路问的就是「从 s 出发」。 ★ 同一处「顺手写死 1 号」,一章里致命,另一章里毫无影响 —— 差别在于题目问的是什么。 第 11 步还会再撞见这句话一次。

8 ★ 动画二:两种算法并排长

一条一条地长:Kruskal 到处开花,Prim 只有一棵树
答案 9
第 1 / 12 步
11-252345-79123456
同色 = 同一个连通块(并查集里的同一族)。绿粗 = 已经选中的边,红 = 这条成环、跳过了
★ 已选中的边数
0/ 5
★ 成环跳过
0
当前权值和 0
已经选中的边
(还没有)
Kruskal:先把边按权值从小到大排好,一开始 6 个点各自成一块。

切「算法」那个下拉框,盯着颜色看:

  • Kruskal:一堆彩色小块到处开花,慢慢并成一块;
  • Prim:一块绿色一直在长,从头到尾只有一棵树。

两条路线完全不像,最后那个数字一模一样 —— 因为它们是同一条性质的两个推论。

右边两个计数器都参与 check:viz 的交叉验证: ★ 已选中的边数(图 A 上停在 5 = n−1)和 ★ 因为成环被跳过的边数(图 A 上是 5)。 把图切成 B,第一个计数器停在 4,再也上不去 —— 这就是 IMPOSSIBLE 的由来

9 另外两种把它写错的方式

wrongCount.cpp(喂给它图 B)✗ 忘了检查「选够 n−1 条没有」
输入(stdin)
输出
点「运行 ▶」看结果

它输出 4,而图 B 的正确答案是 IMPOSSIBLE。

★ 它错的不是算法,是题面:图不连通时 Kruskal 长出来的是一片最小生成森林, 那个 4 是森林的权值和。不崩溃、不报错、数还挺像话。 ⚠ 所以它是「不保证连通」这句话进了题面才存在的 bug —— 生成器要是「顺手保证图连通」(第 30 章那条),它就 0 / 300。第 11 步那张表第一行就是现场。

wrongNeg.cpp(喂给它图 A)✗ 以为负权边白拿,先全收了
输入(stdin)
输出
点「运行 ▶」看结果

它输出 2(正解 9)。

★ 「负的当然能拿就拿」——错在哪

错在把题目看成了「权值最小」四个字,漏掉了前半句:必须恰好是一棵树。 负权边照样会凑成环,凑成环就不能全要。

而图 A 上最刺眼的是那条 −7 的自环:它是全图最小的边, 可它一个新点都连不上 —— 那点白送的负权,你拿不到。

⚠ 这个 bug 对生成器提出了一个非常具体的要求:数据里得有负权边凑出来的环, 最好还有负的自环。全是正权的数据上它和正解一模一样(第 11 步那张表:档位 0/1 都是 0)。

10 ★ 兑现预告②:两棵树可能不一样,那对拍该比什么

plan.cpp把两种算法各自选中的边都打印出来
输入(stdin)
输出
点「运行 ▶」看结果

图 A 上,两棵树真的不一样

KRUSKAL:1—3(-2)  1—2(1)  4—5(2)  5—6(3)  2—4(5)     ← 用桥二
PRIM   :1—3(-2)  1—2(1)  3—4(5)  4—5(2)  5—6(3)     ← 用桥一

两条桥权值并列(都是 5),谁被选中只看「谁先轮到」。而两棵树的权值和都是 9

★ 第 31 章那两条出路,这一章两条都用上了

第 31 章(拓扑排序)是本教材第一次碰到「答案不唯一」,当时给了两条出路:

① 把答案钉唯一。 第 31 章是靠给题面加一句「输出字典序最小的那个」硬钉的; 这一章白送 —— 这道题天生就有一个唯一的东西:权值和。 所以题面只要那一个数,主对拍就能逐字节比。

★ 「答案不唯一」时的第一个动作,是先找找有没有一个天生唯一的量 —— 有的话,题面就该只要它。

② 写验证器。 不比答案,比性质。check:viz 拿到 plan.cpp 那两棵树,各查四件事:

查什么为什么
恰好 n−1 条边「树」的一半
每条边都真的在原图里不许凭空造边
连起来所有点连通「树」的另一半(n−1 条边 + 连通 ⇔ 无环)
权值和 = 第一行 = brute.cpp 枚举出来的最小值「最小」那一半

300 组数据、每组两棵树,全部通过。

第 31 章那个盲区在这里还在:验证器证明不了「答案存在时你没漏报」 —— 一份永远输出 IMPOSSIBLE 的程序能通过上面每一条检查。 所以「不连通」那一支必须靠主对拍单独对。 (第 20 章「对拍只能证伪」、第 31 章「验证器的盲区」、第 33 章「随机数据碰不到最坏情况」—— 这是随机对拍的第二个盲区在这一章的复现。)

★ 还有一个细节值得记:前四条里,前三条能自证清白,第四条不能。 「它是不是一棵合法的生成树」验证器自己就能查完; 「它是不是最小的那棵」只能靠那个数字,也就是靠 brute.cpp。 第 27、28 章那句「一份方案能自证清白,一个数字不能」,在这一章要反过来用一半

11 ★ 对拍与生成器:五个 bug,五样它们各自要的东西

对拍器
★ 这个生成器调了八次,其中**有一次是撤回**。它要同时造出:不连通的图、负权边(而且要能凑成环)、自环和重边 —— 而「不连通」这一支一放开就会把别的 bug 全挤没。

300 轮实测,五个错误版本:

故意写错的地方被抓第几轮
排序反了(≡ 最大生成树)191 / 300第 1 轮
并查集不 find,直接比 fa165 / 300第 3 轮
负权边先全收107 / 300第 1 轮
忘了判「选够 n−1 条没有」69 / 300第 3 轮
Prim 写成 Dijkstra67 / 300第 6 轮

(这 300 轮里:69 轮图不连通,2095 条边里 988 条是负的、201 条自环、306 条重边; 有解的那 231 轮里有 10 轮 Kruskal 和 Prim 长出了不同的树。五个数字都钉在 check:viz 里。)

★ 第一张表:五处改动,一次只改一处

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

档位改了什么图不连通排序反不 find忘了判够负边全收写成 Dijkstra
0(最初)保证连通 + 非负权 + 简单图 + 编号有序021020100120
1不再保证连通22448104224028
2边权可以是负数2245193224919
3允许自环和重边22460922243319
4打乱编号22460912243320
5边权值域拉开([-9,9] → [-20,20])224601042243424

档位 1 那一行是这一章最刺眼的地方,而且它是「双向」的: 「忘了判够」从 0 一下子变成 224(这一支终于有了), 可别的三个 bug 全被腰斩(210→48、201→104、120→28)—— 因为一旦图不连通,所有程序一律输出 IMPOSSIBLE,别的 bug 连出场机会都没有

⚠ 第 31、33 章那条「某一支占得太多,会把别人挤没」的第四次。 这次占到了 224 / 300(七成半),比第 31 章那次还狠。

⚠ 另外两笔老实账:

  • 档位 2 只把「负边全收」从 0 抬到 9。 光有负权边不够 —— 它要的是负边凑成的环。 档位 3 一放开自环和重边,才跳到 33(★ 大头是负的自环,第 29 章那条兑现)。 「加了个好东西」不等于「数据变好了」(第 33 章那条的第二次)。
  • 档位 4(打乱编号)五个数字几乎一个没动(60/91/224/33/20 对比档位 3 的 60/92/224/33/19)。 而第 27、31、32 章里同样一处改动是决定性的(0 / 300 → 两百多)。下一张表会说清为什么。
★★ 第二张表:一次调平衡 + 一次撤回 + 一个对照
档位改了什么图不连通排序反不 find忘了判够负边全收写成 Dijkstra两棵树不同
5(上一张表的最后一行)2246010422434240
6不连通的比例降到 1/36919116969109661
7(在用)把档位 5 那处改动撤回来69191165691076710
8(对照)和档位 7 一样,只是编号不打乱6919116869107593

档位 7 是这一章最值得说的一档:它是一次撤回。

档位 5 那处「把边权值域拉开」,在当时(八成的组都不连通)看着是有效的 (不 find 从 92 涨到 104)。可等档位 6 把不连通降下来之后再对照一量: 五个 bug 的抓获率几乎一个数都没变(191/169/69/109/66 → 191/165/69/107/67), 而它还有一个副作用 —— 值域一宽,并列的边权就少了, 「两棵最小生成树长得不一样」的组数从 10 掉到 1。 而「答案不唯一」正是这一章的第二个主题。于是这处改动被撤回了。

★ 第 32 章那条「调优不可加:一处改动值不值得留,取决于其它旋钮此刻在哪」的第二次, 而且这一次的结论是减法别把调生成器当成「把好点子一条条加上去」 —— 有的点子后来是要拿掉的。

档位 8 那个对照回答了上一张表留下的问题:为什么「打乱编号」在这一章这么弱?

那几章问的是什么编号打乱有没有用
第 27 章谁是★ 0 / 300 → 257 / 300
第 31 章什么顺序★ 0 / 300 → 265 / 300
第 32 章哪个点出发★ 0 / 300 → 252 / 300
本章一个和编号无关的权值和几乎没动(59 → 67,只有最弱那一支受益)

⚠ 所以「顺手写法会悄悄给数据加一条题目里没有的性质」这条规律,要补一句: ★ 它危不危险,取决于题目在问什么。 这和第 33 章那条「同一句代码危不危险,取决于数据的取值范围」是一对。

(它最后还是留下了:定档标准照旧是第 32 章那条 —— 让最弱的那一支尽量强, 档位 8 最弱的是 59,档位 7 是 67。而且退化数据防的是还没写出来的 bug, 第 27 章档位 3 那笔账的同款。)

gen.cpp(九个档位)八次改动全部可重跑,包括那次撤回

12 实测:暴力有多慢,三种正解怎么选

先看暴力。./genBig <n> 造的是稀疏图(m = 4n − 1):

g++ -O2 -std=c++17 -o genBig genBig.cpp && g++ -O2 -std=c++17 -o brute brute.cpp
./genBig 9 0 > big.txt && time ./brute < big.txt     # 55.86 秒
nm枚举边子集 O(2^m)Kruskal
4150.00 秒0.00 秒
5190.00 秒0.00 秒
6230.02 秒0.00 秒
7270.46 秒0.00 秒
8313.97 秒0.00 秒
93555.86 秒0.00 秒

★ 点数每多 1 个,边数多 4 条,耗时就 ×16 —— 底数在上,不在点上。 (第 30 章那条「指数级的底数往往藏在密度里,不在规模里」的第二次。 那一章是简单路径数,这一章是边子集数。)

同题对比:枚举所有边子集 vs Kruskal
7 → 27 条边(约 0.5 秒);8 → 31 条边(约 4 秒)。⚠ 9 就要 56 秒了,超过网页 15 秒上限,只能在终端跑
枚举所有边子集
Kruskal
★ 三种正解的选型:先算一遍账,再去量(第 29 章那条)
时间空间瓶颈在哪
KruskalO(m log m)O(m) 存边排序
朴素 PrimO(n²)O(n²) —— 邻接矩阵每轮扫一遍找最小
堆优化 PrimO(m log n)O(m)

⚠ 朴素 Prim 那个 O(n²) 空间是最容易被忽略的一栏,而它往往先出事 —— 邻接矩阵要 4(n+1)² 字节,这正是第 29 章那个公式

本机实测 · 稀疏图./genBig n 0m = 4n−1):

nm朴素 PrimKruskal堆优化 Prim
1 0003 9990.00 秒 / 7.9 MB0.00 秒 / 4.1 MB0.00 秒 / 4.2 MB
4 00015 9990.06 秒 / 66.6 MB0.00 秒 / 4.2 MB0.00 秒 / 4.7 MB
16 00063 9991.65 秒 / 1004.6 MB0.01 秒 / 4.7 MB0.01 秒 / 6.6 MB
64 000255 999开不出来0.04 秒 / 7.1 MB0.06 秒 / 14.2 MB
256 0001 023 9990.19 秒 / 16.8 MB0.43 秒 / 45.2 MB
1 000 0003 999 9990.81 秒 / 54.5 MB2.34 秒 / 165.4 MB

★ 第 29 章那个公式原样成立:4 × 16001² = 1.024 GB,实测 1004.6 MB。 到 n = 64 000 就要 16.4 GB —— 本机总共只有 8 GB,实测直接 std::bad_alloc稀疏大图上朴素 Prim 不是慢,是根本开不出来。

★ 所以选型表里那一栏「空间」不是走过场: 朴素 Prim 先出事的是空间,不是时间(n=16000 时它只要 1.65 秒,可已经吃掉 1 GB)。

⚠ 稀疏图上 Kruskal 比堆优化 Prim 快三倍 —— 这不是复杂度能解释的

O(m log m)O(m log n) 几乎是一回事,可实测是 0.81 vs 2.34 秒

原因和第 29、33 章那两次一模一样:缓存。 Kruskal 是「排一次序 + 在一个连续数组上顺序扫」, 堆优化 Prim 要维护堆、还要顺着邻接表跳来跳去

★ 第 33 章那句「Bellman-Ford 比 SPFA 还快,因为它在连续数组上顺序扫边」的同款。 口诀要拿实测复核,这是第四次。

本机实测 · 稠密图./genBig n 1,完全图 m = n(n−1)/2):

nm⚠ 只读入朴素 PrimKruskal堆优化 Prim
1 000499 5000.03 秒0.03 秒0.07 秒0.04 秒
2 0001 999 0000.15 秒0.16 秒0.33 秒0.17 秒
3 0004 498 5000.34 秒0.37 秒0.77 秒0.40 秒
★★ 这张表如果不量那一列「只读入」,会得出完全错误的结论

光看后三列,结论是「三者差不多」(0.37 / 0.77 / 0.40)。 可读入本身就吃掉了 0.34 秒 —— 减掉它之后:

减去读入之后(n = 3000)
朴素 Prim0.03 秒
堆优化 Prim0.06 秒
Kruskal0.43 秒

★ 算法部分差了十四倍,而不是「差不多」。 ./count io 这个开关就是为这件事加的(count.cpp)。

★ 第 29 章那条「量之前先确认「你量的就是它」」的第三次 (第一次是量内存时把去重的 set 也量进去了,第二次是第 32 章两份代码 I/O 设置不一致)。 这一次的教训更直白:稠密图的输入本身就是主要开销,不单独量出来, 你比的就不是算法,是 cin。

⚠ 顺带:这一次那句口诀(「稠密图该用朴素 Prim」)终于复现出来了 —— 第 29、32、33 章连着三次都没复现出各自那句口诀,这是第一次量到相符的。 口诀不是都错,是都得量。

count.cpp把三种写法的工作量数出来(附「只读入」开关)
输入(stdin)
输出
点「运行 ▶」看结果

为什么稠密图上朴素 Prim 反而占优,这个计数器一句话说清(./genBig 1000 1,1000 点、499 500 条边):

工作量
Kruskal排序 499 500 条边 —— 可只扫到第 4 392 条就选够了(0.9%)★ 剩下 99% 白排
堆优化 Prim入堆 7 138 次(边数的 1.4%)—— m log n 又一次是很松的上界(第 32 章那条)
朴素 Prim扫描 1 000 000 次(= n²)—— 和 m 一点关系都没有

★ 一句话:稠密图上 m 比 n² 还大,而朴素 Prim 压根不看 m。

13 三种写法怎么选

★ 关键的一步
时间空间什么时候用它
KruskalO(m log m)O(m)默认就用它 —— 代码最短、缓存友好,稀疏图上最快
堆优化 PrimO(m log n)O(m)和 Kruskal 半斤八两;题目已经建好邻接表时顺手
朴素 PrimO(n²)O(n²)只在稠密图(m 接近 n²)且 n 不大(几千)时用

★ 一句话:先看图稀不稀疏。 稀疏(m ≈ n)用 Kruskal; 稠密(m ≈ n²)且 n 只有几千,朴素 Prim 反而最快 —— 但先确认 4(n+1)² 的内存开得出来。

14 这一章可以带走的五样东西

★ 关键的一步

【1】★ 切割性质:横跨任意切割的最小边,一定在某棵最小生成树里。 三句反证(加进去成环 → 环上必有第二条横跨边 → 换掉它不会更差),就是第 19 章那个交换论证。 Kruskal 和 Prim 都是它的推论,只是选的切割不一样: Kruskal 用「这条边左端所在的连通块」,Prim 用「已经长进树里的那堆点」。

【2】★ 那三句话里「边权非负」一次都没出现 —— 所以这一章的贪心不怕负权。 对照第 32 章:Dijkstra 的反证里非负性恰好用在一个不等号上,负权一来就断。

★ 第 20 章「证明断在哪一步,反例就长在哪里」的反面证明里压根没用到的条件,放开它也不会有反例。 ⚠ 但「不怕负权」≠「什么都不怕」:负的自环照样拿不到,因为「树」那个限制还在。

【3】★ Prim 和 Dijkstra 只差一截「dist[u] +」。 dist[v] 记的是「从起点走到 v 多远」(要累加),key[v] 记的是「从树上够到 v 多少钱」(只看一条边)。 写混了就得到最短路径树 —— 一棵合法的、但通常不是最小的生成树。

「它给了一棵合法的树」和「它给了最小的那棵」是两件事。

【4】★ 答案不唯一时,先找有没有一个「天生唯一」的量。 最小生成树可能不止一棵,但权值和唯一 —— 于是题面只要那个数,主对拍就能逐字节比 (第 31 章是靠加一句「字典序最小」硬钉的,这一章白送)。 方案本身交给验证器(n−1 条边 + 都在原图里 + 连通 + 权值和对得上)。 ⚠ 验证器的盲区还在:它证明不了「答案存在时你没漏报」。

【5】★ 调生成器有时候要做减法。 这一章那个「拉开边权值域」的改动,加的时候有效、环境变了之后就没用了,还有害 (并列权值一少,「两棵树不同」从 10 掉到 1),最后被撤回

★ 第 32 章「调优不可加」的第二次,而这次结论是减法。 同一件事的另一面:「顺手写法」危不危险,取决于题目在问什么 —— 打乱编号在第 27、31、32 章是 0 → 两百多,在这一章几乎没动, 因为这一章问的是一个和编号无关的数。

下一章预告

第 35 章:栈与队列 → 单调栈、单调队列

★ 关键一步是均摊分析:每个元素进出各一次,所以那个「看起来有两层循环」的东西其实是 O(n) —— 接的是第 7 章双指针那一段。

⚠ 还有一笔欠了很久的账要还:第 24 章说过「多重背包还能做到 O(nW), 等第 35 章讲完单调队列再回来收尾」—— 写到那里必须回头把它补上。

15 自测

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