CC++ & Algorithm

C++最小生成树 Kruskal 算法

较难3
语言版本:C++Python
概述:用最少的成本连接所有点,就像给村庄修路一样省材料

用Kruskal算法给村庄修路——C++最小生成树详解

什么是最小生成树?

想象你是一个村长,手下有N个散落的小村庄。你想修一些水泥路,让任意两个村庄都能通过新修的路互相到达(不需要直接相连,可以通过其他村庄中转)。但是每修一条路都要花钱,你希望花的总成本最少。这个问题在计算机里就叫“最小生成树”(Minimum Spanning Tree, MST)——在一张带权无向图中,选择若干条边,连接所有顶点,并且这些边的总权重最小。

Kruskal算法就是解决这个问题的经典方法之一。它非常“贪心”:每次都挑当前最便宜的那条路,只要它不会和已经选好的路构成一个环(圆圈)。就像你买文具,先挑最划算又不会重复的,最后凑齐一整套。

生活中的修路问题

假设有5个小村庄(编号0到4),它们之间有一些泥土路可以修成水泥路,但每条路的成本(权重)不同:

  • 0和1之间:2万元
  • 0和3之间:6万元
  • 1和2之间:3万元
  • 1和3之间:8万元
  • 1和4之间:5万元
  • 2和4之间:7万元

村长想用最少的钱把所有村庄连起来(只要任意两个村庄能通过新修的路到达就行)。你会怎么选路呢?

如果随意选,可能会选到昂贵的路,或者不小心让路形成圆圈(比如选了0-1、1-2、0-2三条路,村庄0、1、2就会形成一个三角形,但多了一条不必要的路,浪费钱)。Kruskal算法会避免这种情况。

Kruskal算法的核心思想

每次都选最便宜的边,只要它不会让已经修好的路形成“圆圈”。就像你拼图,先拼最便宜又不会出错的块,最后所有块就拼在一起了。

算法步骤详解

  1. 把所有边按权重从小到大排序。就像把修路预算单按价格从低到高排列。
  2. 初始化一个“连通性检查器”,也就是并查集(Union-Find)。开始时每个村庄单独成一个组,互不相通。
  3. 从最便宜的边开始遍历
    • 检查这条边的两个村庄是否已经在同一个连通组里(用并查集的find操作)。
    • 如果不在同一个组,就选中这条边(修这条路),并把这两个组合并(unite操作)。
    • 如果在同一个组,说明这条边会形成环,跳过它。
  4. 重复步骤3,直到选中的边数达到 村庄数 - 1。此时所有村庄都连在一起了,总成本就是所选边的权重之和。

为什么选 n-1 条边就够了?因为一棵有 n 个顶点的树恰好有 n-1 条边,再多就会形成环。

为什么不会形成环?——并查集来帮忙

并查集就像一个“家族族长查询系统”。每个村庄最初都是自己的族长。当修了一条路连接两个村庄,它们就合并成一个家族,族长统一由其中一个担任。你要判断两个村庄是否已经连通,只要查它们是不是同一个族长就行。

并查集有两个核心操作:

  • find(x):查找村庄x的族长(根节点)。为了加快速度,采用路径压缩:查找时直接把沿途所有村庄的族长都改成最终的族长,下次查找更快。
  • unite(x, y):合并两个村庄的家族。如果已经同一个族长,返回false;否则,把其中一个族长变成另一个的“手下”。为了平衡,采用按秩合并:总是让“矮”的树合并到“高”的树上,避免树太深。

举个例子:村庄0和1连通后,0变成族长。然后村庄2想和1连通,find(1)得到0,find(2)得到2,族长不同,所以合并,现在族长是0。如果下次出现边(0,2),find(0)find(2)都是0,说明已经连通,这条边就会形成环,不能选。

完整代码逐行详解

下面的C++代码实现了Kruskal算法。我用邻接表(边列表)存储图,并配合并查集。

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

// 定义边的结构体:两个顶点u、v和权重w
struct Edge {
    int u; // 顶点1
    int v; // 顶点2
    int w; // 边的权重(成本)
    
    // 重载小于号,用于按权重排序
    bool operator<(const Edge &other) const {
        return w < other.w;
    }
};

// 并查集类,用于判断两个顶点是否连通以及合并
class UnionFind {
public:
    // parent数组:每个顶点的父节点(初始为自身)
    // rank数组:树的高度(用于按秩合并)
    vector<int> parent, rank;
    
    // 构造函数:初始化n个顶点,每个顶点独立
    UnionFind(int n) {
        parent.resize(n);
        rank.resize(n, 0);
        for (int i = 0; i < n; i++) {
            parent[i] = i; // 初始父节点是自己
        }
    }
    
    // 查找顶点x的根节点(族长),并路径压缩
    int find(int x) {
        if (parent[x] != x) {
            parent[x] = find(parent[x]); // 递归查找并压缩
        }
        return parent[x];
    }
    
    // 合并两个顶点所在的集合,成功返回true,已连通返回false
    bool unite(int x, int y) {
        int rootX = find(x);
        int rootY = find(y);
        if (rootX == rootY) return false; // 已经在同一集合,无需合并
        
        // 按秩合并:把矮的树挂到高的树下
        if (rank[rootX] < rank[rootY]) {
            parent[rootX] = rootY;
        } else if (rank[rootX] > rank[rootY]) {
            parent[rootY] = rootX;
        } else {
            // 高度相同,随便挂一个,并增加高度
            parent[rootY] = rootX;
            rank[rootX]++;
        }
        return true;
    }
};

