1. 定义
在连通的带权图的所有生成树中,权值和最小的那棵生成树(包含途中所有顶点的树),称作最小生成树。
举例说明:
比如用微信群组的例子来解释最小生成树的逻辑。
假设有 5 个人(A、B、C、D、E)想建一个微信群组,每个人之间单独建群聊天会耗费一定的手机流量(权值)。现在要让这 5 个人都能在同一个聊天网络里,但又想让总流量耗费最小。
选起点 :先随便选一个人,比如选 A。A 目前在自己的 “小群” 里。
找最小连接 :看看和 A 相连的人(B、C)中,谁和 A 聊天流量耗费最少。假设 A 和 B 聊天流量耗费为 2,A 和 C 聊天流量耗费为 5,那选和 B 建立联系,把 B 拉进群。
重复找最小 :现在群里有 A 和 B。再看看和 A、B 相连还没进群的人(C、D)。A 和 C 聊天流量耗费 5,B 和 D 聊天流量耗费 3,那选拉 D 进来。
继续找最小 :现在群里有 A、B、D。看和这三个人相连还没进群的人(C、E)。假设 D 和 C 聊天流量耗费为 1,B 和 E 聊天流量耗费为 4,那选拉 C 进来。
重复直到全进群 :现在群里有 A、B、D、C。最后看和这四个人相连还没进群的人(E)。比如 D 和 E 聊天流量耗费为 6,C 和 E 聊天流量耗费为 2,那选拉 E 进来。
最终,就形成了一个把所有人都拉进群且总流量耗费最小的聊天网络,这就是最小生成树的逻辑。
2. 相关算法
2.1 Prim算法(普利姆算法)
从任意一个点开始,每次都把“离已选集合最近”的新点拉进来,直到所有点都在集合里。
🥞 生活比喻:Prim 算法就像拼 Wi-Fi 覆盖
想象你搬进了一栋 4 个房间的屋子,每个房间都要连上网。
你手上有几根不同长度的网线,目标是用最少的网线总长让所有房间都能上网。
随便挑一间房(比如客厅 A)先插上路由器,客厅就算“已覆盖”。
看所有能从已覆盖房间拉到未覆盖房间的网线,选最短的那根,把对应的房间也拉进来。
客厅→卧室 B 这根最短?好,把卧室 B 也盖上。现在客厅 + 卧室都覆盖了,再看新的“边界网线”:
卧室→厨房 2 米,客厅→书房 4 米 → 选 2 米,把厨房拉进来。最后只剩书房,再拉一根最短的网线把书房也接上。
最终拉的几根网线就是最小生成树——总长度最短,全屋 Wi-Fi 通!
Prim算法的三种实现策略
1️⃣ 邻接矩阵朴素版 O(V²)
-
V 是点数,E 是边数。
-
矩阵 = 一个二维表,存“每两点之间有没有边”。
-
过程:
每选 1 个点,就要把整个 V×V 的表扫一遍找最短边。
共选 V 次,所以 V×V = V²。 -
生活例子:
你要把全班 100 个同学的姓名、电话写满一张 100×100 的通讯录。
每找 1 个人的号码,都得把整页翻完——慢,但简单直接。 -
适用场景:
图很稠密(边很多,几乎每两点都有边),矩阵反而省内存。// 复杂度来源:两层 for 循环,每层最多 V 次,共 V*V public static int primMatrix(int[][] g) { int n = g.length; boolean[] inMST = new boolean[n]; int[] minEdge = new int[n]; // 到已选集合的最小边权 Arrays.fill(minEdge, Integer.MAX_VALUE); minEdge[0] = 0; for (int cnt = 0; cnt < n; cnt++) { int u = -1; // ① 每次扫整个数组找最小 => O(V) for (int i = 0; i < n; i++) if (!inMST[i] && (u == -1 || minEdge[i] < minEdge[u])) u = i; if (u == -1) return -1; // 图不连通 inMST[u] = true; // ② 再扫一遍更新 => O(V) for (int v = 0; v < n; v++) if (!inMST[v] && g[u][v] != 0 && g[u][v] < minEdge[v]) minEdge[v] = g[u][v]; } return Arrays.stream(minEdge).sum(); // 最小生成树权值和 }
2️⃣ 二叉堆 + 邻接表 O(E log V)
-
邻接表:只存“每个点真正连出去的边”,不存空边。
-
二叉堆(优先队列):每次把“当前最短边”快速弹出来。
-
过程:
每条边最多进堆一次,每次堆操作是 log V,所以 E 条边 × log V = E log V。 -
生活例子:
用微信的“附近的人”功能:只列出你周围 20 个好友,再用小顶堆(按距离排序)找出最近 1 个。
比翻全班通讯录快多了。 -
适用场景:
现实中 90% 的图都是稀疏图(边远少于 V²),这是最常用的写法。// 复杂度来源:每条边最多进堆一次 => E 次 push/pop,每次 log V public static long primBinaryHeap(int n, List<List<Edge>> adj) { boolean[] inMST = new boolean[n]; int[] minEdge = new int[n]; Arrays.fill(minEdge, Integer.MAX_VALUE); PriorityQueue<Edge> pq = new PriorityQueue<>(Comparator.comparingInt(e -> e.w)); minEdge[0] = 0; pq.add(new Edge(0, 0)); long total = 0; while (!pq.isEmpty()) { Edge cur = pq.poll(); int u = cur.v; if (inMST[u]) continue; inMST[u] = true; total += cur.w; for (Edge e : adj.get(u)) { int v = e.v, w = e.w; if (!inMST[v] && w < minEdge[v]) { minEdge[v] = w; pq.offer(new Edge(v, w)); // O(log V) } } } return total; }
3️⃣ 斐波那契堆 O(E + V log V)
-
斐波那契堆是一个更高级的优先队列,支持“减小键值”操作均摊 O(1)。
-
过程:
总操作 = 把 E 条边各插一次 + V 次弹出最小元素(每次 log V)
⇒ E + V log V。 -
生活例子:
相当于你有一个“魔法抽屉”,能在 1 秒内把任何好友的距离改小,而普通抽屉每次改完都要重新排顺序。
魔法抽屉更快,但造起来巨麻烦。 -
适用场景:
写论文、刷比赛想冲极限性能;工程里几乎不用,因为代码复杂、常数大。/* 伪代码:斐波那契堆尚未进入 JDK 标准库。 * 若真要用,可引入第三方库(如 algs4, fastutil)或自己实现。 * 这里给出核心调用逻辑以展示复杂度来源: * * - decreaseKey 均摊 O(1) => 总 E 次 * - extractMin 均摊 O(log V) => 总 V 次 * 于是总体 O(E + V log V) */ public static long primFibonacci(int n, List<List<Edge>> adj) { // FibonacciHeap<Node> heap = new FibonacciHeap<>(); // 其余逻辑与二叉堆版完全一致,只是把 PriorityQueue 换成 FibonacciHeap // 并调用 heap.decreaseKey(...) 而非 offer(...) throw new UnsupportedOperationException("需要外部 FibonacciHeap 实现"); }
✅ 统一测试入口
public static void main(String[] args) {
/* 同一组数据,三种实现跑出的结果都是 6 */
int n = 4;
int[][] matrix = new int[n][n];
matrix[0][1] = matrix[1][0] = 1;
matrix[1][2] = matrix[2][1] = 2;
matrix[2][3] = matrix[3][2] = 3;
matrix[0][3] = matrix[3][0] = 4;
List<List<Edge>> adj = new ArrayList<>();
for (int i = 0; i < n; i++) adj.add(new ArrayList<>());
addEdge(adj, 0, 1, 1);
addEdge(adj, 1, 2, 2);
addEdge(adj, 2, 3, 3);
addEdge(adj, 0, 3, 4);
System.out.println("Matrix O(V²) = " + primMatrix(matrix));
System.out.println("Binary O(E log V) = " + primBinaryHeap(n, adj));
// System.out.println("FibHeap O(E+V log V) = " + primFibonacci(n, adj));
}
static void addEdge(List<List<Edge>> adj, int u, int v, int w) {
adj.get(u).add(new Edge(v, w));
adj.get(v).add(new Edge(u, w));
}
static class Edge {
int v, w;
Edge(int v, int w) { this.v = v; this.w = w; }
}
🧩 复杂度对照小结(代码级)
实现方式 | 关键循环 | 复杂度公式 | 代码体现 |
---|---|---|---|
邻接矩阵朴素版 | 两层 for (int i=0;i<n;i++) | O(V²) | 每次扫整行/整列 |
二叉堆版 | PriorityQueue.poll/offer | O(E log V) | 每边最多一次 log V |
斐波那契堆版 | decreaseKey + extractMin | O(E + V log V) | 均摊 O(1) 改键 + V 次 O(log V) |
🧩 为什么 Prim 会有这三种实现?
Prim 的核心思想永远不变:
“每次把离已选集合最近的顶点拉进来”
但怎么“找到最近的顶点”有三种工具:
暴力扫整张表 → 用 邻接矩阵 → 复杂度 O(V²)
(每个点进来后,把剩下所有点距离扫一遍)用二叉堆(优先队列) → 复杂度 O(E log V)
(把边扔到堆里,每次 log V 弹出最小)用斐波那契堆 → 复杂度 O(E + V log V)
(堆支持 O(1) 的“减小键值”,更精细)
一句话总结
Prim 算法就是“扩张领土法”:已占的城邦不断吞并最近的邻国,直到一统天下且修路总成本最低。
2.2 Kruskal算法(克鲁斯卡尔算法/贪心算法)
按权重从小到大选边,避环合并,直至n-1条边连起所有顶点。
🥞 生活比喻:装修布线
房间 = 顶点
电线 = 边,长度 = 权值
目标:用最少总长度把所有房间连成一个电网
规则:
把所有电线从短到长排好队。
一根一根往屋里放。
如果这根电线会把已经连通的房间再连一次(出现圈),就丢掉。
直到所有房间都通电,剩下的电线就是最小生成树。
算法步骤(图例)
顶点:0 1 2 3
边:
0–1 1
1–2 2
2–3 3
0–3 4
步骤 | 动作 | 选中边 | 连通分量 | 累计长度 |
---|---|---|---|---|
初始 | 排序 | — | {0},{1},{2},{3} | 0 |
① | 取最短 0–1 | 0–1 | {0,1},{2},{3} | 1 |
② | 取下一条 1–2 | 1–2 | {0,1,2},{3} | 1+2=3 |
③ | 再取下一条 2–3 | 2–3 | {0,1,2,3} | 3+3=6 |
④ | 0–3 会成圈 | 丢弃 | — | — |
结果:最小生成树边 = {0–1, 1–2, 2–3},总长度 = 6
核心数据结构
-
并查集(Union-Find):
判断“两点是否已经连通”,以及合并两个集合。
Java 完整代码(含复杂度标注)
import java.util.*;
/**
* Kruskal 算法:最小生成树
* 复杂度:O(E log E) = O(E log V) (排序 + 并查集)
*/
public class KruskalMST {
/* ================= 边 ================= */
static class Edge implements Comparable<Edge> {
int u, v, w;
Edge(int u, int v, int w) {
this.u = u;
this.v = v;
this.w = w;
}
@Override
public int compareTo(Edge o) {
return Integer.compare(this.w, o.w); // 按权值升序
}
}
/* ================= 并查集 ================= */
static class UnionFind {
int[] parent, rank;
UnionFind(int n) {
parent = new int[n];
rank = new int[n];
for (int i = 0; i < n; i++) parent[i] = i;
}
int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]); // 路径压缩
return parent[x];
}
boolean union(int x, int y) {
int rx = find(x), ry = find(y);
if (rx == ry) return false; // 已连通
// 按秩合并
if (rank[rx] < rank[ry]) parent[rx] = ry;
else if (rank[rx] > rank[ry]) parent[ry] = rx;
else {
parent[ry] = rx;
rank[rx]++;
}
return true;
}
}
/* ================= Kruskal ================= */
public static long kruskal(int n, List<Edge> edges) {
// 1. 排序所有边 O(E log E)
Collections.sort(edges);
UnionFind uf = new UnionFind(n);
long total = 0;
int cnt = 0;
// 2. 依次取最短边
for (Edge e : edges) {
if (uf.union(e.u, e.v)) { // 不会成圈
total += e.w;
cnt++;
if (cnt == n - 1) break; // 已选 n-1 条边
}
}
return cnt == n - 1 ? total : -1; // 不连通返回 -1
}
/* ================= 测试 ================= */
public static void main(String[] args) {
int n = 4;
List<Edge> edges = new ArrayList<>();
edges.add(new Edge(0, 1, 1));
edges.add(new Edge(1, 2, 2));
edges.add(new Edge(2, 3, 3));
edges.add(new Edge(0, 3, 4));
System.out.println("Kruskal MST 总权值 = " + kruskal(n, edges)); // 6
}
}
一句话总结
Kruskal:
“排好队,一根一根拿,不成圈就连,直到连完所有点!”
2.3 Prim 和 Kruskal 在稠密图和稀疏图的应用场景有什么不同?
稠密图 → Prim(邻接矩阵 O(V²) 或二叉堆 O(E log V))更快;
稀疏图 → Kruskal(O(E log V))写起来更简单。
为什么?
图类型 | 特点 | 推荐算法 | 原因 |
---|---|---|---|
稠密图(边多,接近 V²) | E ≈ V² | Prim 邻接矩阵 O(V²) | 常数极小,无需排序全部边 |
Prim 二叉堆 O(E log V) | 在 E ≈ V² 时 ≈ V² log V,略慢于矩阵,但代码通用 | ||
稀疏图(边少,E ≈ V 或 V log V) | E ≪ V² | Kruskal O(E log V) | 只需排序少量边,并查集操作极快;代码短、易写 |
Prim 二叉堆 O(E log V) | 复杂度同 Kruskal,但实现稍长,可用 |
实战口诀
-
边多到爆炸(稠密)→ Prim(矩阵或堆)。
-
边少到可怜(稀疏)→ Kruskal(排序 + 并查集)。
复杂度对照表(Prim vs Kruskal)
算法 | 核心操作 | 时间复杂度 | 适用场景 |
---|---|---|---|
Prim 邻接矩阵 | 暴力找最小 | O(V²) | 稠密图 |
Prim 二叉堆 | 二叉堆 | O(E log V) | 稀疏图 |
Prim 斐波那契堆 | 高级堆 | O(E + V log V) | 理论极限 |
Kruskal | 排序 + 并查集 | O(E log E) = O(E log V) | 稀疏图/边可排序 |