mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
2700 字
7 分钟
Prim 最小生成树
2026-06-15 12:02:01

一、从实际问题说起#

假设有几个城市要用光缆连通。城市之间有些能拉线、有些拉不了,每条线成本不同。目标是:

总成本最低的方案,把所有城市连成一张网,让任意两个城市都能互通。

把城市看成图的顶点,光缆看成边,成本看成边权,这个问题就是经典的最小生成树问题。

先看一个包含 4 个城市的简化例子:

4
A-----C
| /|
1 | 2/ |3
| / |
B-----D
5

如果我们要让所有城市连通且修路总长度最短,最佳的连线方案是选择 A-B (1)、B-C (2) 和 C-D (3) 三条边,总代价为 1+2+3=61 + 2 + 3 = 6。这三条边所连接的结构,就是一棵最小生成树

Prim 算法是求解最小生成树的两种经典贪心算法之一(另一种是 Kruskal)。它从一个起点出发,让树逐步长大,每一步都挑当前最便宜的连接方式。


二、生成树与最小生成树#

给定一个连通无向图 G=(V,E)G = (V, E),它的生成树是满足以下三个条件的一个边子集 TT

  1. 包含所有顶点TT 连接了 VV 里的每一个顶点;
  2. 无环TT 里没有回路;
  3. 连通:任意两个顶点之间都有路径。

这三条加起来,决定了生成树的边数是固定的:

T=V1|T| = |V| - 1

直觉上:从空开始,每加一条边把两个不连通的部分连起来,正好需要 V1|V| - 1 条边才能把 V|V| 个顶点连成一个整体;再多一条就会成环。

边数固定是关键

无论哪棵生成树,边数都是 V1|V| - 1。所以”最小化总成本”和”最小化边数”是两回事——边数已经固定,我们最小化的是边权之和

最小生成树就是所有生成树里,边权之和最小的那棵:

W(T)=eTw(e)W(T) = \sum_{e \in T} w(e)

W(T)W(T) 最小的 TT 就是最小生成树。

前提:图连通

只有连通图才有生成树。如果图不连通,最多只能得到”最小生成森林”(每个连通分量各一棵最小生成树)。本文假设图连通。


三、Prim 算法的贪心策略#

Prim 的思路是:让一棵树从单个顶点开始,一步步长出来。

把顶点分成两组:

  • 树内:已经并入生成树的顶点(记作集合 SS);
  • 树外:还没并入的顶点(记作集合 VSV - S)。

任何时候,连接”树内”和”树外”的边,都叫做跨切边——它们正好横跨了 SSVSV - S 之间的分界线。

Prim 的贪心策略:

每一步,在所有跨切边里挑权值最小的那条,把它加进生成树;它连向的那个树外顶点也就并入了树内。

重复 V1|V| - 1 次,树就长全了。

树内树外分割与跨切边

上图中,跨越分界线的候选跨切边有两条:A-D(权值 2)与 C-E(权值 5)。因为 A-D 的权值更小,它被判定为最小跨切边,在这一步被选中并入树中,顶点 D 也随之并入树内。

一句话记忆

Prim 就是:“已经连好的城市是一团,每次从团外挑一个最便宜的城市接进来。”

贪心选择性质

对于一个图,所有可能的生成树构成了该问题的解空间。Prim 算法不需要搜索或回溯整个解空间,而是仅根据当前的“局部最优”(每次在所有跨切边中选择最便宜的那条)进行一步步的选择。这种不考虑后续影响、只看当前最优的决策,正是贪心选择性质的体现。


四、算法执行步骤图解#

来看一个具体的图。6 个顶点,边和权值如下:

算法图示

从顶点 AA 开始跑 Prim。

初始状态#

树内 S={A}S = \{A\},已选边数 00

初始状态

第 1 步#

跨切边(从 AA 出发的边):

跨切边权值
A-B7
A-C8

其中权值最小的是 A-B (7),我们选择它,将顶点 BB 并入树中。

S={A,B}S = \{A, B\},已选:A-B

第 1 步

第 2 步#

跨切边(从 AABB 出发,到树外):

跨切边权值
A-C8
B-C3
B-D6

其中权值最小的跨切边是 B-C (3),选择它,将顶点 CC 并入树中。

S={A,B,C}S = \{A, B, C\},已选:A-BB-C

第 2 步

第 3 步#

跨切边:

跨切边权值
A-C8(两端已入树)
B-D6
C-D4
C-E3
小心废边

A-C 这条边的两端 AACC 现在都在树内,已经不再是跨切边了。实现时遇到这种”两端都在 SS 里”的边要跳过,否则会引入环。

其中最小跨切边为 C-E (3),选择它,将顶点 EE 并入树中。

S={A,B,C,E}S = \{A, B, C, E\},已选:A-BB-CC-E

第 3 步

第 4 步#

跨切边:

跨切边权值
B-D6
C-D4
E-D2
E-F2

其中权值最小的是 E-D (2),同等最小的还有 E-F (2)。任选其一都能保持最优性,但生成树结构可能不同。这里选择 E-D,将顶点 DD 并入树中。

