一、从实际问题说起
假设有几个城市要用光缆连通。城市之间有些能拉线、有些拉不了,每条线成本不同。目标是:
用总成本最低的方案,把所有城市连成一张网,让任意两个城市都能互通。
把城市看成图的顶点,光缆看成边,成本看成边权,这个问题就是经典的最小生成树问题。
先看一个包含 4 个城市的简化例子:
4 A-----C | /| 1 | 2/ |3 | / | B-----D 5如果我们要让所有城市连通且修路总长度最短,最佳的连线方案是选择 A-B (1)、B-C (2) 和 C-D (3) 三条边,总代价为 。这三条边所连接的结构,就是一棵最小生成树。
Prim 算法是求解最小生成树的两种经典贪心算法之一(另一种是 Kruskal)。它从一个起点出发,让树逐步长大,每一步都挑当前最便宜的连接方式。
二、生成树与最小生成树
给定一个连通无向图 ,它的生成树是满足以下三个条件的一个边子集 :
- 包含所有顶点: 连接了 里的每一个顶点;
- 无环: 里没有回路;
- 连通:任意两个顶点之间都有路径。
这三条加起来,决定了生成树的边数是固定的:
直觉上:从空开始,每加一条边把两个不连通的部分连起来,正好需要 条边才能把 个顶点连成一个整体;再多一条就会成环。
边数固定是关键无论哪棵生成树,边数都是 。所以”最小化总成本”和”最小化边数”是两回事——边数已经固定,我们最小化的是边权之和。
最小生成树就是所有生成树里,边权之和最小的那棵:
让 最小的 就是最小生成树。
前提:图连通只有连通图才有生成树。如果图不连通,最多只能得到”最小生成森林”(每个连通分量各一棵最小生成树)。本文假设图连通。
三、Prim 算法的贪心策略
Prim 的思路是:让一棵树从单个顶点开始,一步步长出来。
把顶点分成两组:
- 树内:已经并入生成树的顶点(记作集合 );
- 树外:还没并入的顶点(记作集合 )。
任何时候,连接”树内”和”树外”的边,都叫做跨切边——它们正好横跨了 和 之间的分界线。
Prim 的贪心策略:
每一步,在所有跨切边里挑权值最小的那条,把它加进生成树;它连向的那个树外顶点也就并入了树内。
重复 次,树就长全了。
上图中,跨越分界线的候选跨切边有两条:A-D(权值 2)与 C-E(权值 5)。因为 A-D 的权值更小,它被判定为最小跨切边,在这一步被选中并入树中,顶点 D 也随之并入树内。
一句话记忆Prim 就是:“已经连好的城市是一团,每次从团外挑一个最便宜的城市接进来。”
贪心选择性质对于一个图,所有可能的生成树构成了该问题的解空间。Prim 算法不需要搜索或回溯整个解空间,而是仅根据当前的“局部最优”(每次在所有跨切边中选择最便宜的那条)进行一步步的选择。这种不考虑后续影响、只看当前最优的决策,正是贪心选择性质的体现。
四、算法执行步骤图解
来看一个具体的图。6 个顶点,边和权值如下:
从顶点 开始跑 Prim。
初始状态
树内 ,已选边数 。
第 1 步
跨切边(从 出发的边):
| 跨切边 | 权值 |
|---|---|
A-B | 7 |
A-C | 8 |
其中权值最小的是 A-B (7),我们选择它,将顶点 并入树中。
,已选:
A-B
第 2 步
跨切边(从 或 出发,到树外):
| 跨切边 | 权值 |
|---|---|
A-C | 8 |
B-C | 3 |
B-D | 6 |
其中权值最小的跨切边是 B-C (3),选择它,将顶点 并入树中。
,已选:
A-B,B-C
第 3 步
跨切边:
| 跨切边 | 权值 |
|---|---|
A-C | 8(两端已入树) |
B-D | 6 |
C-D | 4 |
C-E | 3 |
小心废边
A-C这条边的两端 、 现在都在树内,已经不再是跨切边了。实现时遇到这种”两端都在 里”的边要跳过,否则会引入环。
其中最小跨切边为 C-E (3),选择它,将顶点 并入树中。
,已选:
A-B,B-C,C-E
第 4 步
跨切边:
| 跨切边 | 权值 |
|---|---|
B-D | 6 |
C-D | 4 |
E-D | 2 |
E-F | 2 |
其中权值最小的是 E-D (2),同等最小的还有 E-F (2)。任选其一都能保持最优性,但生成树结构可能不同。这里选择 E-D,将顶点 并入树中。
,已选:
A-B,B-C,C-E,E-D
第 5 步
跨切边:
| 跨切边 | 权值 |
|---|---|
D-F | 5 |
E-F | 2 |
其中最小跨切边为 E-F (2),选择它,将顶点 并入树中。
,已选:
A-B,B-C,C-E,E-D,E-F
所有顶点都在树内,已选 条边,算法结束。
最终结果
总权值:
五、Java 代码实现
一种容易理解的写法是懒 Prim:用最小堆存放候选边,每次从堆顶取最小边。
基本思路:
- 任选一个起点,把它入树;把它的所有邻接边入堆。
- 循环:从堆顶弹出权值最小的边。
- 如果这条边两端都在树内(废边),跳过;
- 否则选它:把那个还没入树的顶点并入树,并把它的邻接边入堆。
- 重复直到树里有 个顶点。
“懒”字指废边不立刻清理,而是在弹出时丢弃,因此代码更简洁。
5.1 边与图
import java.util.*;
/** 一条带权边。Comparable 让 PriorityQueue 默认按权值排序。 */class Edge implements Comparable<Edge> { int from, to, weight;
Edge(int from, int to, int weight) { this.from = from; this.to = to; this.weight = weight; }
@Override public int compareTo(Edge other) { return Integer.compare(this.weight, other.weight); }
@Override public String toString() { return "(" + from + "-" + to + ", w=" + weight + ")"; }}图用邻接表表示,每条无向边存两条有向边。
5.2 Prim 算法
public class PrimMST {
private final List<List<Edge>> adj; // 邻接表 private final int n; // 顶点数
public PrimMST(int n) { this.n = n; this.adj = new ArrayList<>(); for (int i = 0; i < n; i++) { adj.add(new ArrayList<>()); } }
/** 添加一条无向边。 */ public void addEdge(int u, int v, int w) { adj.get(u).add(new Edge(u, v, w)); adj.get(v).add(new Edge(v, u, w)); }
/** * 从顶点 start 开始跑 Prim,返回 MST 的边集合。 * 如果图不连通,返回的是最小生成森林。 */ public List<Edge> prim(int start) { List<Edge> mst = new ArrayList<>(); // 结果:MST 的边 boolean[] inTree = new boolean[n]; // 顶点是否已在树内 PriorityQueue<Edge> heap = new PriorityQueue<>(); // 跨切边最小堆
// 1. 起点入树,它的邻接边全部入堆 inTree[start] = true; visit(start, heap);
// 2. 只要 MST 还没长全,就继续 while (!heap.isEmpty() && mst.size() < n - 1) { Edge e = heap.poll(); // 当前最小跨切边
// 懒处理:两端都在树内 = 废边,跳过 if (inTree[e.from] && inTree[e.to]) { continue; }
// 由于前面已经排除了“两端都在树内”的情况,此时这条边必定是一端在树内、一端在树外。 // 找出那个尚未加入生成树的顶点 w,将其并入树中。 int w = inTree[e.from] ? e.to : e.from; inTree[w] = true; mst.add(e); visit(w, heap); // 新顶点的邻接边入堆 }
return mst; }
/** 把顶点 v 的所有邻接边加入堆。 */ private void visit(int v, PriorityQueue<Edge> heap) { for (Edge e : adj.get(v)) { // 将邻接边入堆 heap.offer(e); } }}visit 里为什么不过滤废边
visit会把邻接边全部放进堆,这是”懒”的体现。好处是代码简单;代价是堆里会积累一些两端都已入树的废边,弹出时再丢弃。对稠密图这会增加常数,但很适合理解算法原理。
5.3 完整运行
public static void main(String[] args) { PrimMST g = new PrimMST(6); // 第 4 节示例的图 g.addEdge(0, 1, 7); // A-B g.addEdge(0, 2, 8); // A-C g.addEdge(1, 2, 3); // B-C g.addEdge(1, 3, 6); // B-D g.addEdge(2, 3, 4); // C-D g.addEdge(2, 4, 3); // C-E g.addEdge(3, 4, 2); // D-E g.addEdge(3, 5, 5); // D-F g.addEdge(4, 5, 2); // E-F
List<Edge> mst = g.prim(0); // 从 A 开始
System.out.println("MST 的边:"); int total = 0; for (Edge e : mst) { System.out.println(" " + e); total += e.weight; } System.out.println("总权值:" + total);}运行结果:
MST 的边: (0-1, w=7) (1-2, w=3) (2-4, w=3) (4-3, w=2) (4-5, w=2)总权值:17结果与第 4 节手算一致。
六、复杂度分析
设顶点数为 ,边数为 。
-
懒 Prim:每条边最多入堆和弹出一次,堆操作为 ,总时间复杂度为 ,空间复杂度为 。
-
即时 Prim:对每个树外顶点只保留连向树内的最短边,能把堆大小压到 ,时间复杂度优化到 。
选哪个稀疏图:懒 Prim 足够,代码简单。 稠密图:考虑即时 Prim,或使用邻接矩阵的 版本。
七、Prim 与 Kruskal 算法对比
两种算法都基于切边性质、都能得到最小生成树,但风格不同:
| 维度 | Prim | Kruskal |
|---|---|---|
| 增长方式 | 从一个点开始,长一棵树 | 从空开始,长森林,逐步合并 |
| 选边依据 | 跨切边的最小权 | 全局未选边的最小权(前提是不成环) |
| 关键数据结构 | 最小堆 | 并查集 |
| 典型复杂度 | ||
| 适合的图 | 稠密图 | 稀疏图 |
记忆要点:
- Prim 像“种子向外蔓延”,每步只关心分界线上的跨切边;
- Kruskal 像“按边权从小到大贴胶带”,只要不成环就加入。
记忆口诀:
Prim 选点,Kruskal 选边。
怎么选如果图是稠密的,Prim 更划算;如果是稀疏的,Kruskal 更直观。虽然得到的最小总权值是确定的,但最小生成树本身未必唯一。
八、教材版 Prim
在《算法设计与分析》或《数据结构》考试中,常见的是基于邻接矩阵的 版本。该版本不依赖 PriorityQueue,而是通过两个辅助数组维护树外节点到树内的最短距离:
lowcost[i]:表示当前已建好的生成树到树外顶点i的最小连接代价(边权)。如果顶点i已经加入生成树,则lowcost[i] = 0。closest[i]:表示使顶点i获得最小连接代价的树内顶点。
8.1 算法思路
- 初始化所有顶点的
lowcost[i]为从起点到顶点i的边权,closest[i]为起点。起点本身的lowcost[start] = 0。 - 循环 次(每次并入一个新节点):
- 遍历所有树外顶点,找到一个使得
lowcost最小的顶点u。 - 将
u并入树内(设置lowcost[u] = 0)。 - 遍历
u的所有树外邻接顶点v,如果边(u, v)的权值小于当前的lowcost[v],则更新lowcost[v] = weight(u, v),并令closest[v] = u。
- 遍历所有树外顶点,找到一个使得
8.2 代码实现
public class MatrixPrim { private static final int INF = Integer.MAX_VALUE;
/** * 基于邻接矩阵的 Prim 算法实现。 * @param graph 邻接矩阵,graph[i][j] 表示顶点 i 到 j 的权值,不连通为 INF * @param n 顶点个数 * @param start 起点 */ public static void prim(int[][] graph, int n, int start) { int[] lowcost = new int[n]; int[] closest = new int[n]; boolean[] inTree = new boolean[n];
// 初始化 for (int i = 0; i < n; i++) { lowcost[i] = graph[start][i]; closest[i] = start; } inTree[start] = true;
int totalWeight = 0; System.out.println("教材版 Prim MST 的边:");
// 寻找剩余的 n - 1 个顶点 for (int step = 1; step < n; step++) { int minCost = INF; int u = -1;
// 1. 寻找当前连接代价最小的树外顶点 for (int i = 0; i < n; i++) { if (!inTree[i] && lowcost[i] < minCost) { minCost = lowcost[i]; u = i; } }
if (u == -1) { System.out.println("图不连通,无法构造完整的 MST"); return; }
// 2. 并入树内并输出边 inTree[u] = true; totalWeight += minCost; System.out.println(" (" + closest[u] + "-" + u + ", w=" + minCost + ")");
// 3. 更新剩余树外顶点的最小连接代价 for (int v = 0; v < n; v++) { if (!inTree[v] && graph[u][v] < lowcost[v]) { lowcost[v] = graph[u][v]; closest[v] = u; } } } System.out.println("总权值:" + totalWeight); }}8.3 复杂度与适用场景
- 时间复杂度:。外层循环执行 次,内部寻找最小值和更新代价的循环均为 。
- 空间复杂度:(仅需一维数组存储
lowcost和closest)。 - 适用场景:该版本适合处理稠密图( 接近 )。在此场景下,它常比堆优化版本更快,实现也更简单。
九、总结
Prim 最小生成树可以概括为:
从任意起点开始,每一步在跨切边里挑权值最小的那条加入,让树逐步长大,直到覆盖所有顶点。
要点回顾:
- 生成树有 条边、连通、无环;最小生成树是边权之和最小的那棵。
- Prim 的贪心:每步选当前已选顶点与未选顶点之间的最小跨切边。
- 正确性来自切边性质:任意切的最小跨切边必属于某棵最小生成树。
- 实现用最小堆存放跨切边,弹出废边时跳过(懒 Prim)。
- 复杂度:懒 Prim ,即时 Prim 。
- 与 Kruskal 互补:Prim 适合稠密图,Kruskal 适合稀疏图。
口诀:
一点起步向外延,跨切小边优先连;堆存候选勤更新,长满即是最优树。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时


















