GESP 8级

最小生成树 · Kruskal

8级 · 高级数据结构与DP
Kruskal 算法是一种求最小生成树(MST)的经典算法。它的核心思想非常直观:把所有边按权重从小到大排序,然后一条一条地"挑"——只要加上这条边不会形成环,就选它。最终选出的 n-1 条边构成一棵总权值最小的生成树。
💡 这是什么?
生活比喻:想象你是一个城市规划师,有 n 个城市,城市之间有一些可以修路的路线,每条路的修建费用不同。你的目标是让所有城市都互相连通,同时总花费最小。

Kruskal 的策略就像一个"精打细算的商人":
① 把所有路线按费用从低到高排列
② 从最便宜的开始,一条一条试——如果修这条路不会让已经通的城市形成"环路"(即多余的路),就修它
③ 修了 n-1 条路后,所有城市就连通了,总费用最小

关键概念回顾:
• 生成树:n 个顶点的连通图,用恰好 n-1 条边把所有顶点连起来,且没有环
• 最小生成树:所有生成树中,边权之和最小的那棵
• 环:从一个点出发,经过若干条边后又回到自己。生成树绝对不能有环
🌟 为什么重要?
• 网络设计:设计计算机网络、公路网、输电网时,用最少的线缆/道路连接所有节点
• 聚类分析:在数据科学中,去掉权重最大的边可以做层次聚类
• GESP考试:8级必考内容,是图论的核心算法之一
• 竞赛高频:在信息学竞赛中经常出现,也是学习 Prim 算法的基础对比
📋 前置知识(学这个之前你需要知道)
1. 图的基本概念(GESP 3-4级):顶点、边、邻接表、无向图
2. 并查集(GESP 5级):快速判断两个元素是否在同一集合,快速合并两个集合——这是 Kruskal 判环的关键工具
3. 排序(GESP 1-2级):std::sort 和自定义比较函数
4. 贪心算法思想:每一步选当前最优的,最终得到全局最优

如果你还不熟悉并查集,请先看并查集相关的知识,因为 Kruskal 的核心就是"排序 + 并查集判环"。
📐 Kruskal 算法步骤
① 把图中所有边按权重从小到大排序
② 初始化并查集,每个顶点自成一个集合
③ 依次考虑每条边 (u, v, w):
   如果 find(u) ≠ find(v):这条边的两端不在同一集合
      → 选这条边,merge(u, v),mst += w
   如果 find(u) == find(v):这条边会形成环
      → 跳过
④ 重复直到选了 n-1 条边(或所有边都考虑完)
💻 完整代码(带详细注释)
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

const int N = 100010;

// ========== 并查集部分 ==========
int parent[N];  // parent[i] 表示节点 i 的父节点

// 查找节点 x 所属集合的代表元素(带路径压缩)
int find(int x) {
    if (parent[x] != x)              // 如果 x 不是自己的父节点
        parent[x] = find(parent[x]); // 递归找根,并压缩路径
    return parent[x];                // 返回根节点
}

// 合并两个集合:把 x 和 y 所在的集合连起来
void merge(int x, int y) {
    int rx = find(x);  // 找到 x 的根
    int ry = find(y);  // 找到 y 的根
    if (rx != ry)      // 如果根不同(不在同一集合)
        parent[rx] = ry; // 把 x 的根指向 y 的根
}

// ========== 边的结构体 ==========
struct Edge {
    int u, v, w;  // u-v 之间有一条权重为 w 的边
};

// 自定义排序:按边权从小到大排序
bool cmp(Edge a, Edge b) {
    return a.w < b.w;  // w 小的排前面
}

// ========== Kruskal 主算法 ==========
int kruskal(vector<Edge>& edges, int n) {
    // 第1步:把所有边按权重从小到大排序
    sort(edges.begin(), edges.end(), cmp);

    // 第2步:初始化并查集——每个节点最初是自己的父节点
    for (int i = 0; i < n; i++)
        parent[i] = i;

    int mstWeight = 0;  // 最小生成树的总权值
    int edgeCount = 0;  // 已经选了多少条边

    // 第3步:依次考虑每条边
    for (auto& e : edges) {
        // 检查:这条边的两个端点是否在同一集合?
        if (find(e.u) != find(e.v)) {
            // 不在同一集合 → 加入这条边不会形成环
            merge(e.u, e.v);      // 合并两个集合
            mstWeight += e.w;      // 累加边权
            edgeCount++;           // 选中的边数+1
            if (edgeCount == n - 1)
                break;  // 已经选了 n-1 条边,MST 完成
        }
        // 如果在同一集合 → 加入会形成环,跳过
    }
    return mstWeight;
}

int main() {
    int n, m;  // n个顶点,m条边
    cin >> n >> m;
    vector<Edge> edges(m);
    for (int i = 0; i < m; i++)
        cin >> edges[i].u >> edges[i].v >> edges[i].w;

    cout << kruskal(edges, n) << endl;
    return 0;
}
🔍 算法执行过程举例
假设有4个城市,边如下:
(0,1,1) (1,2,2) (0,2,3) (2,3,4)

第1步:排序 → (0,1,1), (1,2,2), (0,2,3), (2,3,4)
第2步:初始化 → {0}, {1}, {2}, {3} 四个独立集合
第3步:处理边 (0,1,1)
   find(0)=0, find(1)=1, 0≠1 → 选!merge(0,1), 权值=1, 集合: {0,1}, {2}, {3}
第4步:处理边 (1,2,2)
   find(1)=0, find(2)=2, 0≠2 → 选!merge(1,2), 权值=1+2=3, 集合: {0,1,2}, {3}
第5步:处理边 (0,2,3)
   find(0)=0, find(2)=0, 0==0 → 跳过!(加了会成环)
第6步:处理边 (2,3,4)
   find(2)=0, find(3)=3, 0≠3 → 选!merge(2,3), 权值=3+4=7, 集合: {0,1,2,3}
结果:选了3条边(=4-1),最小生成树权值 = 7
⏱ 复杂度分析
排序:O(m log m),m 是边数
并查集操作:O(m × α(n)),α 是反阿克曼函数,几乎等于常数
总复杂度:O(m log m)

适用场景:稀疏图(边比较少)效果好,代码也比 Prim 更好写
⚠️ 易错点
1. 忘记排序! Kruskal 必须先把边按权重排序,否则结果不是最小生成树。
2. 并查集没路径压缩 → 复杂度退化,可能超时。find 函数一定要写路径压缩。
3. 边数判断错误 → 如果遍历完所有边还没选到 n-1 条,说明图不连通,没有生成树。
4. 节点编号从0还是从1 → 注意题目要求,并查集初始化时要对应。
5. 有重边/自环 → Kruskal 天然可以处理重边(排序后小的先处理),但自环(u==v的边)记得跳过。
🎯 练习建议
入门练习:
• 洛谷 P3366 【模板】最小生成树(直接套模板)
• 洛谷 P1546 【模板】最小生成树

进阶练习:
• 洛谷 P1661 扩散(需要建模成最小生成树)
• 洛谷 P2820 电信网络(需要理解 Kruskal 的应用)

学习方法:
① 先手写并查集(find + merge),确保你很熟练
② 然后把排序 + 并查集组合起来,就是 Kruskal
③ 对比 Prim 算法(下一个知识点),理解两种方法的区别
④ 做 3-5 道模板题,确保能快速写出完整代码