Prim 算法的核心思想是“点扩张”。它从一个起点出发,像滚雪球一样维护一个已连通的点集 $S$,每次贪心地挑选连接 $S$ 与外部未选点集 $V-S$ 的最小权值边,拉入一个新点,直至所有点连通。
Prim 算法:从“点扩张”理解最小生成树
适合信奥 / CSP-J 后期 / CSP-S 入门的图论核心讲解版 · 先懂模型,再手推状态更新,最后读代码。
一、先回答问题
通过“修路连通村庄”的背景切入最小生成树(MST)概念;借由 5 点图互动模拟深刻体会割性质与过期边过滤;通过 tab 切换对比稀疏图版(优先队列)与稠密图版(邻接矩阵)模板,并掌握常见建模技巧。
二、核心观察
1. 为什么要选择“最便宜的跨割边”?(割性质)
如果我们将所有点切成两份,要让全图连通,这两份点之间必须且至少有一条边连接。既然必须连,为了让总权重最小,在这一刻选择最便宜的那条跨界边,绝对是全局最优的安全选择。这就是 Prim 的贪心正确性保障。
2. Prim 与 Dijkstra 的 dist 数组有什么本质区别?
虽然两者代码结构极其相似,但 dist[v] 的含义完全不同:
- Dijkstra 的 dist[v]:从起点到点 $v$ 的总路径长度(累加值)。
- Prim 的 dist[v]:点 $v$ 到当前生成树点集 S 的最短单步距离(不累加,只代表接入整棵树的边代价)。
3. 树不唯一,值唯一
当图中有相同权值的边时,选出来的最小生成树边集可能不唯一(比如三角环 1-2, 2-3, 3-1 权值均为 1,选哪两条都行),但是最小生成树的总权值和在同一个图中永远是唯一且确定的。
三、互动演示:手推 Prim 过程
下面我们用一个 5 个点、7 条边的无向图来演示。起点选为 1。你可以点击“下一步”来看清点是如何被一步步拉入生成树的,注意观察 vis 判断如何避开环(过滤过期边)。
四、自己选答案
在优先队列版 Prim 算法中,为什么从 PQ 弹出来的边对应的点还要进行 if (vis[u]) continue; 判断?
从“割性质”贪心策略来想,如果一个无向图中有若干条边的权值是相同的,下面哪个结论是正确的?
五、手推结果对比表
| 步骤 | 已选点集 S | 处理点 u | 确定树边与权值 | 此时优先队列 PQ 状态 | 说明 |
|---|---|---|---|---|---|
| 0 | {} | - | - | [(0, 1)] | 初始化起点 1 入队,代价值 0 |
| 1 | {1} | 1 | - | [(2, 2), (3, 3)] | 1号点出队。邻居 2(2)、3(3) 未访问,入队 |
| 2 | {1, 2} | 2 | 1-2 (权值 2) | [(1, 3), (3, 3), (4, 4)] | 弹出最小 (2, 2),连 1-2 边。更新邻居 3(1)、4(4) 入队 |
| 3 | {1, 2, 3} | 3 | 2-3 (权值 1) | [(3, 3), (4, 4), (6, 5)] | 弹出最小 (1, 3),连 2-3 边。更新邻居 5(6),3-4太长忽略 |
| 4 | {1, 2, 3} | 3 | - | [(4, 4), (6, 5)] | 弹出 (3, 3),但 vis[3] 已经是 true,这属于过期边,跳过 |
| 5 | {1, 2, 3, 4} | 4 | 2-4 (权值 4) | [(6, 5)] | 弹出最小 (4, 4),连 2-4 边。更新邻居 5(7)比已存6长,不入队 |
| 6 | {1, 2, 3, 4, 5} | 5 | 3-5 (权值 6) | [] | 弹出最小 (6, 5),连 3-5 边。所有点已连通,完成!和为13 |
六、C++14 参考代码
在信奥考试中,对于稀疏图(点多边少),我们常用“优先队列版”;而对于完全图或稠密图(边很多),“邻接矩阵版”在常数和代码书写上都更有优势。点击切换选项卡来查看并复制代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef pair<int, int> pii; // {边权, 目标点}
const ll INF = 1e18;
int main() {
// 提升 I/O 效率
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
vector<vector<pii>> g(n + 1);
for (int i = 0; i < m; i++) {
int u, v, w;
cin >> u >> v >> w;
// 无向图,双向建边
g[u].push_back({v, w});
g[v].push_back({u, w});
}
vector<bool> vis(n + 1, false);
// 优先队列声明:
// 第1参数:元素类型 pii 即 pair<int,int>
// 第2参数:底层容器 vector<pii>
// 第3参数:比较函数 greater<pii> 产生最小堆(小顶堆)
// 【记忆口诀】:C++ 优先队列中,比较函数返回 true 代表优先级低(在下面)。
// greater 在 a > b 时返回 true,所以大的数在下面,即小的数在最上面(小顶堆)。
// pair 比较时默认先比较 first 元素(边权)
priority_queue<pii, vector<pii>, greater<pii>> pq;
// 从 1 号点开始,起点接入树的代价为 0
pq.push({0, 1});
ll ans = 0; // 记录最小生成树总权值
int cnt = 0; // 记录已加入生成树的点数
while (!pq.empty()) {
int w = pq.top().first;
int u = pq.top().second;
pq.pop();
// 关键一步:如果该点已在树中,说明这条边是过期的,跳过防环
if (vis[u]) continue;
// 确认把 u 归入最小生成树点集
vis[u] = true;
ans += w;
cnt++;
// 遍历所有从 u 出发的边,尝试更新邻居
for (auto e : g[u]) {
int v = e.first;
int cost = e.second;
if (!vis[v]) {
pq.push({cost, v});
}
}
}
// 若连通的点数小于 n,说明图不连通,无法形成最小生成树
if (cnt < n) {
cout << "orz\n";
} else {
cout << ans << '\n';
}
return 0;
}
- 注意
vis[u]的标记时机,它只在点从堆中弹出且确定计入生成树时才置为true,而不是在被邻居推入堆时。这就保证了我们每次加进去的都是当前最短边。 - 关于
priority_queue<pii, vector<pii>, greater<pii>>声明中的三个参数:C++ 优先队列默认是大顶堆,若要使用小顶堆(让最小边最先弹出),必须显式写出这三个参数,并使用greater作为第三个参数。 - 【比较器记忆口诀】:在 C++ 优先队列的比较函数中,返回
true代表在下面(优先级低)。因为greater比较器在a > b时返回true,表示大数a优先级低、被放在下面,因此小数b就会在堆顶,形成小顶堆。这与std::sort(返回true在前面/左边)刚好相反!
七、洛谷经典例题精讲
在信奥图论题中,除了标准的模板题,还有许多以 MST 为底层的变形建模。点击下面各卡片可查看建模分析与 C++ 代码。
例题 1:洛谷 P3366【模板】最小生成树 (标准模板 & 连通性判断)
题目要点:给出一个无向图,输出最小生成树的总权值,如果图不连通则输出 orz。
建模解析:这是最基础的 MST。用稀疏图邻接表构建无向图,用优先队列实现 Prim,最后通过计数变量 cnt 判断是否加满了 n 个点。若 cnt < n 说明图是不连通的(即图中有部分村庄无法修路到达),输出 orz。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef pair<int, int> pii;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
vector<vector<pii>> g(n + 1);
for (int i = 0; i < m; i++) {
int x, y, z;
cin >> x >> y >> z;
g[x].push_back({y, z});
g[y].push_back({x, z});
}
vector<bool> vis(n + 1, false);
// 最小堆声明,利用 greater 使得较小值在堆顶
// 口诀:比较返回 true 代表在下面。a > b 为 true,大值在下面,即为小顶堆。
priority_queue<pii, vector<pii>, greater<pii>> pq;
pq.push({0, 1});
ll ans = 0;
int cnt = 0;
while (!pq.empty()) {
int w = pq.top().first;
int u = pq.top().second;
pq.pop();
if (vis[u]) continue;
vis[u] = true;
ans += w;
cnt++;
for (auto e : g[u]) {
int v = e.first;
int cost = e.second;
if (!vis[v]) {
pq.push({cost, v});
}
}
}
if (cnt < n) cout << "orz\n";
else cout << ans << '\n';
return 0;
}
例题 2:洛谷 P1546 最短网络 Agri-Net (矩阵读入 & 稠密图)
题目要点:给定 $N$ 个农场($N \le 100$),读入是一个 $N \times N$ 的连通矩阵,表示任意两个农场之间连接的光纤费用。求将所有农场连通的最少费用。
建模解析:点数很小(最大 100),但给的是完整的邻接矩阵,代表这是一个完全图(稠密图)。这种情况下使用邻接矩阵版 Prim ($O(N^2)$)是最简单、最不容易出错的选择,代码非常直观,免去了邻接表建边的繁琐步骤。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll INF = 1e18;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<vector<ll>> g(n + 1, vector<ll>(n + 1));
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
cin >> g[i][j];
}
}
vector<bool> vis(n + 1, false);
vector<ll> dist(n + 1, INF);
dist[1] = 0;
ll ans = 0;
for (int i = 1; i <= n; i++) {
int u = -1;
for (int j = 1; j <= n; j++) {
if (!vis[j] && (u == -1 || dist[j] < dist[u])) {
u = j;
}
}
vis[u] = true;
ans += dist[u];
for (int v = 1; v <= n; v++) {
if (!vis[v] && g[u][v] < dist[v]) {
dist[v] = g[u][v];
}
}
}
cout << ans << '\n';
return 0;
}
例题 3:洛谷 P2872 Building Roads S (坐标完全图 & 已有边权处理)
题目要点:给定平面上 $N$ 个村庄的坐标,任意两点之间都可以连边,边权为欧几里得距离。此外,图里已经有 $M$ 条修好的路,求再新建一部分路使得全部村庄连通的最小长度。
建模解析:
- 坐标点连边:每个村庄都可以与其他任意村庄连边,所以这是一个 $O(N^2)$ 的稠密完全图,推荐使用邻接矩阵版 Prim。边权可以通过两点坐标 $dis = \sqrt{(x_1-x_2)^2 + (y_1-y_2)^2}$ 计算。
- 已有道路处理:题目说已经有 $M$ 条路存在,不需要新增花费。因此,我们可以将这 $M$ 条已有道路的边权直接设为 0(即
g[u][v] = g[v][u] = 0),然后再运行标准的 MST 算法。这样,算法在扩张时会优先免费选用这些已有的道路!
#include <bits/stdc++.h>
using namespace std;
const double INF = 1e18;
struct Point {
double x, y;
};
// 计算两点之间的距离
double dis(Point a, Point b) {
double dx = a.x - b.x;
double dy = a.y - b.y;
return sqrt(dx * dx + dy * dy);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
vector<Point> p(n + 1);
for (int i = 1; i <= n; i++) {
cin >> p[i].x >> p[i].y;
}
// 用 INF 初始化邻接矩阵
vector<vector<double>> g(n + 1, vector<double>(n + 1, INF));
for (int i = 1; i <= n; i++) {
g[i][i] = 0;
}
// 1. 初始化任意两点间的距离为边权
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
g[i][j] = dis(p[i], p[j]);
}
}
// 2. 将已有道路的费用置为 0
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
g[u][v] = g[v][u] = 0;
}
vector<bool> vis(n + 1, false);
vector<double> dist(n + 1, INF);
dist[1] = 0;
double ans = 0;
// 3. 运行邻接矩阵版的 Prim 算法
for (int i = 1; i <= n; i++) {
int u = -1;
for (int j = 1; j <= n; j++) {
if (!vis[j] && (u == -1 || dist[j] < dist[u])) {
u = j;
}
}
vis[u] = true;
ans += dist[u];
for (int v = 1; v <= n; v++) {
if (!vis[v] && g[u][v] < dist[v]) {
dist[v] = g[u][v];
}
}
}
// 保留两位小数输出
cout << fixed << setprecision(2) << ans << '\n';
return 0;
}
八、避坑指南:信奥中常见的 MST 错误
无向图忘记双向建边
在邻接表建边时,如果是无向图,一定要正反加两次边!只写 g[u].push_back({v, w}) 不写反向边会导致图的连通方向受阻,无法连通。
邻接矩阵的重边覆盖写错
在用邻接矩阵存图时(例如有平行重边),读入数据必须使用 g[u][v] = min(g[u][v], w),否则直接赋值 g[u][v] = w 会导致之前更小的边被大边给覆盖了,从而算出错误的树权值。
连通性判断被忽略
若原图不连通,图里是不会存在最小生成树的。使用优先队列版 Prim 时,必须使用 cnt 计数,如果最后 cnt < n,代表并没有把全部点接进来,一定要特殊输出 orz 或 -1,不能直接输出 ans。
和 Dijkstra 算法的更新写混
这是最严重的逻辑错误。在 Prim 松弛邻居时,更新条件是:g[u][v] < dist[v](邻居到最新加入点 $u$ 的单步距离是否小于其到生成树的距离);而 Dijkstra 是:dist[u] + g[u][v] < dist[v](累加值松弛)。写错会导致算法退化成错误的逻辑。
九、课后巩固与思考练习
完成本讲精讲的三道核心例题:
重点是能盲打优先队列和邻接矩阵两套模板。尝试利用本节所学进行建模拓展:
- 超级源点模型:P1550 Watering Hole G(打井与铺管道连通,思考如何虚构一个 0 号井源点)。
- 最小生成森林模型:P1195 口袋的天空(将村庄连成 $K$ 个部落,思考为什么只需要选 $N-K$ 条边)。