← 目录 / 算法文档 · 模块十五 图论基础 / 15.5 Prim 最小生成树

15.5 Prim 最小生成树

同样是求最小生成树,换一个角度:不给边排序,而是从一个点出发,像树在生长一样,每次贪心地吞并离自己最近的那个新点。

本页目录
① Prim 和 Kruskal 的视角差异:从边出发,还是从点出发

15.4 节的 Kruskal 站在""的视角:把所有边排好序,一条条考察要不要加入。Prim 算法站在""的视角:从某一个点开始,维护一棵正在生长的树,每一步都贪心地选一条连接"树内"和"树外"、权值最小的边,把树外的那个点纳入树中——像细胞分裂一样,树每一步只长大一个点,直到把所有点都纳入进来。

这个"每次贪心地吞并最近的新点"的过程,和 15.3 节 Dijkstra 的操作方式几乎一模一样——区别只在于贪心的依据:Dijkstra 比较的是"离起点的累计距离",Prim 比较的是"离当前树最近的单条边权值"。

② 核心思想:让树一点点长大
步骤做什么
① 初始化随便挑一个起点纳入树中,其余点都在树外
② 找候选边在所有"一端在树内、一端在树外"的边里,找权值最小的那一条
③ 扩展把这条边和它树外的那个端点,一起纳入树中
④ 重复重复 ②③,直到所有点都被纳入树中(选够 V-1 条边)
③ 图解:从 A 出发,树是怎么长大的

还是 15.1~15.4 节的示例图(A-B=4A-C=1B-C=2B-D=5C-D=8),从 A 出发跑一遍 Prim:

树每一步吞并一个新点
当前树树内→树外的候选边选中最小的一条树扩展为
{A}A-B(4),A-C(1)A-C(1){A, C}
{A, C}A-B(4),C-B(2),C-D(8)C-B(2){A, B, C}
{A, B, C}B-D(5),C-D(8)B-D(5){A, B, C, D}
选中的三条边依然是 A-C(1)、B-C(2)、B-D(5),总权值 1+2+5=8——和 15.4 节 Kruskal 算出的结果完全一样(这并非巧合:只要图连通,最小生成树的总权值是唯一确定的,Kruskal、Prim 只是用不同顺序把它找出来)。留意第二步:A-B(4) 一直摆在候选里,但因为后来出现了更便宜的 C-B(2),它从未被选中——这也是"贪心"在每一步都做局部最优选择的体现。
④ 完整代码
C++ · Prim 最小生成树(优先队列实现)
1const int INF = 0x3f3f3f3f;
2vector<pair<int,int>> adj[MAXN]; // adj[u]:(邻居, 边权)
3int key_[MAXN]; // key_[v]:目前"树到 v"最便宜的单条边权值
4bool inTree[MAXN]; // v 是否已经被纳入树中
5
6int Prim(int n) // 从 1 号点出发,返回最小生成树总权值(不连通返回 -1)
7{
8 fill(key_ + 1, key_ + 1 + n, INF);
9 priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;
10 key_[1] = 0; pq.push({0, 1}); // 起点距离"树"的代价记为 0
11
12 int total = 0, cnt = 0;
13 while (!pq.empty())
14 {
15 auto [d, u] = pq.top(); pq.pop();
16 if (inTree[u]) continue; // ★ u 已经在树里了,这条是过时记录,跳过
17 inTree[u] = true;
18 total += d; cnt++; // 把"吞并 u 花的这条边权值"计入总权值
19
20 for (auto [v, w] : adj[u])
21 {
22 if (!inTree[v] && w < key_[v]) // v 还在树外,且这条边比 v 已知最便宜的边还便宜
23 {
24 key_[v] = w;
25 pq.push({w, v});
26 }
27 }
28 }
29 return cnt == n ? total : -1;
30}
💡
和 Dijkstra 代码几乎一样,但有一处关键区别:第 22 行的判断是 w < key_[v]——只看这一条边本身的权值;而 15.3 节 Dijkstra 第 18 行判断的是 dist_[u] + w < dist_[v]——看的是累计距离。这正是 ① 提到的"单条边权值"和"累计距离"的区别,也是 Prim 和 Dijkstra 唯一的本质差异。另外这里用了显式的 inTree[] 数组(而不是 15.3 节 Dijkstra 那种"比较 ddist_[u]"的过时判断),两种写法都能达到同样的"跳过重复处理"效果,属于同一个技巧的不同实现方式。
⑤ Prim vs Kruskal:该选哪个
对比项Kruskal(15.4 节)Prim(本节)
贪心视角边——排序后逐条考察点——树逐步扩展
依赖的数据结构并查集优先队列(或邻接矩阵朴素实现)
时间复杂度O(E log E),只和边数有关堆优化 O(E log V);邻接矩阵朴素版 O(V²),只和点数有关
适合场景稀疏图(边数远小于 V²)稠密图(边数接近 V²)用朴素版更快
🎯
两种算法求出的最小生成树总权值一定相同(只是选出的具体边在有多种最优方案时可能不同),选哪个更多是效率和实现习惯的权衡:图比较"稀疏"(点多边少)时 Kruskal 通常更简单直接;图比较"稠密"(接近完全图)时,Prim 的邻接矩阵朴素版(每轮 O(V) 扫描找最小候选边,共 O(V) 轮,总 O(V²))反而比"边数很多导致排序很慢"的 Kruskal 更划算。
⑥ 常见陷阱
把 Prim 的"边权比较"错写成 Dijkstra 的"累计距离比较":这是从 Dijkstra 代码直接照抄改写时最容易犯的错误——第 22 行必须是 w < key_[v](只看这条边本身),如果照抄 Dijkstra 写成 key_[u] + w < key_[v],算出来的就不再是最小生成树,而是变成了一个和 Dijkstra 功能重复、意义不明的东西。
原图不连通时,树长不到所有点:和 Kruskal 一样,如果图本身分成几个互不相连的部分,从某一个点出发的 Prim 永远无法触及另一部分的点,优先队列会提前空掉,纳入树中的点数 cnt 会小于 n。第 29 行用 cnt == n 判断这种情况,返回 -1 表示生成树不存在。
稠密图上还在死用优先队列版本,效率不如朴素版:本节给出的是堆优化写法,适合稀疏图;如果图接近完全图(边数接近 ),堆里会塞进大量边,O(E log V) 里的 E 趋近 ,反而不如"不用堆,每轮直接 O(V) 线性扫描找最小候选点"的朴素版 O(V²) 实现划算——这是稠密图场景下值得注意的优化方向。
🏆
模块小结:15.1~15.5 节把图论基础走了一遍:图的两种存储方式 → 任意两点最短路(Floyd)→ 单源最短路(Dijkstra)→ 最小生成树的两种做法(Kruskal、Prim)。15.6 节的拓扑排序会换一类完全不同的问题——不再关心"距离"或"权值之和",而是关心一张有向无环图里,一系列有先后依赖关系的任务,应该按什么顺序执行。