☰
说人话的Prim算法教程:从零手写最小生成树
2026/10/14 14:40:34 网站建设 项目流程

1. 为什么我要写一份“说人话”的Prim算法教程

最小生成树这个词,很多刚接触图论的朋友第一次听到时,脑子里冒出来的画面大概是:一堆点和线,要从中挑出几条边把所有点连起来,还得让总长度最短。这个直觉其实已经抓住了核心,但真到写代码的时候,问题就来了——网上的教程要么一上来就是反证法证明,要么代码写得像天书,变量名全是lowcost、adjvex、closest,看三行就劝退。

我自己当年学Prim算法的时候,踩过的坑相当典型。第一次看的是某本经典教材,伪代码写得倒是简洁,但里面有个key数组的更新逻辑我死活想不通为什么是对的;第二次看的是某个博客,代码能跑,但注释只有“初始化”“更新”几个字,等于没写;第三次干脆自己拿纸笔画了一遍,才终于把“已选集合”和“候选边”这两个概念彻底理顺。

这篇教程就是把我当年希望有人能讲给我听的东西,完整地写出来。它适合三类人:正在准备数据结构考试的学生、刚刷到图论题但不知道从哪下手的算法初学者、以及写过Prim但每次都要翻旧代码才能想起来的开发者。我会从最朴素的生活场景切入,把算法拆成“每一步在做什么”和“为什么这样做是对的”两条线,代码部分给出一份注释密度极高的实现,每一行都告诉你它在干嘛。读完你至少能做到:不看书,从零手写一个正确的Prim,并且能跟别人讲清楚它和Kruskal的区别在哪。

2. 先把问题说清楚:最小生成树到底在解决什么

2.1 一个修路的例子比任何定义都好懂

假设有六个村子,分别叫A、B、C、D、E、F。现在要修路把它们连通起来,任意两个村子之间修路的造价不一样,因为地形不同。你的任务是:选出一组路,使得所有村子都能互相到达,同时总造价最低。

这个场景里,村子就是图的顶点,可以修的路就是边,造价就是边的权值。“所有村子都能互相到达”意味着选出来的边必须构成一个连通图;“总造价最低”意味着在所有连通方案里找权值和最小的那个。而一个连通且边数最少的图,就是树。所以这个问题就叫最小生成树(Minimum Spanning Tree,MST)。

这里有个容易被忽略的点:最小生成树不一定唯一。如果图里存在权值相同的边,可能有好几棵不同的树总权值一样。但不管哪一棵,总权值都是最小的,这一点是确定的。Prim算法求出来的是其中一棵,不保证是“唯一正确”的那棵,因为本来就没有唯一。

2.2 生成树的三个硬性条件

在动手写算法之前,先把约束条件列清楚,后面判断代码对不对就靠这三条:

  • 包含所有顶点:图里有n个顶点,生成树就必须包含这n个顶点,一个都不能少。
  • 恰好n-1条边:树的性质决定了,n个顶点的连通无环图恰好有n-1条边。少一条就不连通,多一条就一定有环。
  • 没有环:这是树和普通连通图的本质区别。有环意味着存在冗余的边,去掉环上任意一条边仍然连通,那总权值只会更小,所以最小生成树里不可能有环。

Prim算法的所有操作,本质上都是在满足这三个条件的前提下,一步步把边选出来。

2.3 Prim和Kruskal的分工逻辑

图论里求最小生成树有两个经典算法:Prim和Kruskal。很多人学完两个之后会混淆,不知道该用哪个。我用一句话概括它们的核心区别:

Prim是“从一个点开始,不断把最近的邻居拉进来”;Kruskal是“把所有边按长短排好队,从最短的开始挑,不形成环就要”。

Prim的关注点在顶点,它维护一个“已经连通的区域”,每次从区域外找一个离区域最近的点加进来。Kruskal的关注点在边,它把所有边排序后逐条考察,用并查集判断这条边的两个端点是否已经连通。

