📖 第 8.1 章 ⏱️ 约 50 分钟 🎯 Gold

第 8.1 章:最小生成树

📝 前置要求: 本章需要第 5.1~5.3 章(图、BFS/DFS、并查集/DSU)的知识。阅读 Kruskal 算法前,必须理解 DSU 的 findunion 操作。

带权无向图的**最小生成树(MST)**是满足以下条件的边的子集:

  1. 连通所有 N 个顶点(生成)
  2. 不包含环(树)
  3. 边权总和尽可能小(最小)

USACO Gold 中的 MST 题目常以各种形式出现:如"以最低成本建立网络"、"求连通所有节点的最小代价",或需要考虑哪些边是必须保留的。

学习目标:

  • 理解什么是生成树,以及 MST 的用途
  • 用 DSU 实现 Kruskal 算法,时间复杂度 O(E log E)
  • 用优先队列实现 Prim 算法,时间复杂度 O(E log V)
  • 识别 USACO 中的 MST 问题,并运用割边性质与环性质

8.1.0 什么是生成树?

对于一个有 N 个顶点和 E 条边的连通图,生成树是满足以下条件的边的子集:

  • 连通所有 N 个顶点
  • 恰好使用 N−1 条边
  • 不包含环

一个图可以有许多生成树。最小生成树是边权之和最小的那棵。

图:                    某棵生成树:               MST:
  1                         1                          1
 / \                       / \                        / \
2   3   边权:             2   3                      2   3
|\ /|   1-2: 4             |                          |
| X |   1-3: 2             4                          4
|/ \|   2-3: 5       总权=4+2+3=9              总权=4+2+1=7 ← 最小
4   5   2-4: 3
        3-4: 1
        4-5: 6

💡 为什么恰好是 N−1 条边? N 个节点的树恰好有 N−1 条边。少了则不连通;多了则有环。


8.1.1 割边性质与环性质

这两个基本性质是 MST 算法正确性的理论基础:

割边性质(Cut Property): 对图的任意划分(将顶点分为两组 S 和 V−S),连接两组的最小权重边一定属于某棵 MST。

环性质(Cycle Property): 对图中任意一个环,环上最大权重的边一定不属于任何 MST(权重有并列时除外)。

这两个性质同时证明了 Kruskal 算法和 Prim 算法的正确性。


8.1.2 Kruskal 算法

核心思想: 将所有边按权重排序,贪心地加入不构成环的最便宜边。用 DSU 以 O(α(N)) ≈ O(1) 的时间检测是否成环。

算法流程:

  1. 将所有边按权重从小到大排序
  2. 用 N 个分量初始化 DSU(每个顶点自成一组)
  3. 对每条边 (u, v, w)(按排序顺序):
    • find(u) ≠ find(v):将此边加入 MST,调用 union(u, v)
    • 否则:跳过(会构成环)
  4. 当 MST 包含 N−1 条边时停止
📄 4. 当 MST 包含 N−1 条边时停止
#include <bits/stdc++.h>
using namespace std;

// ── 并查集(DSU)──────────────────────────────────
struct DSU {
    vector<int> parent, rank_;
    int components;

    DSU(int n) : parent(n), rank_(n, 0), components(n) {
        iota(parent.begin(), parent.end(), 0); // parent[i] = i
    }

    int find(int x) {
        if (parent[x] != x)
            parent[x] = find(parent[x]);  // 路径压缩
        return parent[x];
    }

    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;           // 已在同一分量中
        if (rank_[x] < rank_[y]) swap(x, y);
        parent[y] = x;                      // 将秩小的挂到秩大的下面
        if (rank_[x] == rank_[y]) rank_[x]++;
        components--;
        return true;
    }

    bool connected(int x, int y) { return find(x) == find(y); }
};

// ── Kruskal MST ────────────────────────────────────
int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;  // n 个顶点,m 条边

    // edges[i] = {权重, u, v}
    vector<tuple<int,int,int>> edges(m);
    for (auto& [w, u, v] : edges) {
        cin >> u >> v >> w;
        u--; v--;  // 0-indexed
    }

    sort(edges.begin(), edges.end());  // 按权重排序(第一个元素)

    DSU dsu(n);
    long long mst_weight = 0;
    int edges_added = 0;
    vector<pair<int,int>> mst_edges;

    for (auto& [w, u, v] : edges) {
        if (dsu.unite(u, v)) {      // 仅在不构成环时加边
            mst_weight += w;
            mst_edges.push_back({u, v});
            edges_added++;
            if (edges_added == n - 1) break;  // MST 构建完成
        }
    }

    if (edges_added < n - 1) {
        cout << "图不连通——MST 不存在\n";
    } else {
        cout << "MST 权重:" << mst_weight << "\n";
    }

    return 0;
}

