5 个岛都要能互相走,桥一座一座修——每次修最便宜的,但不能修出「绕圈」的桥。修满 n−1 座就停,总花费最少:这就是最小生成树(MST)。
海上有 5 个岛(图里的「点」),有些岛对之间能修桥(「边」),每座桥造价不同(边的「权」)。目标是:让所有岛都能互相到达,花的钱最少。
先想清楚要修几座:5 个岛,一座一座连——1 座连 2 个岛、2 座连 3 个岛、4 座连 5 个岛。规律:n 个岛最少要 n − 1 座桥。少一座,有岛「孤零零」连不上;多一座,必然出现绕圈(A→B→C→A),圈上的某座桥就是白花钱——去掉它,岛还是全通的。
所以最优方案一定长这样:恰好 n − 1 座桥、全岛连通、不绕圈——这叫生成树(n 个点 n−1 条边的连通图,形似一棵树,没有环);在所有生成树里总造价最小的,就是最小生成树。
怎么找到它?Kruskal 算法(克拉斯卡尔,1956 年):把所有桥按造价从便宜到贵排序,然后一条一条看:这座桥连的两个岛如果还不通(不同组)就修;如果已经通了(同组,再修就绕圈)就跳过。修满 n − 1 座就停。就这么简单——便宜优先,绝不绕圈!
4 个岛 A、B、C、D,6 座候选桥及造价。生成树 = 恰好 4 − 1 = 3 座桥、全岛连通、不绕圈。把 3 座桥的组合挨个看:
// 4 岛候选桥:AB=2 BC=3 CD=4 DA=5 AC=6 BD=7 // 生成树 = 3 座桥 + 连通 + 无环(多 1 座必成环,少 1 座必断连) // 挑几个组合算一算: AB + BC + CD = 2 + 3 + 4 = 9 // A-B-C-D 一条链 ✓ 树,最便宜 AB + BC + DA = 2 + 3 + 5 = 10 // B 当「枢纽」✓ 树 AB + CD + DA = 2 + 4 + 5 = 11 // A 当「枢纽」✓ 树 AB + AC + BD = 2 + 6 + 7 = 15 // D-B-A-C ✓ 树(贵) AB + BC + AC = 2 + 3 + 6 = 11 // ✗ A、B、C 绕了圈,D 还孤零零——不是树! // 最便宜的树:AB + BC + CD = 9 → 最小生成树 // 用 Kruskal 复核(排序后逐条加): // 排序:AB(2) BC(3) CD(4) DA(5) AC(6) BD(7) AB(2) → 不同组 ✓ 修 // {A,B} {C} {D} BC(3) → 不同组 ✓ 修 // {A,B,C} {D} CD(4) → 不同组 ✓ 修 // {A,B,C,D} → 3 座 = 4−1,完成 总造价 = 2 + 3 + 4 = 9 // 和枚举答案一致 ✓
5 个岛、7 座候选桥。Kruskal 三步:① 按造价排序;② 逐条处理(不同组 → 修 + 合并,同组 → 跳过);③ 修满 5 − 1 = 4 座就停。全程手推:
// 候选桥:1-2:2 2-3:3 3-4:4 4-5:5 1-3:6 2-4:7 1-5:8 // 第 1 步:按造价排序(便宜 → 贵) 1-2(2) 2-3(3) 3-4(4) 4-5(5) 1-3(6) 2-4(7) 1-5(8) // 第 2 步:逐条处理(「分组」= 现在哪些岛已经互相通了) 1-2(2): 1、2 不同组 → 修!合并 → {1,2} {3} {4} {5} 2-3(3): 2、3 不同组 → 修!合并 → {1,2,3} {4} {5} 3-4(4): 3、4 不同组 → 修!合并 → {1,2,3,4} {5} 4-5(5): 4、5 不同组 → 修!合并 → {1,2,3,4,5} ── 已修 4 座 = 5 − 1,全岛同组 ── // 第 3 步:修满 n − 1 → 停!后面的桥都不用看了 1-3(6): (若继续看)1、3 已同组 → 跳过(1→2→3 通了,再加 1-3 成三角圈) 2-4(7): 已同组 → 跳过 1-5(8): 已同组 → 跳过 // 结果:修 1-2、2-3、3-4、4-5,总造价 = 2 + 3 + 4 + 5 = 14 // 自检:5 个点 4 条边、连通、无环 ✓ 是生成树; // 总造价 14 是所有生成树里最小的(正确性见子任务 4)。
怎么知道「修这座桥会不会绕圈」?看两个岛现在通不通:已经通了(同组)→ 再修必绕圈;还没通(不同组)→ 放心修。用一张同组表记:group[i] = 岛 i 所在组的编号(用组长当编号):
// 初始:每个岛自己一组 group = [1, 2, 3, 4, 5] // 1-2(2):group[1]=1 ≠ group[2]=2 → 不同组 → 修! // 合并(2 的组并入 1 的组,统一记组长 1): group = [1, 1, 3, 4, 5] // 2-3(3):group[2]=1 ≠ group[3]=3 → 不同组 → 修! // 合并:group[3] 改成 1 group = [1, 1, 1, 4, 5] // 3-4(4):group[3]=1 ≠ group[4]=4 → 不同组 → 修! group = [1, 1, 1, 1, 5] // 4-5(5):group[4]=1 ≠ group[5]=5 → 不同组 → 修! group = [1, 1, 1, 1, 1] // 全岛同组 → 修满 4 座,完成 // 1-3(6):group[1]=1 = group[3]=1 → 同组 → 跳过(会绕圈) // 2-4(7)、1-5(8) 同理:同组 → 跳过 // 口诀(并查集 Union-Find 的白话版): // Find:查 group[u] 和 group[v] 等不等 // 相等 → 同组 → 跳过 // 不等 → 修桥 → Union:把两组合并(全改成同一组长)
Kruskal 是「贪心」:每次只挑眼前最便宜且不成环的桥。敢贪心,是因为有个换桥论证(用大白话说):
// 换桥论证(为什么「最便宜且不成环的桥 e 一定该修」): // e 是队列里最便宜、且在 Kruskal 表里「两端不同组」的桥: // 它的两端 u、v 在表里分属两个组(还没通)。 // 第 1 步:假设某个最优方案 S 里没有 e。把 e 加进 S: // S 是树(n 点全通、无环),加一条边必多出恰好一个圈 // (S 里连接 u、v 的旧路 + e)。 // 第 2 步:这个圈上必有一座桥 f「跨越两个组的边界」: // u 在 A 组、v 在 B 组,旧路必然要从 A 组走到 B 组, // 「跨界」的那座桥就是 f。 // 第 3 步:f 的造价 ≥ e 的造价。反证:若 f 比 e 便宜, // 排序时 f 排在 e 前面,Kruskal 早处理 f 时 // f 两端也是不同组(组只会越并越大)→ 早就修了 f, // 现在 f 两端已同组,f 就不可能「跨界」——矛盾! // 第 4 步:从 S 里拆掉 f、换上 e → 边数没变、不断连、圈消掉, // 还是生成树,总造价没变贵(e ≤ f)。 // 结论:任何最优方案都能「换」成含 e 的一样优的方案 // → 每步贪心都不亏 → Kruskal 的结果一定是最优的 ✓ // 复杂度(m 条边): // ① 排序:O(m log m) ← 最慢的一步(大头) // ② 逐条看:每条边查 1 次组 + 至多合并 1 次 → O(m) // 总计 ≈ O(m log m)(m = 200 时,排序约 200×7.6 ≈ 1500 步) // 对比暴力法(枚举所有生成树): // 本课 7 条边选 4 条:C(7,4) = 35 种组合,还要逐个查连通—— // m = 200 时组合数是天文数字;Kruskal 只需排一次序 + 扫一遍。
// 输入:无(5 岛 7 桥直接写死在程序里,方便对答案) // 输出:每座「修的桥 / 跳过的桥」+ 总造价 // 答案:修 1-2、2-3、3-4、4-5,总造价 = 14 #include <iostream> #include <algorithm> using namespace std; struct Edge { int u, v, w; }; // 一座桥:两端 u、v,造价 w Edge e[7]; int grp[5]; // 同组表:grp[i] = 岛 i+1 的组号 bool byW(const Edge &a, const Edge &b) { return a.w < b.w; } int findGroup(int x) { return grp[x - 1]; } void unionGroup(int a, int b) { int ga = grp[a - 1], gb = grp[b - 1]; for (int i = 0; i < 5; i++) if (grp[i] == gb) grp[i] = ga; // b 的组并入 a 的组 } int main() { // 候选桥:1-2:2 2-3:3 3-4:4 4-5:5 1-3:6 2-4:7 1-5:8 e[0] = {1, 2, 2}; e[1] = {2, 3, 3}; e[2] = {3, 4, 4}; e[3] = {4, 5, 5}; e[4] = {1, 3, 6}; e[5] = {2, 4, 7}; e[6] = {1, 5, 8}; for (int i = 0; i < 5; i++) grp[i] = i + 1; // 初始:各岛自己一组 sort(e, e + 7, byW); // 第 1 步:按造价排序(便宜 → 贵) int cnt = 0, total = 0; for (int i = 0; i < 7 && cnt < 4; i++) { // 修满 5 − 1 = 4 座就停 if (findGroup(e[i].u) != findGroup(e[i].v)) { cout << "修桥 " << e[i].u << "-" << e[i].v << "(造价 " << e[i].w << ")" << endl; unionGroup(e[i].u, e[i].v); // 修桥 → 立刻合并 cnt++; total += e[i].w; } else { cout << "跳过 " << e[i].u << "-" << e[i].v << "(已同组,会绕圈)" << endl; } } cout << "总造价 = " << total << endl; return 0; } // 运行结果:修 1-2(2) 2-3(3) 3-4(4) 4-5(5) → 总造价 = 14 // 把 e[3] 改成 {4,5,9} 再跑:修 1-2、2-3、3-4、1-5 → 总造价 = 17
sort 不用操心这个。