这就导致它们的适用场景不同。Prim的时间复杂度在朴素实现下是O(n²),用优先队列优化后是O(E log V),适合稠密图(边多),因为它是按顶点扩展的,边多反而效率稳定。Kruskal的时间复杂度主要花在排序上,是O(E log E),适合稀疏图(边少),因为边少排序快。这个选择逻辑后面还会展开讲。

3. Prim算法的核心思路拆解

3.1 用“滚雪球”来理解贪心策略

Prim算法的思路可以用滚雪球来类比。想象你站在雪地里,手里捏了一个小雪球(初始时只包含一个顶点),然后你不断把周围最近的雪往雪球上粘(每次选一条连接雪球和外部的最短边),雪球越滚越大,直到把所有雪都粘进来。

这个过程中有两个关键角色:

  • 已选集合:雪球本身,里面是已经连通的顶点。
  • 候选边集合:雪球表面所有能粘的雪,也就是一端在雪球内、一端在雪球外的边。

每一步的操作就是:从候选边里挑最短的那条,把它和它连接的外部顶点一起并入雪球。重复n-1次,雪球就包含了所有顶点。

3.2 为什么贪心能保证全局最优

这是Prim算法最核心也最容易被跳过的问题。每次只挑当前最短的边,凭什么最后得到的是全局最小?

这里用到一个叫切割定理(Cut Property)的性质。把顶点分成两个不相交的集合S和V-S,横跨这两个集合的边里,权值最小的那条一定属于某棵最小生成树。

用反证法理解:假设横跨切割的最小边是e,如果某棵最小生成树T不包含e,那把e加进T会形成一个环。这个环上必然还有另一条横跨切割的边e',且e'的权值不小于e(因为e是最小的)。把e'去掉换成e,得到的新树权值不会更大,仍然是棵最小生成树。所以一定存在一棵最小生成树包含e。

Prim算法每一步选的正是“已选集合”和“未选集合”之间权值最小的边,所以每一步选的边都安全,最终结果必然是最小生成树。这个证明不需要背,但理解了之后,写代码时心里会踏实很多。

3.3 朴素实现和优先队列优化的取舍

Prim有两种常见实现方式,选择哪种取决于图的规模和稠密程度。

朴素实现维护一个dist数组,记录每个未选顶点到已选集合的最短距离。每轮遍历所有未选顶点找最小值,然后更新受影响的邻居。时间复杂度O(n²),空间复杂度O(n)。优点是代码简单、常数小、不需要额外数据结构;缺点是顶点多的时候慢。

优先队列优化把候选边放进小根堆,每次弹出最短的。时间复杂度O(E log V)。优点是稀疏图上快很多;缺点是需要处理“过期边”(堆里可能存着已经失效的旧距离),代码复杂度上升。

我的建议是:如果是考试或面试手写,写朴素版就够了,逻辑清晰不容易出错;如果是实际工程中处理大规模稀疏图,用优先队列版。这篇教程两种都会给,但重点讲朴素版,因为它是理解算法本质的最佳入口。

4. 手把手实现:从数据结构到完整代码

4.1 图的存储方式选择

Prim算法需要频繁查询“某个顶点到其他顶点的边权”,所以存储结构直接影响实现难度。

邻接矩阵用一个二维数组graph[i][j]表示顶点i到j的边权,不相连就设为一个极大值(比如无穷大)。优点是查询任意两点间边权是O(1),代码直观;缺点是空间O(n²),稀疏图浪费严重。

邻接表用数组加链表(或vector数组)存储每个顶点的邻居。优点是空间O(V+E),稀疏图友好;缺点是查询两点间是否有边需要遍历。

对于Prim的朴素实现,邻接矩阵是更自然的选择,因为算法本身就要反复扫描“所有未选顶点到已选集合的距离”,矩阵的随机访问特性正好匹配。下面的代码就用邻接矩阵。

4.2 核心数组的含义与初始化