复杂度: 排序 O(E log E) + DSU 操作 O(E · α(N)) ≈ O(E log E)

Kruskal 算法追踪示例

顶点:4 个(0, 1, 2, 3)
边(已排序):(0,1,1)、(1,2,2)、(2,3,3)、(0,2,5)、(1,3,6)

第 1 步:边 (0,1,w=1) → find(0)=0 ≠ find(1)=1 → 加入  DSU: {0,1},{2},{3}
第 2 步:边 (1,2,w=2) → find(1)=0 ≠ find(2)=2 → 加入  DSU: {0,1,2},{3}
第 3 步:边 (2,3,w=3) → find(2)=0 ≠ find(3)=3 → 加入  DSU: {0,1,2,3}
        edges_added = 3 = n-1 → 完成

MST 权重 = 1 + 2 + 3 = 6

8.1.3 Prim 算法

核心思想: 从一个起始顶点出发逐步扩张 MST。每一步,加入将 MST 内某顶点与 MST 外某顶点相连的最小权重边。用小根堆(优先队列)高效选取最便宜的边。

Prim vs Kruskal 的选择: 对于稠密图(E ≈ V²),Prim 算法更优——邻接表 + 堆的实现为 O(E log V),而 Kruskal 需要排序 E 条边,复杂度为 O(E log E)。在稠密图上 E log V < E log E,但在竞赛实践中两者通常都能满足约束。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;

    // 邻接表:adj[u] = {边权, 邻居} 的列表
    vector<vector<pair<int,int>>> adj(n);
    for (int i = 0; i < m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        u--; v--;
        adj[u].push_back({w, v});
        adj[v].push_back({w, u});  // 无向图
    }

    // 用小根堆实现 Prim
    vector<bool> in_mst(n, false);
    long long mst_weight = 0;
    int edges_added = 0;

    // 小根堆:{边权, 顶点}
    priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;
    pq.push({0, 0});  // 从顶点 0 出发,代价 0

    while (!pq.empty() && edges_added < n) {
        auto [w, u] = pq.top(); pq.pop();

        if (in_mst[u]) continue;  // 已加入 MST,跳过(懒惰删除)
        in_mst[u] = true;
        mst_weight += w;
        edges_added++;

        for (auto [edge_w, v] : adj[u]) {
            if (!in_mst[v]) {
                pq.push({edge_w, v});  // 候选扩展边
            }
        }
    }

    if (edges_added < n) {
        cout << "图不连通\n";
    } else {
        cout << "MST 权重:" << mst_weight << "\n";
    }

    return 0;
}

复杂度: 用二叉堆时为 O(E log V)。


8.1.4 MST 性质在解题中的应用

除了直接计算 MST,以下几个性质在 USACO 中非常实用:

性质 1:唯一性

若所有边权互不相同,则 MST 唯一。若存在相同边权,可能有多棵总权重相等的 MST。

性质 2:瓶颈生成树

MST 最小化了任意两顶点之间路径上的最大边权。即:MST 上 u 到 v 的路径具有最小可能的"瓶颈"边

💡 USACO 应用: "u 到 v 路径上最大边权的最小值是多少?"→ 答案是 MST 上 u 到 v 路径中的最大边权。

性质 3:MST 作为贪心框架

很多 USACO Gold 题目可以归结为带有变形的 Kruskal 算法:

  • 按某种代价对"连接"排序
  • 贪心合并各组,只要合并合法
  • DSU 追踪哪些组已连通

非标准"边"上的 Kruskal 算法

经典 USACO 模式:边不是显式给出的——你需要自己分析排序的依据和"合并"的含义。

示例模式(USACO 2016 February Gold — Fencing the Cows):

  • 奶牛分布在各个牧场中;连接两头奶牛有代价
  • 目标:以最小总代价将所有奶牛连通
  • 解法:建图后运行 Kruskal 算法

