算法讲解061 必备 最小生成树

来源:算法讲解061【必备】最小生成树 · 2026-10-26
依据:视频字幕 2000 字

一句话概括

最小生成树(Minimum Spanning Tree, MST)是在连通无向带权图中选出 N−1 条边使所有点连通且总权值最小的树;Kruskal 算法按边权从小到大加边、用并查集判环,Prim 算法从一点出发用小根堆不断扩展。

它解决什么问题

给一张连通的无向带权图(N 个点、M 条边),要求选出一个边集:

输出通常只要这个最小总权值;有时也要求输出选了哪些边。典型场景:修路/铺网线/建电网,要求花最少钱把所有城市连起来。

基础知识(前置概念,用大白话解释)

原理(为什么这么做是对的,尽量给直觉)

Kruskal 的直觉(贪心 + 割性质):把边按权值从小到大看。当前这条边 (u,v,w),如果 u、v 已经连通,加它必然成环,扔掉;如果不连通,就加它。

为什么加它不会亏?设当前 u 在一个连通块 A 里、v 在另一个连通块 B 里。任何一棵生成树都必须至少有一条边跨过 A 和 B 之间(否则 A、B 不连通)。而 w 是所有还没考虑的边里最小的,也就是所有跨 A、B 的边里最小的(更小的边要么已经加过、要么会成环被扔,都不跨 A、B)。所以一定存在一棵 MST 包含这条边——这就是「割性质(cut property)」。每次加边都保持「存在一棵 MST 包含已选边集」这个不变式,加满 N−1 条就得到 MST。

Prim 的直觉:从任意点出发,维护「已选点集 S」和「S 到外部的最小边」。每次取这条最小边把外部点拉进 S。同样由割性质保证正确:S 与外部之间的最小边一定属于某棵 MST。

两者关系:都是贪心 + 割性质,只是「扩展方式」不同——Kruskal 按全局边权扩展(需要并查集判环),Prim 按点集边界扩展(需要堆取最小边)。

步骤(编号,可照着写)

Kruskal

  1. 读入 N、M 和所有边 (u, v, w),存进数组(不用建邻接表)。
  2. 把所有边按 w 升序排序。
  3. 初始化并查集,每个点自成一个集合;计数器 cnt = 0,答案 ans = 0。
  4. 依次遍历排序后的边:

- 若 find(u) != find(v):union(u,v),ans += w,cnt++;

- 若 cnt == N-1:提前结束。

  1. 若结束时 cnt < N-1,说明图不连通,无 MST;否则 ans 就是最小总权值。

Prim(堆优化)

  1. 建邻接表 vector<vector<pair<int,int>>> g(N+1),存 (邻点, 边权)。
  2. dist[] 全部初始化为 INF,vis[] 全 false;dist[1] = 0。
  3. 小根堆放 (0, 1)((距离, 点))。
  4. 循环直到堆空或已选 N 个点:

- 弹出 (d, u);若 vis[u] 跳过(懒删除);

- vis[u] = true,ans += d,cnt++;

- 遍历 u 的邻边 (v, w):若 !vis[v] && w < dist[v],更新 dist[v] = w,push (w, v)。

  1. cnt == N 则有 MST,否则不连通。

样例(一个具体小例子,带每步变化)

N = 5,边(按权值已排好序):

(1,2,1)  (3,4,1)  (2,3,3)  (1,3,4)  (2,4,6)  (4,5,7)

Kruskal 过程:

| 步 | 边 | 两端是否已连通 | 动作 | 已选边数 | ans |

|---|---|---|---|---|---|

| 1 | (1,2,1) | 否 | 加 | 1 | 1 |

| 2 | (3,4,1) | 否 | 加 | 2 | 2 |

| 3 | (2,3,3) | 否({1,2} 与 {3,4}) | 加 | 3 | 5 |

| 4 | (1,3,4) | 是(1、3 已同块) | 跳过 | 3 | 5 |

| 5 | (2,4,6) | 是 | 跳过 | 3 | 5 |

| 6 | (4,5,7) | 否 | 加 | 4 = N−1 | 12 |

结束,MST 总权值 = 12,选边 {(1,2),(3,4),(2,3),(4,5)}。

注意:若把 (2,3,3) 换成 (1,3,3),同样得到权值 12 的另一棵 MST——MST 不唯一,但最小总权值唯一。

代码

Python(好读,用于理解)

import sys

def solve():
    input = sys.stdin.readline
    n, m = map(int, input().split())
    edges = []
    for _ in range(m):
        u, v, w = map(int, input().split())
        edges.append((w, u, v))
    edges.sort()                       # 按权值升序

    parent = list(range(n + 1))
    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]   # 路径压缩
            x = parent[x]
        return x

    ans, cnt = 0, 0
    for w, u, v in edges:
        ru, rv = find(u), find(v)
        if ru != rv:
            parent[ru] = rv
            ans += w
            cnt += 1
            if cnt == n - 1:
                break
    print(ans if cnt == n - 1 else "orz")   # 不连通时按题目要求输出

solve()

C++(竞赛风,可直接抄)

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> fa, sz;
    DSU(int n) : fa(n + 1), sz(n + 1, 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;
        if (sz[a] < sz[b]) swap(a, b);
        fa[b] = a; sz[a] += sz[b];
        return true;
    }
};