朴素Prim需要三个辅助数组,这是整个算法最容易搞混的地方,我逐个解释:

  • dist[i]:顶点i到已选集合的最短距离。注意不是到某个特定点的距离,是到整个集合的。初始时,只有起点在集合里,所以dist[i]就是起点到i的边权。
  • visited[i]:布尔值,标记顶点i是否已经加入已选集合。
  • parent[i]:记录顶点i是通过哪条边被加入的,也就是它的“父节点”。这个数组不是算法运行必需的,但用来输出最小生成树的具体边非常方便。

初始化的逻辑:选一个起点(通常选0号顶点),把dist设为起点到各点的边权,visited全为false,parent全设为起点。然后把起点标记为已访问。

4.3 完整代码与逐行注释

下面是朴素Prim的完整实现,用C++写成,但逻辑对任何语言都通用。注释我写得非常细,每一行都说明意图。

#include <iostream> #include <vector> #include <climits> using namespace std; const int INF = INT_MAX; // 用最大整数表示“不可达” // graph是邻接矩阵,n是顶点数 void prim(vector<vector<int>>& graph, int n) { vector<int> dist(n, INF); // 各顶点到已选集合的最短距离 vector<bool> visited(n, false); // 是否已加入已选集合 vector<int> parent(n, -1); // 记录每个顶点的父节点,用于输出边 // 从顶点0开始,初始化dist为0到各点的边权 dist[0] = 0; parent[0] = -1; // 起点没有父节点 int totalWeight = 0; // 最小生成树的总权值 // 一共要选n个顶点,循环n次 for (int count = 0; count < n; count++) { // 第一步:在未访问顶点中找dist最小的 int u = -1; int minDist = INF; for (int i = 0; i < n; i++) { if (!visited[i] && dist[i] < minDist) { minDist = dist[i]; u = i; } } // 如果找不到可达的顶点,说明图不连通 if (u == -1) { cout << "图不连通,无法生成最小生成树" << endl; return; } // 第二步:把u加入已选集合 visited[u] = true; totalWeight += minDist; // 第三步:用u更新其未访问邻居的dist for (int v = 0; v < n; v++) { // 如果v未访问,且u到v有边,且这条边比v当前记录的dist更短 if (!visited[v] && graph[u][v] != INF && graph[u][v] < dist[v]) { dist[v] = graph[u][v]; // 更新最短距离 parent[v] = u; // 记录v是从u连过来的 } } } // 输出结果 cout << "最小生成树总权值: " << totalWeight << endl; cout << "选中的边:" << endl; for (int i = 1; i < n; i++) { if (parent[i] != -1) { cout << parent[i] << " -- " << i << " 权值: " << graph[parent[i]][i] << endl; } } }

这段代码里,外层循环执行n次,每次选一个顶点加入集合。第一次选的是起点0(因为dist[0]=0最小),后面每次选当前离集合最近的顶点。totalWeight累加的是每次选中的minDist,也就是每条被选边的权值。

4.4 手动模拟一遍执行过程

光看代码容易晕,拿一个具体的小图走一遍。假设有5个顶点,邻接矩阵如下(INF表示不可达):

01234
002INF6INF
120385
2INF30INF7
368INF09
4INF5790

初始:dist = [0, 2, INF, 6, INF],visited全false。

第1轮:找未访问中dist最小的,是顶点0(dist=0)。标记visited[0]=true,totalWeight+=0。用0更新邻居:顶点1的dist从2保持(2<INF),顶点3的dist从6保持。dist = [0, 2, INF, 6, INF]。

第2轮:未访问中最小的是顶点1(dist=2)。标记visited[1]=true,totalWeight+=2。用1更新邻居:顶点2的dist从INF变成3,顶点3的dist从6变成min(6,8)=6不变,顶点4的dist从INF变成5。dist = [0, 2, 3, 6, 5]。

第3轮:未访问中最小的是顶点2(dist=3)。标记visited[2]=true,totalWeight+=3。用2更新邻居:顶点4的dist从5变成min(5,7)=5不变。dist = [0, 2, 3, 6, 5]。

