最小生成树(Minimum Spanning Tree)
什么是最小生成树?
给定一个连通无向带权图 G = (V, E),其中 V 是顶点集合,E 是边集合。G 的生成树是一棵包含 V 中所有顶点的树,它是 G 的一个子图,包含全部 n 个顶点和 n-1 条边。
在 G 的所有生成树中,边权总和最小的那一棵生成树称为最小生成树。
关键性质
- 包含全部 n 个顶点
- 恰好包含 n-1 条边
- 是一棵树(连通且无环)
- 边权和在所有生成树中最小
应用场景
- 城市间铺设光缆/公路,使所有城市连通且总造价最小
- 电网布线设计
- 网络聚类分析
克鲁斯卡尔(Kruskal)算法
Kruskal 算法是一种使用贪婪方法的最小生成树算法。该算法初始将图视为森林,图中的每一个顶点视为一棵单独的树。一棵树只与它的邻接顶点中权值最小且不违反最小生成树属性(不构成环)的树之间建立连边。
算法步骤
- 把所有边按权值从小到大排序
- 按权值从小到大的顺序依次选择边,判断能否加入最小生成树
- 判断方法:利用并查集,检查边的两个端点是否在同一个连通分量中
- 如果不在同一分量,则选择这条边,合并两个分量
- 如果在同一分量,则跳过(否则会形成环)
- 当已选择 n-1 条边时,最小生成树构建完成
代码实现
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* 给定一个带权无向连通图:
* n(10 <= n <= 100) 个点,m 条边,顶点编号为 0 ~ n-1
* 求该图的最小生成树的边权和 */
int n, m;
typedef struct {
int u, v; // 两个端点
int w; // 权值
} Edge;
Edge e[10005]; // 边集数组
int f[105]; // 并查集中的父亲数组
// 按权值对边排序(选择排序实现)
void Sortt(int l, int r) {
int minn;
Edge t;
for (int i = l; i <= r - 1; i++) {
minn = i;
for (int j = i; j <= r; j++) {
if (e[j].w < e[minn].w) {
minn = j;
}
}
t = e[minn];
e[minn] = e[i];
e[i] = t;
}
}
// 并查集查找(带路径压缩)
int Find(int x) {
if (f[x] == x) {
return x;
} else {
return f[x] = Find(f[x]);
}
}
void Kruskal() {
// 初始化并查集:每个顶点自成一个集合
for (int i = 0; i < n; i++) {
f[i] = i;
}
int cnt = 0; // 已选择的边数
int sum = 0; // 边权和
int fu = 0, fv = 0;
for (int i = 1; i <= m; i++) {
// 判断这条边的两个端点是否属于同一个集合
fu = Find(e[i].u);
fv = Find(e[i].v);
if (fu != fv) {
// 不属于同一集合 → 可选,不会构成环
printf("选 %d 和 %d 之间 边权为 %d 的边\n", e[i].u, e[i].v, e[i].w);
sum += e[i].w;
cnt++;
f[fv] = fu; // 合并两个集合
}
if (cnt == n - 1) {
break; // 已选够 n-1 条边,最小生成树形成
}
}
printf("最小生成树的边权和为:%d\n", sum);
}
int main() {
scanf("%d %d", &n, &m);
int x, y, w;
for (int i = 1; i <= m; i++) {
scanf("%d %d %d", &x, &y, &w);
e[i].u = x;
e[i].v = y;
e[i].w = w;
}
Sortt(1, m); // 按边权排序
Kruskal();
return 0;
}
/*
输入样例:
9 15
0 1 3
0 5 4
1 6 6
6 5 7
1 2 8
1 8 5
2 8 2
2 3 12
8 3 11
6 3 14
6 7 9
5 4 18
3 7 6
7 4 1
3 4 10
*/
关键代码解析
1. 边集数组 Edge e[]
typedef struct {
int u, v; // 两个端点
int w; // 权值
} Edge;
Edge e[10005];
Kruskal 算法是对边操作,因此用边集数组存储图。每条边记录两个端点和权值即可,不需要邻接矩阵或邻接表。
2. 排序函数 Sortt
void Sortt(int l, int r) {
int minn;
Edge t;
for (int i = l; i <= r - 1; i++) {
minn = i;
for (int j = i; j <= r; j++) {
if (e[j].w < e[minn].w) {
minn = j;
}
}
t = e[minn];
e[minn] = e[i];
e[i] = t;
}
}
- 使用选择排序对边按权值从小到大排序
- 外层循环从
l到r-1,内层循环找到[i..r]中权值最小的边的下标minn - 找到后将
e[i]与e[minn]交换 - 排序是整个 Kruskal 算法的前提——只有边有序,才能按贪心策略依次选取
3. 并查集查找 Find
int Find(int x) {
if (f[x] == x) {
return x;
} else {
return f[x] = Find(f[x]); // 路径压缩
}
}
- 递归查找顶点 x 所在集合的根节点("祖先")
- 路径压缩:在查找过程中,将沿途所有节点直接挂到根节点下,加速后续查找
- 返回 x 所属集合的代表元素
4. Kruskal 核心逻辑
for (int i = 1; i <= m; i++) {
fu = Find(e[i].u);
fv = Find(e[i].v);
if (fu != fv) {
sum += e[i].w;
cnt++;
f[fv] = fu; // 合并
}
if (cnt == n - 1) break;
}
- 按权值从小到大遍历每条边
Find(e[i].u) != Find(e[i].v)说明 u 和 v 当前不在同一连通分量中——选这条边不会形成环- 选择边后,
f[fv] = fu将两个分量合并 cnt记录已选边数,达到 n-1 时提前退出
5. 为什么选这条边不会形成环?
初始:每个顶点独立
0 1 2 3 4
↓ ↓ ↓ ↓ ↓
[0] [1] [2] [3] [4]
选择边 0-1(权 3):0 和 1 不在同一集合 → 合并
[0]──[1] 2 3 4
↓ ↓
{0, 1}
选择边 0-5(权 4):0 和 5 不在同一集合 → 合并
[0]──[1] [2] [3] [4] [5]
↓ ↓ ↓
{0,1,5}
假设之后遇到边 1-5(权 7):
Find(1) = 0, Find(5) = 0 → 祖先相同 → 跳过(否则成环)
时间复杂度分析
1. 排序函数 Sortt
- 两条嵌套循环,执行次数为 (m-1) + (m-2) + ... + 1 = m(m-1)/2
- 时间复杂度:O(m²)
若改用快速排序或归并排序,排序部分可降至 O(m log m),是实际工程中的标准做法。
2. 并查集操作
- 路径压缩后,单次
Find的均摊时间复杂度接近 O(α(n)),其中 α 为 Ackermann 函数的反函数,可认为是一个极小的常数(通常 ≤ 4) - Kruskal 主循环中最多执行 m 次 Find 和 n-1 次合并操作
- 总时间复杂度:接近 O(m)
3. 整体复杂度
| 步骤 | 操作 | 复杂度(当前实现) | 复杂度(优化后) |
|---|---|---|---|
| 排序 | 选择排序 / 快速排序 | O(m²) | O(m log m) |
| 主循环 | m 次 Find + 合并 | O(m α(n)) ≈ O(m) | O(m α(n)) |
| 总计 | O(m²) | O(m log m) |
- 当 m 较大时,排序是 Kruskal 的主要瓶颈
- 当前代码使用选择排序,对于 m = 10000 时性能尚可接受,但建议工程中改用
qsort
空间复杂度
- 边集数组
e[m+1]:O(m) - 并查集数组
f[n]:O(n) - 总空间复杂度:O(m + n)
普里姆(Prim)算法
Prim 算法在找最小生成树时,将顶点分为两类:一类是在查找的过程中已经包含在生成树中的顶点(假设为 A 类),剩下的为另一类(假设为 B 类)。
算法步骤
- 任选一个顶点作为生成树的起点(并标记),将其到生成树的距离设为 0,其他顶点到生成树的距离初始化为无穷大
- 更新起点所有邻接点到生成树的距离
- 在未加入生成树的顶点中,选择距离生成树最近的那个顶点,加入生成树并标记
- 更新新加入顶点的邻接点到生成树的距离(取原有值与新边权值的较小值)
- 重复步骤 3-4,直到所有 n 个顶点都加入生成树
代码实现
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define inf 10001
/* 给定一个带权无向连通图:
* n(<=100) 个点,m 条边,顶点编号为 0 ~ n-1
* 边权都小于 10000
* 输出最小生成树的边权和 */
int n, m;
int dist[105]; // 各顶点到生成树的距离
int flag[105]; // 标记顶点是否已加入生成树
int g[105][105]; // 邻接矩阵
int mymin(int a, int b) {
return a < b ? a : b;
}
void Prim() {
int s = 1; // 任选一点(此处选顶点 1)作为起点
dist[s] = 0; // 起点到生成树的距离为 0
int minn; // dist 最小的顶点
int t; // 保存 dist[minn]
int sum = 0; // 边权和
for (int i = 1; i <= n; i++) { // 循环 n 次,每次加入一个顶点
// 找未加入生成树中 dist 最小的顶点
t = inf;
minn = -1;
for (int j = 0; j < n; j++) {
if (flag[j] == 0 && dist[j] < t) {
minn = j;
t = dist[j];
}
}
// 将 minn 加入生成树
printf("点 %d 通过边权为 %d 的边加入到生成树中\n", minn, t);
flag[minn] = 1; // 标记
sum += t;
// 更新 minn 的邻接点到生成树的距离
for (int j = 0; j < n; j++) {
if (flag[j] == 0 && g[minn][j] < inf) {
dist[j] = mymin(dist[j], g[minn][j]);
}
}
}
printf("边权总和为 %d\n", sum);
}
int main() {
scanf("%d %d", &n, &m);
// 初始化邻接矩阵和 dist 数组
for (int i = 0; i < n; i++) {
dist[i] = inf;
for (int j = 0; j < n; j++) {
g[i][j] = inf;
}
}
int x, y, w;
for (int i = 1; i <= m; i++) {
scanf("%d %d %d", &x, &y, &w);
g[x][y] = g[y][x] = w; // 无向图
}
Prim();
return 0;
}
/*
输入样例:
9 15
0 1 3
0 5 4
1 6 6
6 5 7
1 2 8
1 8 5
2 8 2
2 3 12
8 3 11
6 3 14
6 7 9
5 4 18
3 7 6
7 4 1
3 4 10
*/
关键代码解析
1. 核心数据结构
int dist[105]; // 顶点到生成树的距离
int flag[105]; // 是否已加入生成树
int g[105][105]; // 邻接矩阵存图
dist[j]:顶点 j 到当前生成树的最短距离,若 j 已在树中则为 0flag[j]:标记顶点是否已被选入生成树g[i][j]:邻接矩阵存储图中边 i-j 的权值,无直接连边则为inf
2. Prim 核心逻辑
// 找未加入生成树中 dist 最小的顶点
t = inf;
minn = -1;
for (int j = 0; j < n; j++) {
if (flag[j] == 0 && dist[j] < t) {
minn = j;
t = dist[j];
}
}
// 将 minn 加入生成树
flag[minn] = 1;
sum += t;
// 更新 minn 的邻接点到生成树的距离
for (int j = 0; j < n; j++) {
if (flag[j] == 0 && g[minn][j] < inf) {
dist[j] = mymin(dist[j], g[minn][j]);
}
}
每一步做三件事:
- 选点:遍历所有未加入的顶点,找出
dist最小的那个(离生成树最近) - 加入:标记该顶点已加入,累加边权
- 更新:扫描新顶点的所有邻接点,若通过新顶点到生成树距离更短,则更新
dist
3. 执行流程示例
初始:以顶点 1 为起点
顶点: 0 1 2 3 4 5 6 7 8
dist: ∞ 0 ∞ ∞ ∞ ∞ ∞ ∞ ∞
flag: 0 1 0 0 0 0 0 0 0
第1步:选 dist 最小的未标记点 → 顶点 1(t=0,起点无代价)
更新邻接点 dist:
dist[0]=3, dist[2]=8, dist[6]=6, dist[8]=5
第2步:选 dist 最小的未标记点 → 顶点 0(t=3)
更新邻接点:dist[5]=4
第3步:选 dist 最小的未标记点 → 顶点 5(t=4)
更新邻接点:dist[4]=18, dist[6]=7(原6→新7,不变)
第4步:选 dist 最小的未标记点 → 顶点 8(t=5)
更新邻接点:dist[2]=2(原8→新2), dist[3]=11
第5步:选 dist 最小的未标记点 → 顶点 2(t=2)
更新邻接点:dist[3]=11→12(不变,保留11)
... 依次进行,直到所有顶点加入
4. 与 Dijkstra 算法的关系
Prim 算法的代码结构与 Dijkstra 最短路径算法非常相似,核心区别在于 dist 的含义:
| 算法 | dist[j] 的含义 | 更新方式 |
|---|---|---|
| Prim | j 到生成树的最短距离 | dist[j] = min(dist[j], g[minn][j]) |
| Dijkstra | j 到源点的最短路径 | dist[j] = min(dist[j], dist[minn] + g[minn][j]) |
Prim 只关心顶点到树的距离(单边权值),Dijkstra 关心到源点的累积距离(路径和)。
时间复杂度分析
1. 查找最小 dist 顶点
for (int j = 0; j < n; j++) {
if (flag[j] == 0 && dist[j] < t) { ... }
}
- 每次循环需要扫描 n 个顶点来找到
dist最小且未标记的顶点 - 外层循环执行 n 次,内层扫描 n 个顶点
- 时间复杂度:O(n²)
2. 更新邻接点 dist
for (int j = 0; j < n; j++) {
if (flag[j] == 0 && g[minn][j] < inf) {
dist[j] = mymin(dist[j], g[minn][j]);
}
}
- 每次加入新顶点后,扫描 n 个顶点更新 dist
- 外层循环 n 次,内层扫描 n 个顶点
- 时间复杂度:O(n²)
3. 整体复杂度
| 步骤 | 操作 | 复杂度 |
|---|---|---|
| 初始化 | 邻接矩阵和 dist 数组 | O(n²) |
| 主循环 | 每次找最小 dist(n 次循环 × n 次扫描) | O(n²) |
| 更新 dist | 每次更新邻接点(n 次循环 × n 次扫描) | O(n²) |
| 总计 | O(n²) |
- 对于邻接矩阵实现的 Prim,时间复杂度始终为 O(n²),与边数 m 无关
- 因此 Prim 算法特别适合稠密图(m 接近 n²)
4. 优化方向
| 优化方式 | 查找最小值 | 更新 | 总复杂度 |
|---|---|---|---|
| 邻接矩阵(当前实现) | O(n) 遍历 | O(n) 遍历 | O(n²) |
| 二叉堆 + 邻接表 | O(log n) | O(log n) | O((n+m) log n) |
| 斐波那契堆 + 邻接表 | O(log n) | O(1) 均摊 | O(n log n + m) |
对于稀疏图,使用二叉堆优化的 Prim(O((n+m) log n))通常优于邻接矩阵版本。
空间复杂度
- 邻接矩阵
g[n][n]:O(n²) - 距离数组
dist[n]:O(n) - 标记数组
flag[n]:O(n) - 总空间复杂度:O(n²)
两种算法对比
| 对比项 | Kruskal 算法 | Prim 算法 |
|---|---|---|
| 出发点 | 从边的角度出发 | 从顶点的角度出发 |
| 核心操作 | 选权值最小的边,用并查集判环 | 选离生成树最近的顶点加入 |
| 数据结构 | 边集数组 + 并查集 | 邻接矩阵 / 邻接表 |
| 时间复杂度 | O(m log m)(排序优化后) | O(n²)(邻接矩阵) |
| 空间复杂度 | O(m + n) | O(n²) |
| 适用场景 | 稀疏图(m 远小于 n²) | 稠密图(m 接近 n²) |
| 实现复杂度 | 较低(只需边排序 + 并查集) | 中等(需维护 dist 数组) |
如何选择?
图很稀疏(m ≈ n) → Kruskal 算法(O(m log m))
图很稠密(m ≈ n²) → Prim 算法(O(n²))
顶点数很小(n ≤ 200) → 两者均可,Prim 实现更直接
边权有负数 → Kruskal 可用,Prim 需谨慎
两种算法都是贪心算法的正确典范——Kruskal 在全局边集上贪心,Prim 在当前顶点集合的邻接边上贪心,两者均能得到全局最优解(最小生成树)。