struct Edge { int u, v; long long w; };

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    vector<Edge> edges(m);
    for (auto &e : edges) cin >> e.u >> e.v >> e.w;

    sort(edges.begin(), edges.end(),
         [](const Edge &a, const Edge &b) { return a.w < b.w; });

    DSU dsu(n);
    long long ans = 0;
    int cnt = 0;
    for (auto &e : edges) {
        if (dsu.unite(e.u, e.v)) {
            ans += e.w;
            if (++cnt == n - 1) break;
        }
    }
    if (cnt == n - 1) cout << ans << '\n';
    else cout << "orz\n";   // 图不连通,按题目要求输出
    return 0;
}

复杂度(时间/空间,最好/平均/最坏,顺带说常数大不大)

Kruskal

Prim(堆优化)

竞赛里什么时候用(看到什么题面特征就该想到它)

竞赛模板(C++,可直接背下来的写法;变量名清楚)

// Kruskal + 并查集,适用于 N ≤ 2e6, M ≤ 2e6
struct DSU {
    vector<int> fa, sz;
    DSU(int n) : fa(n + 1), sz(n + 1, 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;
        if (sz[a] < sz[b]) swap(a, b);
        fa[b] = a; sz[a] += sz[b];
        return true;
    }
};

struct Edge { int u, v; long long w; };

long long kruskal(int n, vector<Edge> &edges) {   // 返回 -1 表示不连通
    sort(edges.begin(), edges.end(),
         [](const Edge &a, const Edge &b) { return a.w < b.w; });
    DSU dsu(n);
    long long ans = 0;
    int cnt = 0;
    for (auto &e : edges) {
        if (dsu.unite(e.u, e.v)) {
            ans += e.w;
            if (++cnt == n - 1) return ans;
        }
    }
    return -1;
}
// Prim 堆优化,适用于稠密图 / 需要邻接表的场景
long long prim(int n, vector<vector<pair<int,long long>>> &g) {  // 1-indexed
    const long long INF = LLONG_MAX / 2;
    vector<long long> dist(n + 1, INF);
    vector<char> vis(n + 1, 0);
    priority_queue<pair<long long,int>, vector<pair<long long,int>>,
                   greater<pair<long long,int>>> pq;
    dist[1] = 0; pq.push({0, 1});
    long long ans = 0; int cnt = 0;
    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();
        if (vis[u]) continue;
        vis[u] = 1; ans += d; cnt++;
        for (auto [v, w] : g[u])
            if (!vis[v] && w < dist[v]) { dist[v] = w; pq.push({w, v}); }
    }
    return cnt == n ? ans : -1;
}

边界与易错点(WA 的常见原因,越具体越好)

  1. 图不连通:Kruskal 结束时 cnt < N-1,不能直接输出 ans,要按题目要求输出 orz / -1 / impossible。多测时尤其容易忘。
  2. 多测清空:edges、并查集、vis、dist 每轮都要重置。用局部变量 + 重新构造最省心。
  3. long long:边权总和可能爆 int(M 条边 × 每条 1e9 ≈ 1e15)。ans 用 long long,边权也建议 long long。
  4. 下标从 0 还是 1:并查集 fa 大小开 n+1 且 iota 从 0 开始,1-indexed 输入直接用;0-indexed 输入要保证 fa 大小 ≥ n。
  5. 重边/自环:自环 u == v 在 Kruskal 里 find 相等自动跳过,没问题;重边排序后正常处理,不影响正确性。
  6. N = 1:MST 是空集,权值 0。Kruskal 里 cnt == 0 == N-1 直接返回 0,注意别写成 cnt == N-1 之前就 break 出错。
  7. 排序比较器:用 <= 当比较器会 UB(严格弱序要求),必须用 <。
  8. Prim 的懒删除:弹出时 vis[u] 为真必须 continue,否则重复加点。
  9. Prim 的 dist 初值:INF 别用 INT_MAX,加法会溢出;用 LLONG_MAX/2 或 0x3f3f3f3f3f3f3f3f。
  10. 提前 break 的条件:cnt == N-1 要在加边之后判断,别在加边前判断。

同类题 / 变形(说清楚怎么改)

常见坑

拓展(变体、实际用途、面试常问)

自测 3 题(题目 + 折叠答案)

题 1:N=4,边 (1,2,1)、(2,3,2)、(3,4,3)、(1,4,10)、(1,3,5)。求 MST 总权值。

答案

排序后:(1,2,1)、(2,3,2)、(3,4,3)、(1,3,5)、(1,4,10)。

答案 6。选边 {(1,2),(2,3),(3,4)}。

</details>

题 2:上题若把 (3,4,3) 改成 (3,4,100),MST 总权值是多少?图连通吗?

答案

排序:(1,2,1)、(2,3,2)、(1,3,5)、(3,4,100)、(1,4,10)。

答案 103。图仍连通(4 通过 3 连出去),只是边权很大。

</details>

题 3:N=3,边只有 (1,2,5)。求 MST。

答案

Kruskal 跑完只加了 1 条边,cnt=1 < N-1=2,图不连通,不存在 MST。按题目要求输出 orz / -1 / impossible。这是多测和边界最容易 WA 的地方。

</details>