第4轮:未访问中最小的是顶点4(dist=5)。标记visited[4]=true,totalWeight+=5。用4更新邻居:顶点3的dist从6变成min(6,9)=6不变。dist = [0, 2, 3, 6, 5]。

第5轮:只剩顶点3(dist=6)。标记visited[3]=true,totalWeight+=6。

最终总权值 = 0+2+3+5+6 = 16。选中的边是:0-1(2)、1-2(3)、1-4(5)、0-3(6)。你可以验证一下,这四条边确实连通了所有5个顶点,且没有环。

5. 优先队列优化版:稀疏图上的加速方案

5.1 朴素版的性能瓶颈在哪

朴素版每轮都要遍历所有顶点找最小值,这是O(n)的操作,执行n轮就是O(n²)。同时每轮还要遍历u的所有邻居做更新,总共是O(E)。所以总复杂度是O(n² + E),通常简写为O(n²)。

当n=1000时,n²是一百万,还能接受;当n=100000时,n²就是一百亿,直接超时。问题就出在“找最小值”这个操作上——它本可以用堆来加速。

5.2 用优先队列替代线性扫描

优先队列(小根堆)可以在O(log n)时间内取出最小值。我们把候选边以(距离, 顶点)的形式放进堆里,每次弹出距离最小的。但要注意:堆里可能存在同一个顶点的多条记录(因为它的dist被更新过多次),弹出时如果该顶点已经访问过,直接跳过。

#include <iostream> #include <vector> #include <queue> #include <climits> using namespace std; const int INF = INT_MAX; void primPQ(vector<vector<pair<int,int>>>& adj, int n) { vector<int> dist(n, INF); vector<bool> visited(n, false); // 小根堆,存储 (距离, 顶点) priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq; dist[0] = 0; pq.push({0, 0}); int totalWeight = 0; int edgeCount = 0; while (!pq.empty()) { auto [d, u] = pq.top(); pq.pop(); // 过期记录,跳过 if (visited[u]) continue; visited[u] = true; totalWeight += d; edgeCount++; // 遍历u的所有邻居 for (auto [v, w] : adj[u]) { if (!visited[v] && w < dist[v]) { dist[v] = w; pq.push({w, v}); } } } if (edgeCount != n) { cout << "图不连通" << endl; return; } cout << "最小生成树总权值: " << totalWeight << endl; }

这里用的是邻接表,因为优先队列版通常面向稀疏图。greater<>让堆按距离从小到大排序。visited[u]的判断是处理过期记录的关键,没有这一步会重复计算。

5.3 两种实现的对比与选择建议

对比项朴素版优先队列版
时间复杂度O(n²)O(E log V)
空间复杂度O(n²)(邻接矩阵)O(V+E)(邻接表)
适用图类型稠密图稀疏图
代码复杂度低中
是否需要处理过期记录否是
推荐场景考试、面试、小规模工程、大规模稀疏图

选择逻辑很简单:边数E接近n²就用朴素版,边数E远小于n²就用优先队列版。如果拿不准,先看数据范围,n≤1000基本随便写,n≥10000就得考虑优化了。

6. 常见问题与排查技巧实录

6.1 为什么我的Prim求出来不是最小值

这是新手最常遇到的问题,原因通常有三个。

第一个原因是dist数组更新时比较对象搞错了。更新dist[v]时,比较的应该是graph[u][v]和dist[v],而不是graph[u][v]和某个全局最小值。dist[v]记录的是v到已选集合的最短距离,每次有新顶点加入集合,都可能通过新顶点找到更短的路径。

第二个原因是起点初始化不完整。如果起点是0,那么dist数组应该初始化为graph[0][i],而不是全设为INF。我见过有人把dist全设INF然后只把dist[0]=0,结果第一轮之后所有顶点的dist都没更新,算法直接卡死。

第三个原因是把有向图当无向图处理。Prim算法针对的是无向图,邻接矩阵必须是对称的。如果只填了graph[i][j]没填graph[j][i],算法会漏掉一半的边。

6.2 图不连通时会怎样