// Kruskal算法主函数,返回最小生成树的总权重
int kruskal(int n, vector<Edge> &edges) {
    // 第一步:按权重从小到大排序所有边
    sort(edges.begin(), edges.end());
    
    // 创建并查集,初始有n个独立的顶点
    UnionFind uf(n);
    
    int totalWeight = 0; // 总成本
    int count = 0;       // 已选中的边数
    
    // 遍历每一条边
    for (Edge &e : edges) {
        // 如果合并成功(两个顶点原本不连通)
        if (uf.unite(e.u, e.v)) {
            totalWeight += e.w; // 加上这条边的权重
            count++;            // 边数加1
            // 当已选边数达到n-1时,就可以提前结束
            if (count == n - 1) break;
        }
        // 如果合并失败,说明这条边会形成环,直接跳过
    }
    
    // 如果图不连通(比如有些顶点无法到达),count可能小于n-1,
    // 但题目保证图连通,所以这里直接返回总权重
    return totalWeight;
}

int main() {
    // 例子:5个村庄(编号0~4),6条路
    int n = 5; // 顶点数量
    vector<Edge> edges = {
        {0, 1, 2},  // 村庄0和1修路需2万元
        {0, 3, 6},  // 村庄0和3修路需6万元
        {1, 2, 3},  // 村庄1和2修路需3万元
        {1, 3, 8},  // 村庄1和3修路需8万元
        {1, 4, 5},  // 村庄1和4修路需5万元
        {2, 4, 7}   // 村庄2和4修路需7万元
    };
    
    int result = kruskal(n, edges);
    cout << "最小生成树的总成本: " << result << "万元" << endl;
    // 运行结果:选中的边为(0,1,2),(1,2,3),(1,4,5),(0,3,6) → 总成本2+3+5+6=16万元
    return 0;
}

代码执行过程简述

排序后的边权重顺序:2, 3, 5, 6, 7, 8。

  • 选(0,1,2):合并0和1,总成本2,边数1。
  • 选(1,2,3):查find(1)=0, find(2)=2,合并,总成本5,边数2。
  • 选(1,4,5):查find(1)=0, find(4)=4,合并,总成本10,边数3。
  • 选(0,3,6):查find(0)=0, find(3)=3,合并,总成本16,边数4。
    现在count=4等于n-1,算法结束。总成本16万元,正是最小成本。

常见错误与调试技巧

  1. 忘记对边排序
    没有sort(edges.begin(), edges.end()),算法就变成随机选边,结果大概率不是最小生成树。

  2. 并查集数组大小不够
    如果顶点编号从1开始,而并查集只申请了n个空间,访问find(n)会越界。要么把顶点编号统一从0开始,要么申请n+1个空间。

  3. 路径压缩和按秩合并没实现
    虽然简单实现也能跑,但效率会降低。在竞赛或大数据下可能超时。务必记得写路径压缩(find中的递归赋值)和按秩合并(比较rank)。

  4. 提前结束条件写错
    正确的条件是count == n - 1。如果写成count == n,会多选一条边,形成环;如果写成count == n - 2,则未完成连接。

  5. 图不连通时处理不当
    Kruskal算法如果执行完所有边后count < n-1,说明图不连通,最小生成树不存在。实际题目通常保证连通,但自己写程序时最好加个判断。

生活中的更多例子

  • 连接岛屿建桥:太平洋上有N个岛屿,要建一些桥使得任意两岛能互相到达,建桥成本不同。用Kruskal可以找到最省钱的桥梁方案。
  • 班级友谊网络:班里N个同学,让每两个同学成为“朋友”需要花费一定时间聊天,希望用最少的总时间让全班都“间接认识”。这不就是最小生成树吗?
  • 城市供水管道:多个居民楼要接入自来水总管,铺设管道成本各异,Kruskal帮你选出最经济的连接方案。

相关知识点指引

  • Prim算法:另一种求最小生成树的算法,从一个顶点出发逐步扩张,适用于稠密图。你可以对比学习。
  • 并查集:Kruskal算法的核心工具,也用于判断无向图连通性、求连通分量个数、处理等价关系等。
  • 图论基础:理解顶点、边、权重、连通图、树的概念是学习最小生成树的前提。
  • 贪心算法:Kruskal和Prim都是贪心算法。如果你对“每一步都选最优”的方法感兴趣,可以深入学习贪心思想。
  • 时间复杂度分析:Kruskal的时间复杂度为O(E log E)(排序花费大),适合边较少的稀疏图;Prim(用二叉堆)为O((V+E) log V),适合稠密图。根据数据范围选择合适的算法。

下次遇到需要“连接所有点,总花费最少”的问题,试试用Kruskal算法吧!

例题精讲

1单选题

在Kruskal算法中,每次选择下一条加入最小生成树的边时,应该选择哪条边?

A当前未处理边中权重最小的边
B当前未处理边中权重最大的边
C随机选择一条未处理的边
D与已选边形成环的边中权重最小的边
2判断题

Kruskal算法适用于稠密图,而Prim算法适用于稀疏图。

3判断题

在Kruskal算法中,使用并查集可以高效地判断加入一条边是否会形成环。

4填空题
以下是用并查集实现Kruskal算法的部分代码,请补全 find 函数(路径压缩)。

int fa[100005];
int find(int x) {
    if (fa[x] != x) fa[x] = ___;
    return fa[x];
}
5单选题

对于一个有 n 个顶点、m 条边的无向连通图,使用 Kruskal 算法求最小生成树的时间复杂度是?

AO(n²)
BO(m log m)
CO(m log n)
DO(n log m)