8.1.5 Kruskal 重构树

Kruskal 重构树(Kruskal 树)是在 Kruskal 算法过程中构建的一种强大结构,它编码了连通分量的"合并历史"。

构建方法: 当 Kruskal 算法通过权重为 w 的边合并包含 u 和 v 的分量时:

  • 创建一个新节点 x,值为 w
  • 将 u 所在分量和 v 所在分量的根分别设为 x 的子节点
  • 用 x 替代这两个分量成为新的根

构建完毕后,该树具有以下结构:

  • N 个叶节点(原始顶点)
  • N-1 个内部节点(每条 MST 边对应一个,值 = 边权)
  • 共 2N-1 个节点
📄 Code 完整代码
示例 MST 边(已排序):(0,1,w=1)、(1,2,w=2)、(2,3,w=3)

合并 (0,1,w=1) 后:  节点 4(w=1)
                      / \
                     0   1

合并 (1,2,w=2) 后:  节点 5(w=2)
                      / \
                节点4    2
                (w=1)
                 / \
                0   1

合并 (2,3,w=3) 后:  节点 6(w=3)
                      / \
                  节点5   3
                  (w=2)
                   / \
                节点4  2
                (w=1)
                 / \
                0   1

关键性质:LCA 即瓶颈边

Kruskal 树中 u 和 v 的 LCA 的值 = MST 上 u 到 v 路径中最大边权 = u 和 v 之间最小可能瓶颈。

这意味着:

  • 查询"u 和 v 之间的最小瓶颈"→ 在 Kruskal 树中求 LCA
  • 查询"使 u 和 v 连通的最小边权是多少?"→ 同上
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> parent, rank_, root;  // root[i] = 以 i 为根的分量在 Kruskal 树中的根节点
    DSU(int n) : parent(n), rank_(n, 0), root(n) {
        iota(parent.begin(), parent.end(), 0);
        iota(root.begin(), root.end(), 0);
    }
    int find(int x) {
        return parent[x] == x ? x : parent[x] = find(parent[x]);
    }
    // 返回此次合并创建的新 Kruskal 树节点
    int unite(int x, int y, int new_node) {
        x = find(x); y = find(y);
        if (x == y) return -1;
        if (rank_[x] < rank_[y]) swap(x, y);
        parent[y] = x;
        if (rank_[x] == rank_[y]) rank_[x]++;
        root[x] = new_node;   // 新 Kruskal 树节点成为合并后分量的根
        return new_node;
    }
    int get_root(int x) { return root[find(x)]; }
};

// 构建 Kruskal 重构树
// 返回:Kruskal 树的邻接表和节点值
// 叶节点 0..n-1 为原始顶点;节点 n..2n-2 为内部节点(MST 边)
void build_kruskal_tree(
        int n,
        vector<tuple<int,int,int>>& edges,   // {权重, u, v}——必须已排序
        vector<vector<int>>& ktree,          // ktree[node] = Kruskal 树中的子节点
        vector<int>& node_val                // node_val[node] = 边权(内部节点)或 0(叶节点)
) {
    ktree.assign(2 * n, {});
    node_val.assign(2 * n, 0);

    DSU dsu(n);
    int next_node = n;  // 下一个内部节点的 ID

    for (auto [w, u, v] : edges) {
        int ru = dsu.find(u), rv = dsu.find(v);
        if (ru == rv) continue;  // 同一分量,跳过

        // 创建新内部节点
        int x = next_node++;
        node_val[x] = w;

        // 添加子节点:u 和 v 所在分量的 Kruskal 树根
        ktree[x].push_back(dsu.get_root(u));
        ktree[x].push_back(dsu.get_root(v));

        dsu.unite(u, v, x);
    }
}

// 构建完毕后,在 ktree 上用倍增 LCA 回答瓶颈查询
// ktree 中的 lca(u, v) 即为 MST 上 u 和 v 之间的瓶颈边权

USACO Gold 应用

题型:"对于每个查询 (u, v, k),统计从 u 出发仅使用权重 ≤ k 的边可以到达的顶点数"

在 Kruskal 树中,阈值 k 下以某个 LCA 为根的子树,恰好包含当只允许使用权重 ≤ node_val[LCA] 的边时,从 u 可以到达的所有顶点。