如果图本身不连通,最小生成树根本不存在(因为无法连通所有顶点)。朴素版的表现是:某一轮找不到dist小于INF的未访问顶点,u保持为-1。这时候必须加判断并退出,否则会访问非法下标。

优先队列版的表现是:队列空了但edgeCount < n,说明有顶点从未被加入。同样需要判断。

实际做题时,如果题目保证连通就不用管;如果不保证,一定要加这个判断,否则会出错。

6.3 权值有负数怎么办

Prim算法对负权边是完全正确的,因为切割定理不要求权值为正。但要注意:如果图里有负权环,最小生成树的概念依然成立(树不能有环),Prim照样能求出正确结果。这一点和最短路径算法不同,Dijkstra不能处理负权边,但Prim可以。

不过实际应用中,权值通常代表距离、造价、时间这类非负量,负数权值比较少见。

6.4 常见问题速查表

问题现象可能原因排查方法
结果偏大dist更新条件写错检查是否用了graph[u][v] < dist[v]
死循环没有标记visited确认每轮选中的u被标记
数组越界图不连通未判断检查u==-1的情况
结果不稳定存在等权边正常现象,换起点可能得到不同树
输出边数不对parent数组未更新检查更新dist时是否同步更新parent

6.5 几个我踩过的坑

第一个坑是把dist和最短路径的dist搞混。最短路径里dist[i]是起点到i的距离,Prim里dist[i]是i到已选集合的距离。这两个含义完全不同,混了就会写出错误的更新逻辑。

第二个坑是忘记处理重边。如果两个顶点之间有多条边,邻接矩阵只能存一条。存最短的那条就行,因为Prim永远选最短的。

第三个坑是输出边的时候用了错误的权值。输出graph[parent[i]][i]是对的,但如果图里有重边且矩阵存的是另一条,就会不一致。所以存矩阵时就要保证存的是最短边。

7. 从Prim延伸出去的知识点

7.1 Prim和Dijkstra的微妙关系

学过最短路径的人会发现,Prim和Dijkstra的代码结构几乎一模一样,都是“选一个未访问的最近顶点,然后更新邻居”。区别只在更新逻辑:

  • Dijkstra更新的是dist[v] = min(dist[v], dist[u] + graph[u][v]),累加的是路径长度。
  • Prim更新的是dist[v] = min(dist[v], graph[u][v]),不累加,只看单条边。

这个差异导致Dijkstra求的是起点到各点的最短路径,Prim求的是连通所有点的最小总权值。理解了这个区别,两个算法就都不会混了。

7.2 最小生成树在实际中的应用

最小生成树不只是算法题,实际应用很广。比如通信网络布线,要在若干城市之间铺设光缆,如何用最短的总长度连通所有城市;比如电路板设计,要在若干焊点之间走线,如何用最少的材料连接所有焊点;再比如聚类分析,可以把数据点看成顶点,相似度看成边权,最小生成树去掉最长的几条边就得到聚类结果。

这些场景的共同特点是:需要连通所有节点,且连接成本可以量化。只要满足这两点,最小生成树就是一个可选的方案。

7.3 进一步学习的方向

如果想把图论算法学扎实,Prim之后可以接着看这几个:Kruskal算法(另一种最小生成树,理解并查集)、Dijkstra算法(单源最短路径)、Bellman-Ford算法(处理负权边)、拓扑排序(有向无环图)。这些算法之间有内在联系,放在一起学效率更高。

刷题的话,最小生成树的题目通常不会直接考模板,而是包装成各种场景。识别方法是看题目是否要求“连通所有点”且“总代价最小”。看到这两个关键词,基本就是最小生成树了。

我个人在实际写代码时的体会是,Prim算法的难点不在代码本身,而在理解“已选集合”和“候选边”这两个概念。一旦把这两个概念在脑子里建立起来,代码就是自然推导的结果。刚开始可以拿纸笔手动模拟几遍,比盯着代码看有效得多。另外,写完代码后用一个已知答案的小图验证一下,能避免大部分低级错误。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询