目录
[二、Kruskal 算法("边"贪心,稀疏图首选)](#二、Kruskal 算法(“边”贪心,稀疏图首选))
[1. 核心思想](#1. 核心思想)
[2. 算法步骤](#2. 算法步骤)
[3. 完整代码 + 逐行注释](#3. 完整代码 + 逐行注释)
[4. 算法优缺点](#4. 算法优缺点)
[三、Prim 算法("点"贪心,稠密图首选)](#三、Prim 算法(“点”贪心,稠密图首选))
[1. 核心思想](#1. 核心思想)
[2. 算法步骤](#2. 算法步骤)
[3. 完整代码 + 逐行注释](#3. 完整代码 + 逐行注释)
[4. 算法优缺点](#4. 算法优缺点)
[四、两大算法核心对比(面试 / 做题必背)](#四、两大算法核心对比(面试 / 做题必背))
最小生成树(Minimum Spanning Tree,MST)是图论中最经典的算法之一,核心作用是:在一个带权无向连通图中,选出 n-1 条边,连接所有 n 个节点,且总边权最小(不形成环)。
本文将带你吃透最小生成树的两大核心算法:
✅ Kruskal 算法(并查集 + 贪心,适合稀疏图)
✅ Prim 算法(贪心 + 遍历,适合稠密图)
附带可直接运行的 C++ 模板 + 详细注释 + 场景对比,新手也能轻松掌握!
一、前置知识:什么是最小生成树?
举个生活例子:你要给 n 个村庄修公路,任意两个村庄之间修公路的成本不同,要求所有村庄连通 、总修路成本最低 、不修环路 (浪费钱),最终修出来的公路网,就是这个图的最小生成树。
核心性质:
- 包含图中所有 n 个节点
- 有且仅有n-1 条边
- 总边权最小
- 无环、连通
这也是最小生成树名字的由来:只需保证联通无需任意两点间都有路(树),在这基础上选择成本最低的方案(最小)
但是要注意在实际应用中,生成树是成本最小不代表最短,在实际中我们要兼顾路程和成本,比如贵州花江峡谷大桥可能不是联通两地成本最低的方案,但是建造之后所带来的效益却是远超其他方案
算法不要死学,学的是计算机思维
二、Kruskal 算法("边"贪心,稀疏图首选)
1. 核心思想
对边进行操作,图中的边权最小的边定然是生成树的一条枝干,这便是 Kruskal 算法最核心的贪心思想,对边从小到大遍历,只要边所连的两点不在同一个联通块,就保留这条边,最终选择n-1条边(节点数为n的树边为n-1)
一句话总结:小边优先,并查集判环
2. 算法步骤
- 把图中所有边按权值升序排列(使用优先队列贼方便)
- 初始化并查集(每个节点自己是一个连通块)
- 遍历排序后的边:
- 若边的两个端点不属于同一连通块 → 选中这条边,合并连通块
- 若属于同一连通块 → 跳过(会成环)
- 选中 n-1 条边时,结束算法
3. 完整代码 + 逐行注释
P3366 【模板】最小生成树
https://www.luogu.com.cn/problem/P3366
cpp
#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 2e6 + 10;
const int inf = 2147483647;
// 边的结构体:存储两个端点+边权
struct edge {
int x, y, z;
};
// 优先队列比较器:小顶堆(边权小的优先出队)
struct cmp {
bool operator()(const edge &e1, const edge &e2) {
return e1.z > e2.z;
}
};
int fa[N]; // 并查集父节点数组
int n, m; // n:节点数,m:边数
int sum, cnt; // sum:最小生成树总权值,cnt:已选边数
priority_queue<edge, vector<edge>, cmp> q; // 存储所有边
// 并查集初始化:每个节点的父节点是自己
void init(){
for(int i = 0; i <= n; i++) fa[i] = i;
}
// 并查集查找
int get(int x){
return fa[x] = (fa[x] == x ? x : get(fa[x]));
}
// 合并两个连通块,a挂在b上
void merge(int a, int b){
fa[get(a)] = get(b);
}
// Kruskal算法核心
void kruskal() {
while (!q.empty() && cnt < n-1) {
edge t = q.top(); q.pop(); // 取出当前最小边
int x = t.x, y = t.y, z = t.z;
// 两个节点不在同一连通块 → 选中这条边
if (get(x) != get(y)) {
sum += z; // 累加总权值
merge(x, y); // 合并连通块
cnt++; // 已选边数+1
}
}
// 最终判断:是否连通(选够n-1条边)
if (cnt == n-1) cout << sum << endl;
else cout << "orz" << endl; // 图不连通,无生成树
}
void solve() {
cin >> n >> m;
init();
sum = 0, cnt = 0; // 初始化变量
for (int i = 0; i < m; i++) {
int x, y, z;
cin >> x >> y >> z;
q.push({x, y, z}); // 边存入优先队列
}
kruskal();
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
solve();
return 0;
}
4. 算法优缺点
通过上面的讲解大家应该看出来了,Kruskal 算法一直都是在对边进行操作,故而:
✅ 优点:代码简洁、易理解、稀疏图效率高(边少的图)
❌ 缺点:需要存储所有边,稠密图(边多)效率略低
时间复杂度:O(mlogm)(m 为边数,主要耗时在排序)
三、Prim 算法("点"贪心,稠密图首选)
1. 核心思想
核心贪心思想和 Kruskal 算法差不多,只不过Prim算法是对点进行操作,Prim需要抽象出两个集合,集合1:已选节点集合(也就是已经确定是树的一部分),集合2:未选节点集合(待成树),每次选2到1的最小边权,将对应节点加入集合,直到所有节点都被选中。我们不用在意具体两点间的边权,考虑的是点到集合1的最短距离(看后面代码理解)
一句话总结:点集扩张,每次选最短跨集边。
2. 算法步骤
- 初始化:选定一个起点(如节点 1),标记为已选
- 维护数组
d[]:d[i]表示节点 i 到已选点集的最小距离 - 循环 n-1 次(需要选 n-1 条边):
- 找到未选节点中
d[]最小的节点,加入已选集合 - 累加边权,更新其他未选节点的
d[]值
- 找到未选节点中
- 所有节点加入后,结束算法
3. 完整代码 + 逐行注释
P3366 【模板】最小生成树
https://www.luogu.com.cn/problem/P3366朴素版
cpp
#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 2e6 + 10;
const int inf = 2147483647;
// 邻接表节点:存储终点+边权
struct node {
int u, w;
};
bool st[N]; // 标记节点是否已加入生成树
vector<node> mp[N]; // 邻接表:存储图
int d[N]; // d[i]:节点i到已选点集的最小距离
int n, m, sum; // sum:最小生成树总权值
// Prim算法核心
void prim() {
d[1] = 0; // 从节点1开始,初始距离为0
st[1] = true; // 标记节点1已选
// 初始化:起点的邻接节点距离
for (auto x : mp[1]) d[x.u] = min(d[x.u], x.w);
// 选n-1条边,循环n-1次
for (int i = 2; i <= n; i++) {
int mn = inf, xb = -1;
// 找到未选节点中,d[]最小的节点
for (int j = 2; j <= n; j++) {
if (!st[j] && d[j] < mn) {
mn = d[j];
xb = j;
}
}
// 无符合条件的节点 → 图不连通,直接结束即可!
if (xb == -1) {
sum = inf;
return;
}
st[xb] = true; // 标记节点已选
sum += mn; // 累加总权值
// 更新邻接节点的最小距离
for (auto x : mp[xb]) {
d[x.u] = min(d[x.u], x.w);
}
}
}
void solve() {
cin >> n >> m;
for (int i = 0; i < m; i++) {
int x, y, z;
cin >> x >> y >> z;
mp[x].push_back({y, z}); // 无向图,双向存边
mp[y].push_back({x, z});
}
// 初始化距离数组为无穷大
for (int i = 1; i <= n; i++) d[i] = inf;
sum = 0;
prim();
if (sum == inf) cout << "orz" << endl;
else cout << sum << endl;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
solve();
return 0;
}
堆优化版
cpp
#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 2e6 + 10;
const int inf = 2147483647; // 无穷大
// 邻接表存储边:u是终点,w是边权
struct node {
int u, w;
};
// 堆节点:存储【当前节点到生成树的最小距离】+【节点编号】
struct HeapNode {
int dis, id;
// 小根堆重载:让优先队列从小到大弹出(距离越小越优先)
bool operator<(const HeapNode &other) const {
return dis > other.dis;
}
};
bool st[N]; // 标记节点是否已经加入最小生成树
vector<node> mp[N];// 邻接表存图
int d[N]; // d[i]:节点i到已选生成树的最小边权
int n, m, sum; // n点数,m边数,sum生成树总权值
void prim() {
// 1. 初始化:所有节点到生成树的距离设为无穷大
for (int i = 1; i <= n; i++) d[i] = inf;
// 初始化所有节点未被访问
memset(st, 0, sizeof st);
sum = 0; // 总权值归零
d[1] = 0; // 从 1 号节点开始,初始距离为 0
// 小根堆:每次取出【距离最小】的节点
priority_queue<HeapNode> q;
q.push({0, 1}); // 将起点(距离0,编号1)推入堆
int cnt = 0; // 记录已经加入生成树的节点数量
// 2. 开始循环:堆不为空就继续
while (!q.empty()) {
// 取出堆顶:当前距离最小的节点
auto now = q.top();
q.pop();
int u = now.id; // 当前节点编号
int dis = now.dis;// 当前节点到生成树的最小距离
// 3. 重要剪枝:如果这个节点已经加入过生成树,直接跳过(堆里的旧数据)
if (st[u]) continue;
// 4. 标记该节点已加入生成树
st[u] = true;
sum += dis; // 累加这条边的权值
cnt++; // 已加入节点数 +1
// 5. 遍历当前节点的所有邻边(松弛操作)
for (auto edge : mp[u]) {
int v = edge.u; // 邻接点编号
int w = edge.w; // 这条边的权值
// 6. 如果邻接点没被选过,且新边权更小 → 更新距离
if (!st[v] && d[v] > w) {
d[v] = w; // 更新最小距离
q.push({d[v], v}); // 新状态推入堆
}
}
}
// 7. 最终判断:如果加入的节点数 != 总点数 → 图不连通
if (cnt != n) sum = inf;
}
void solve() {
cin >> n >> m;
// 读入 m 条无向边,存入邻接表
for (int i = 0; i < m; i++) {
int x, y, z;
cin >> x >> y >> z;
mp[x].push_back({y, z});
mp[y].push_back({x, z});
}
// 执行堆优化 Prim
prim();
// 输出答案:不连通输出 orz,否则输出总权值
if (sum == inf) cout << "orz\n";
else cout << sum << "\n";
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
solve();
return 0;
}
4. 算法优缺点
与Kruskal 算法一直都是在对边进行操作相对Prim一直在对点进行操作,故而:
✅ 优点:稠密图效率极高(边多、节点少的图),无需存储所有边
❌ 缺点:朴素版代码理解难度略高于 Kruskal
时间复杂度:朴素版O(n2),堆优化版O(mlogn)(n 为节点数)
四、两大算法核心对比(面试 / 做题必背)
| 维度 | Kruskal 算法 | Prim 算法 |
|---|---|---|
| 核心思路 | 边贪心,从小到大选边 | 点贪心,扩张点集 |
| 数据结构 | 优先队列 / 排序 + 并查集 | 邻接表 + 距离数组 |
| 判环方式 | 并查集(判断连通块) | 无需判环(点集天然无环) |
| 适用场景 | 稀疏图(边少,m 远小于 n²) | 稠密图(边多,m 接近 n²) |
| 代码难度 | 简单,新手首选 | 中等 |
| 时间复杂度 | O(mlogm) | 朴素版O(n2) |
五、做题小技巧
- 稀疏图:无脑用 Kruskal(代码短,不易错)
- 稠密图:用 Prim 朴素版(效率拉满)
- 数据范围:节点数≤1e4 用 Prim,边数≤1e6 用 Kruskal
六、总结
最小生成树的本质就是贪心:
- Kruskal:选最小的边,不环就留,靠并查集实现;
- Prim:扩最小的点,逐步连通,靠距离数组实现。
关键点回顾
- 最小生成树:连通所有节点、n-1 条边、总权最小、无环
- Kruskal:边排序 + 并查集,稀疏图首选,O(mlogm)
- Prim:点集扩张 + 距离数组,稠密图首选,O(n2)
- 核心都是贪心,根据图的疏密选择算法即可
本篇文章主要介绍两个最小生成树算法,里面有一些比如优先队列的重载并没有讲,后续还会写专门介绍优先队列使用方法