S={A,B,C,E,D}S = \{A, B, C, E, D\},已选:A-BB-CC-EE-D

第 4 步

第 5 步#

跨切边:

跨切边权值
D-F5
E-F2

其中最小跨切边为 E-F (2),选择它,将顶点 FF 并入树中。

S={A,B,C,E,D,F}S = \{A, B, C, E, D, F\},已选:A-BB-CC-EE-DE-F

所有顶点都在树内,已选 V1=5|V| - 1 = 5 条边,算法结束。

最终结果#

最小生成树结果

总权值:

W(T)=7+3+3+2+2=17\begin{aligned} W(T) &= 7 + 3 + 3 + 2 + 2 \\ &= 17 \end{aligned}

五、Java 代码实现#

一种容易理解的写法是懒 Prim:用最小堆存放候选边,每次从堆顶取最小边。

基本思路:

  1. 任选一个起点,把它入树;把它的所有邻接边入堆。
  2. 循环:从堆顶弹出权值最小的边。
    • 如果这条边两端都在树内(废边),跳过;
    • 否则选它:把那个还没入树的顶点并入树,并把它的邻接边入堆。
  3. 重复直到树里有 V|V| 个顶点。

“懒”字指废边不立刻清理,而是在弹出时丢弃,因此代码更简洁。

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 节手算一致。


六、复杂度分析#

设顶点数为 VV,边数为 EE

  • 懒 Prim:每条边最多入堆和弹出一次,堆操作为 O(logE)O(\log E),总时间复杂度为 O(ElogE)O(E \log E),空间复杂度为 O(E)O(E)

  • 即时 Prim:对每个树外顶点只保留连向树内的最短边,能把堆大小压到 O(V)O(V),时间复杂度优化到 O(ElogV)O(E \log V)

选哪个

稀疏图:懒 Prim 足够,代码简单。 稠密图:考虑即时 Prim,或使用邻接矩阵的 O(V2)O(V^2) 版本。


七、Prim 与 Kruskal 算法对比#

两种算法都基于切边性质、都能得到最小生成树,但风格不同:

维度PrimKruskal
增长方式从一个点开始,长一棵树从空开始,长森林,逐步合并
选边依据跨切边的最小权全局未选边的最小权(前提是不成环)
关键数据结构最小堆并查集
典型复杂度O(ElogV)O(E \log V)O(ElogE)O(E \log E)
适合的图稠密图稀疏图

记忆要点:

  • Prim 像“种子向外蔓延”,每步只关心分界线上的跨切边;
  • Kruskal 像“按边权从小到大贴胶带”,只要不成环就加入。

记忆口诀:

Prim 选点,Kruskal 选边。

怎么选

如果图是稠密的,Prim 更划算;如果是稀疏的,Kruskal 更直观。虽然得到的最小总权值是确定的,但最小生成树本身未必唯一。


八、教材版 Prim#

在《算法设计与分析》或《数据结构》考试中,常见的是基于邻接矩阵的 O(V2)O(V^2) 版本。该版本不依赖 PriorityQueue,而是通过两个辅助数组维护树外节点到树内的最短距离:

  • lowcost[i]:表示当前已建好的生成树到树外顶点 i 的最小连接代价(边权)。如果顶点 i 已经加入生成树,则 lowcost[i] = 0
  • closest[i]:表示使顶点 i 获得最小连接代价的树内顶点。

8.1 算法思路#

  1. 初始化所有顶点的 lowcost[i] 为从起点到顶点 i 的边权,closest[i] 为起点。起点本身的 lowcost[start] = 0
  2. 循环 V1V - 1 次(每次并入一个新节点):
    • 遍历所有树外顶点,找到一个使得 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 复杂度与适用场景#

  • 时间复杂度O(V2)O(V^2)。外层循环执行 VV 次,内部寻找最小值和更新代价的循环均为 O(V)O(V)
  • 空间复杂度O(V)O(V)(仅需一维数组存储 lowcostclosest)。
  • 适用场景:该版本适合处理稠密图EE 接近 V2V^2)。在此场景下,它常比堆优化版本更快,实现也更简单。

九、总结#

Prim 最小生成树可以概括为:

从任意起点开始,每一步在跨切边里挑权值最小的那条加入,让树逐步长大,直到覆盖所有顶点。

要点回顾:

  • 生成树V1|V| - 1 条边、连通、无环;最小生成树是边权之和最小的那棵。
  • Prim 的贪心:每步选当前已选顶点与未选顶点之间的最小跨切边。
  • 正确性来自切边性质:任意切的最小跨切边必属于某棵最小生成树。
  • 实现用最小堆存放跨切边,弹出废边时跳过(懒 Prim)。
  • 复杂度:懒 Prim O(ElogE)O(E \log E),即时 Prim O(ElogV)O(E \log V)
  • Kruskal 互补:Prim 适合稠密图,Kruskal 适合稀疏图。

口诀:

一点起步向外延,跨切小边优先连;堆存候选勤更新,长满即是最优树。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Prim 最小生成树
https://dawn114514.site/posts/algorithm/primmst/
作者
黎明
发布于
2026-06-15 12:02:01
许可协议
MIT

部分信息可能已经过时

目录