// 查询:仅使用权重 <= threshold 的边时,
// 与 u 在同一连通分量中的顶点数是多少?
// → 在 Kruskal 树中找 u 的最深祖先 x,满足 node_val[x] <= threshold
// → 答案 = sz[x](子树大小,计算的是叶节点数 = 原始顶点数)

💡 原因: Kruskal 树精确记录了边的合并顺序。内部节点 x 的子树包含了在权重为 node_val[x] 的边加入时同属一个连通分量的所有顶点。


8.1.6 USACO Gold 题型模式

模式 1:直接求 MST

"以最小总连接代价连通所有 N 个节点。"

直接应用 Kruskal 或 Prim 算法。

模式 2:排序 + DSU(Kruskal 式贪心)

"按某种顺序处理事件/对;合并分组;查询连通性。"

这本质是 Kruskal 算法,只是没有明确称其为 MST。核心思路:按某种标准排序,再用 DSU 合并。

// 模板:Kruskal 式贪心
sort(events.begin(), events.end(), comparator);
DSU dsu(n);
for (auto& event : events) {
    if (dsu.unite(event.u, event.v)) {
        // 处理合并操作
    }
}

模式 3:MST + 附加查询

"求 MST,然后对 MST 上的路径回答查询。"

构建 MST,再由 MST 的边构造出一棵树,最后回答路径查询(通常与第 8.4 章的欧拉游览结合使用)。


💡 思路陷阱

陷阱 1:把"最小瓶颈路"误当"最短路"

错误判断: "求 u 到 v 路径上最大边权的最小值,用 Dijkstra 最短路"
实际情况: 最短路最小化边权之和;最小瓶颈路最小化路径上的最大边 — 这是 MST 问题

图:u→A(w=1), u→B(w=5), A→v(w=10), B→v(w=6)
Dijkstra 最短路(u→v):u→A→v,总权=11,最大边=10
MST 瓶颈路(u→v):    u→B→v,总权=11,最大边=6  ← 最小瓶颈

关键:最小瓶颈路 = MST 上 u→v 的路径(由割边性质保证)

识别信号: 题目要求"最小化路径上的最大/最重边" → MST + 树上路径,而非 Dijkstra


陷阱 2:贪心合并时忘记"Kruskal 视角"

错误判断: "按某种顺序处理操作,感觉像贪心,写个模拟"
实际情况: 操作可以排序 + 用 DSU 合并 → 本质是 Kruskal 的变体

典型题:N 个集合,每次可以合并代价最小的两个集合(权重 = 两集合大小之积)
错误:模拟优先队列,每次弹出最小的两个合并 → 复杂度 O(N² log N)
正确:认识到"按代价排序 + DSU 合并"就是 Kruskal-style greedy → O(N log N)

识别信号: "处理 N 个对象,按某种代价合并,最终连通" → 先想 Kruskal 框架


⚠️ 常见错误

  1. 忘记判断连通性: 不是所有图都连通。Kruskal 结束后,验证 edges_added == n - 1;Prim 结束后,验证 edges_added == n

  2. DSU 实现有误(未路径压缩或未按秩合并): 朴素 DSU 不加优化时每次操作 O(N),导致 Kruskal 整体为 O(E·N) 而非 O(E log E)。

  3. 边数差一: MST 有 N−1 条边。若停在 N 条边,则多加了一条。

  4. 将 Kruskal 用于有向图: 两种算法都假设无向边。有向图需要不同方法(最小树形图 / 朱-刘/Edmonds 算法——USACO Gold 不考)。

  5. 整数溢出: 若边权最大 10⁹ 且 N = 10⁵,MST 总权重可达约 10¹⁴。请使用 long long


📋 章节小结

📌 核心要点

概念说明
MST 定义N−1 条边连通所有 N 个顶点,总权重最小
Kruskal 算法排序边,贪心加入不构成环的边(DSU);O(E log E)
Prim 算法从源点出发用小根堆扩张;O(E log V)
割边性质任意割的最小边一定在所有 MST 中
环性质任意环的最大边不在任何 MST 中
瓶颈路径MST 路径最小化了任意两顶点间的最大边
USACO 题型排序 + DSU 就是伪装后的 Kruskal——要认出来!

