④ 数据结构与算法 — 第 13 课
给一张无向连通图,每条边有个权(修路造价)。要选出若干条边,使得:
满足第一条时,最少需要 n-1 条边(少一条就至少有两点断开;多一条就会形成环)。这 n-1 条边构成的图形就叫生成树。在所有生成树里,总权最小的那个就是最小生成树(MST, Minimum Spanning Tree)。
思路只有三步:
n-1 条就停,加起来就是答案。为什么「不连通就选」是对的?直观解释:每次选的都是「当前能把两块连起来的最便宜的边」。如果不用它,就必须用一条更贵(或同样贵)的边来代替,所以用了不会更差。这条论述的正式版本叫割性质,下面第 3 节会写。
样例就是这个图(4 个点、5 条边):
4 5
1 2 2
1 3 2
1 4 3
2 3 4
3 4 3
按权排序后依次处理:
| 顺序 | 边 | 权 | 两端连通吗 | 动作 | 累计 |
|---|---|---|---|---|---|
| 1 | 1–2 | 2 | 不连通 | 选它,{1,2} | 2 |
| 2 | 1–3 | 2 | 不连通 | 选它,{1,2,3} | 4 |
| 3 | 1–4 | 3 | 不连通 | 选它,{1,2,3,4} | 7 |
| 已经选满 n−1 = 3 条边,停止 | 7 | ||||
注意第 4、5 条边(3–4 权 3、2–3 权 4)根本没看 —— 因为已经凑够 3 条边了。
#include <bits/stdc++.h>
using namespace std;
struct DSU { // 0410 那套并查集
vector<int> fa;
DSU(int n) : fa(n + 1) { iota(fa.begin(), fa.end(), 0); }
int find(int x) {
while (fa[x] != x) { fa[x] = fa[fa[x]]; x = fa[x]; }
return x;
}
bool unite(int a, int b) {
a = find(a); b = find(b);
if (a == b) return false; // 已经同根 → 这条边会成环
fa[a] = b;
return true;
}
};
struct Edge { int u, v, w; };
int main() {
ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; cin >> n >> m;
vector<Edge> es(m);
for (auto& e : es) cin >> e.u >> e.v >> e.w;
sort(es.begin(), es.end(), [](const Edge& a, const Edge& b) { return a.w < b.w; });
DSU d(n);
long long sum = 0; int used = 0;
for (auto& e : es) {
if (d.unite(e.u, e.v)) {
sum += e.w;
if (++used == n - 1) break; // 选满就走
}
}
if (used == n - 1) cout << sum << '\n';
else cout << "orz\n"; // 不连通
}
复杂度:排序 O(m log m) + 并查集 O(m α(n)) → O(m log m),瓶颈在排序。
只讲一句核心,用反证:
割性质:把点任意分成两堆(A 和 B),横跨这两堆的所有边里最便宜的那条,一定属于某棵最小生成树。
证明:假设最便宜的那条边 e 不在某棵 MST 里。把 e 加进去,树上就出现一个环;这个环必然还有另一条边 f 也横跨 A、B(要成环就得从 A 出去再回 A)。把 f 换成 e,总权不会变大(因为 e 是最便宜的),所以仍然是最小生成树。
Kruskal 每次选边时,「已选点集」和「未选点集」就构成一个割,而它挑的正是这个割上当前最便宜的边 —— 所以每一步都安全。
Prim 用同一个道理:它的割是「已选集合」和「其余」,每次把跨这个割最便宜的边拉进来,所以也对。两个算法共享同一个正确性来源,只是组织方式不同。
把 0411 的 Dijkstra 拿来,改两个地方:
| Dijkstra(0411) | Prim(本节) | |
|---|---|---|
dis[v] 表示 | 起点到 v 的最短距离 | 已选集合到 v 的最短边权 |
什么时候更新 dis[v] | dis[u] + w < dis[v] | w < dis[v](不加 dis[u]) |
| 答案怎么算 | 输出每个点的 dis | 把每次拉进来的点的 dis 累加 |
| 能不能有负权 | 不能 | 能 |
就这一处差别(dis[u] + w 变成 w),整个算法的行为就从「求最短路径」变成了「求最小生成树」。
long long prim(int n, const vector<vector<pair<int,int>>>& g) {
vector<int> dis(n + 1, INT_MAX);
vector<bool> used(n + 1, false);
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;
dis[1] = 0; pq.push({0, 1});
long long sum = 0; int cnt = 0;
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (used[u]) continue; // 懒删除,同 0411
used[u] = true;
sum += d; cnt++; // 这条边就是把它连进来的那条
for (auto [v, w] : g[u])
if (!used[v] && w < dis[v]) { dis[v] = w; pq.push({w, v}); }
}
return cnt == n ? sum : -1; // -1 = 不连通
}
dis[v] = w 而不是 dis[v] = d + w。d + w 就变成 Dijkstra 了,样例上可能还能过(因为样例里恰好一样),但换个图立刻就错。
如果图很稠密(边数接近 n²),堆里的元素会非常多,这时朴素版 O(n²) 反而更快:
long long prim_dense(int n, const vector<vector<int>>& w) {
vector<int> dis(n + 1, INT_MAX);
vector<bool> used(n + 1, false);
dis[1] = 0;
long long sum = 0;
for (int it = 0; it < n; it++) {
int u = -1;
for (int i = 1; i <= n; i++)
if (!used[i] && dis[i] != INT_MAX && (u == -1 || dis[i] < dis[u])) u = i;
if (u == -1) return -1;
used[u] = true; sum += dis[u];
for (int v = 1; v <= n; v++)
if (!used[v] && w[u][v] < dis[v]) dis[v] = w[u][v];
}
return sum;
}
结构和 0411 的朴素 Dijkstra 一模一样,只把 dis[u] + w[u][v] 换成 w[u][v]。
下面都是本机实跑(g++ 16.2.0 -O2 -std=c++17),两边答案一致才算数。
| 图 | Kruskal | Prim(堆) | Prim(O(n²)) | MST 总权 |
|---|---|---|---|---|
| 稀疏:n = 5000、m = 200 000(P3366 上限) | 10.5 ms | 4.1 ms | — | 762963 |
| 稠密:n = 500 完全图(m = 124 750) | 7.0 ms | 0.7 ms | 0.7 ms | 13633 |
结论:稀疏图 Prim(堆)略快,稠密图两种 Prim 都明显快。但差距在毫秒级,P3366 的时限下两个都能过(Kruskal 十几毫秒,离 1 秒很远)。所以选哪个更多看「写起来顺不顺手」:
N ≤ 5000、M ≤ 2×105、1 ≤ Z ≤ 104,无向边。如果图不连通,输出 orz(三个小写字母,不是 Impossible)—— 这是本题唯一的额外要求,也是最多人踩的坑。
完整的 Kruskal 解法就是上面第 2 节那份代码(它本来就是按 P3366 写的)。实测结果:
| 输入 | 输出 | 说明 |
|---|---|---|
| 样例(4 个点、5 条边) | 7 | 2 + 2 + 3 |
4 2 / 1 2 5 / 3 4 4 | orz | 两个连通块,不连通 |
1 0 | 0 | n = 1 时不需要任何边,答案是 0 |
| 满规模 n = 5000、m = 200 000 | 745493 | 耗时 94.1 ms(含读入 20 万行) |
另外我用 200 组随机小图(n ≤ 7、m ≤ 10,含重边和自环)与一份「枚举所有 n−1 条边组合」的暴力对拍:Kruskal 和 Prim 都与暴力一致,0 组不一致。
orz 还是数值?大部分 WA 是「图不连通但没判」(used != n-1 时要输出 orz);u == v)处理了吗?并查集里 find(u) == find(v) 会直接返回 false,天然跳过 —— 但如果你自己写「是否成环」的判断,容易漏;M ≤ 2×10^5,无向边要存一条(不是两条),Kruskal 只需要边表;n-1 条边、每条最多 10^4 → 最大约 5×10^7,int 够,但养成 long long 的习惯;dis[v] = w 还是 dis[v] = d + w。
orz:Kruskal 看「选了几条边」,Prim 看「进了几个点」,都要判n-1 = 0 条边)dis 不是到起点的距离:是到「已选集合」的最短边权,写错就变 Dijkstrasum += d 要在 used[u] = true 时加:加的是「把它连进来的那条边」,不是它所有出边dis[v] 表示什么?orz。建议先用 Kruskal 交一次(代码你已经有骨架了:0410 的并查集 + 0407 的排序),再用 Prim 交一次,两边的答案应该完全一样。n-1 条边连通所有点;最小生成树 = 总权最小的那个O(m log m),稀疏图首选,代码最短dis[v] = w(不是 dis[u] + w)orzsum += d 为什么在那一刻加 —— 直接问我,我从零再讲一遍。