C++最小生成树 Kruskal 算法
较难3用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算法的核心思想
每次都选最便宜的边,只要它不会让已经修好的路形成“圆圈”。就像你拼图,先拼最便宜又不会出错的块,最后所有块就拼在一起了。
算法步骤详解
- 把所有边按权重从小到大排序。就像把修路预算单按价格从低到高排列。
- 初始化一个“连通性检查器”,也就是并查集(Union-Find)。开始时每个村庄单独成一个组,互不相通。
- 从最便宜的边开始遍历:
- 检查这条边的两个村庄是否已经在同一个连通组里(用并查集的
find操作)。 - 如果不在同一个组,就选中这条边(修这条路),并把这两个组合并(
unite操作)。 - 如果在同一个组,说明这条边会形成环,跳过它。
- 检查这条边的两个村庄是否已经在同一个连通组里(用并查集的
- 重复步骤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万元,正是最小成本。
常见错误与调试技巧
-
忘记对边排序
没有sort(edges.begin(), edges.end()),算法就变成随机选边,结果大概率不是最小生成树。 -
并查集数组大小不够
如果顶点编号从1开始,而并查集只申请了n个空间,访问find(n)会越界。要么把顶点编号统一从0开始,要么申请n+1个空间。 -
路径压缩和按秩合并没实现
虽然简单实现也能跑,但效率会降低。在竞赛或大数据下可能超时。务必记得写路径压缩(find中的递归赋值)和按秩合并(比较rank)。 -
提前结束条件写错
正确的条件是count == n - 1。如果写成count == n,会多选一条边,形成环;如果写成count == n - 2,则未完成连接。 -
图不连通时处理不当
Kruskal算法如果执行完所有边后count < n-1,说明图不连通,最小生成树不存在。实际题目通常保证连通,但自己写程序时最好加个判断。
生活中的更多例子
- 连接岛屿建桥:太平洋上有N个岛屿,要建一些桥使得任意两岛能互相到达,建桥成本不同。用Kruskal可以找到最省钱的桥梁方案。
- 班级友谊网络:班里N个同学,让每两个同学成为“朋友”需要花费一定时间聊天,希望用最少的总时间让全班都“间接认识”。这不就是最小生成树吗?
- 城市供水管道:多个居民楼要接入自来水总管,铺设管道成本各异,Kruskal帮你选出最经济的连接方案。
相关知识点指引
- Prim算法:另一种求最小生成树的算法,从一个顶点出发逐步扩张,适用于稠密图。你可以对比学习。
- 并查集:Kruskal算法的核心工具,也用于判断无向图连通性、求连通分量个数、处理等价关系等。
- 图论基础:理解顶点、边、权重、连通图、树的概念是学习最小生成树的前提。
- 贪心算法:Kruskal和Prim都是贪心算法。如果你对“每一步都选最优”的方法感兴趣,可以深入学习贪心思想。
- 时间复杂度分析:Kruskal的时间复杂度为O(E log E)(排序花费大),适合边较少的稀疏图;Prim(用二叉堆)为O((V+E) log V),适合稠密图。根据数据范围选择合适的算法。
下次遇到需要“连接所有点,总花费最少”的问题,试试用Kruskal算法吧!
例题精讲
在Kruskal算法中,每次选择下一条加入最小生成树的边时,应该选择哪条边?
Kruskal算法适用于稠密图,而Prim算法适用于稀疏图。
在Kruskal算法中,使用并查集可以高效地判断加入一条边是否会形成环。
以下是用并查集实现Kruskal算法的部分代码,请补全 find 函数(路径压缩)。
int fa[100005];
int find(int x) {
if (fa[x] != x) fa[x] = ___;
return fa[x];
}对于一个有 n 个顶点、m 条边的无向连通图,使用 Kruskal 算法求最小生成树的时间复杂度是?