❓ 常见问题

Q:Kruskal 和 Prim 各适用什么场景?
A:竞赛中几乎总是优先选 Kruskal + DSU——实现更简洁,对稀疏图(USACO 的典型场景)效果好。只有在 E ≈ V² 的稠密图时才考虑 Prim。

Q:对所有边权加同一个常数,MST 会变吗?
A:不会——给所有边加常数不改变 MST 包含哪些边(只影响总权重)。

Q:一个图能有多棵 MST 吗?
A:可以,当存在相等边权时。但 MST 的总权重(总和)始终唯一。

Q:图不连通怎么办?
A:此时不存在生成树(无法连通所有顶点)。改为计算最小生成森林——每个连通分量各自的 MST。

Q:我的 Kruskal 在 USACO 评测机上答案错误——哪里出问题了?
A:检查:① 顶点编号是 1-indexed 还是 0-indexed 是否一致?② DSU 的路径压缩是否正确?③ 总权重是否使用了 long long

🔗 与后续章节的联系

  • 第 8.3 章(树形 DP): 构建 MST 后它就是一棵树,可以在 MST 上做树形 DP 来回答路径查询。
  • 第 8.4 章(欧拉游览): 欧拉游览可以在 MST 树结构上支持区间查询。
  • 第 5.3 节(DSU): Kruskal 算法大量依赖 DSU,请准备好第 5.3 节中带路径压缩的 DSU 模板。

🏋️ 练习题

🟢 简单

8.1-E1. 经典 MST
给定 N 个城市和 M 条有权道路,求连通所有城市的最小总权重。
(标准 Kruskal——热身题)

提示

按权重排序边,用 DSU 应用 Kruskal 算法。输出 MST 的边权之和。

解题模板
#include <bits/stdc++.h>
using namespace std;
// ...(使用 8.1.2 中的 Kruskal 模板)

8.1-E2. 是否连通?
给定 N 个节点和 M 条边,判断图是否连通。若连通,输出 MST 权重;若不连通,输出连通分量的数量。

提示

Kruskal 算法结束后,检查 edges_added == n - 1。若不满足,图不连通。连通分量的数量为 dsu.components


🟡 中等

8.1-M1. 最小瓶颈路径 (USACO 风格)
给定 N 个节点、M 条有权边以及 Q 个查询 (u, v),对每个查询求从 u 到 v 的任意路径上最大边权的最小值。

提示

核心思路:查询 (u, v) 的答案就是 MST 上 u 到 v 路径中的最大边权。

构建 MST。对每个查询,在 MST 树上找路径并返回最大边。(朴素:对每个查询做 DFS/BFS,O(N·Q)。高效:LCA + 倍增,见第 8.4 章。)

竞赛中,若 Q ≤ 1000,朴素的 O(N·Q) 方案通常能通过。


8.1-M2. Kruskal 式贪心 USACO 2016 February Gold — Fencing the Cows)
N 头奶牛,每头在某个牧场中。在牧场 i 和 j 之间移动一头奶牛的代价为 |i - j|。用最小总代价将所有牧场连成一组。

提示

这本质是在完全图上求 MST,但排序所有 O(N²) 条边太慢。核心观察:对于数轴上的点,MST 始终只使用相邻边!将奶牛按位置排序,只在相邻牧场之间添加边。


🔴 困难

8.1-H1. 动态连通性 (进阶)
给定 N 个节点,处理 Q 次操作:"添加边 (u,v,w)"或"查询:到目前为止所有已添加边的 MST 权重是多少?"

提示

维护一个有序的边集合,增量运行 Kruskal 算法。当新增边 (u,v,w) 时:若 u 和 v 在当前 MST 中已连通,只有当 w 小于 MST 路径上的最大边权时,才用新边替换。

这需要找到树上路径中的最大边——使用欧拉游览 + 线段树,或倍增。


🏆 挑战

8.1-C1. Steiner 树(近似)
给定一个图和一组"必选"顶点集合 S,求连通 S 中所有顶点的最小权重子树(Steiner 树问题)。当 |S| ≤ 15 时,用状压 DP 精确求解。

提示

这不是 MST 问题——而是状压 DP 与最短路的结合。定义 dp[mask][v] = 以顶点 v 为节点、生成 mask 中所有顶点的最小代价树。