本文假设你已经了解最小生成树和切边性质。如果不熟,建议先看 Prim 最小生成树 的前几节。
一、Kruskal 的贪心思想
Prim 最小生成树 算法是以一个顶点为种子向外蔓延,每一步都在树内与树外的分界线(跨切边)上选择最小权值的边。
Kruskal 算法则换了一个角度:不考虑分界线,而是把所有边按权值从小到大排序,然后依次扫描;每条边只要加入后不成环,就予以保留。
在此过程中,图里会同时长出好几棵互不连通的小树(即森林),它们随着不断并入边而逐渐合并,最终合并为一棵覆盖所有顶点的最小生成树。
上图展示了森林合并的过程,虚线表示当前能够连接两棵不同小树且权值最小的候选边。Kruskal 算法就是反复将这种边并入,直至所有小树融为一体。
算法策略对比
- Prim:从单个顶点开始,向外生长一棵树。
- Kruskal:从孤立顶点形成的森林开始,逐步合并为一棵树。
Kruskal 算法的骨架很简洁:
- 将所有边按权值进行升序排序。
- 从最小边开始,依次遍历每条边 :
- 如果 和 已经在同一棵树中(加入该边会构成环路),则跳过;
- 否则保留该边,并将两棵树合并。
- 当保留的边数达到 条时,算法结束。
实现上述第 2 步的成环判断,最高效的数据结构就是并查集。需要说明的是,Kruskal 算法的正确性并非来自并查集,而是基于最小生成树的安全边性质(或称切边性质);并查集在此只作为高效判环的辅助工具。
二、关键工具:并查集
并查集是一种专门处理分组与合并的高效数据结构,在 Kruskal 算法中用来动态维护森林的连通分量。
它支持两项操作:
find(x):查询元素 属于哪个连通分量(返回所在组的代表元/根节点)。union(x, y):将元素 和 所在的两个连通分量合并为一个。
判断顶点 和 是否属于同一棵树,只需检查是否满足 find(u) == find(v)。
2.1 基本结构与数组表示
我们用数组 parent[] 来记录每个顶点的父亲节点,若顶点是其所在分量的根节点,则其父亲节点是其自身。
- 查询根节点:沿着父链一直向上爬,直到
parent[x] == x为止。 - 合并两个分量:分别找到 和 所在的根节点
rx和ry,将rx的父亲指向ry(或者反过来)即可完成合并。
若使用朴素查询,每次可能会遍历一条很长的链,时间复杂度最坏为 。并查集通过以下两个优化把均摊复杂度压到近似常数。
2.2 路径压缩
在递归向上寻找根节点的过程中,将沿途所有节点挂到根节点下,使整条长链扁平化。下次再查询这些节点时即可一步到位。
public int find(int x) { if (parent[x] != x) { parent[x] = find(parent[x]); // 递归寻根,并将沿途节点挂到根下 } return parent[x];}2.3 按秩合并
在合并两棵树时,总是让较矮的树挂载到较高的树下,以限制树高的增长。我们用 rank[] 记录每棵树的近似高度。
public void union(int x, int y) { int rx = find(x); int ry = find(y); if (rx == ry) return; // 已经在同一分量,无需合并
// 矮树挂载到高树下 if (rank[rx] < rank[ry]) { parent[rx] = ry; } else if (rank[rx] > rank[ry]) { parent[ry] = rx; } else { parent[ry] = rx; rank[rx]++; }}均摊复杂度同时使用路径压缩和按秩合并后,对含有 个元素的并查集进行 次操作,总时间复杂度为 。其中 是反阿克曼函数,增长极慢,实际工程中常近似看作 。
2.4 代码实现
/** * 采用路径压缩与按秩合并优化的并查集实现。 */class UnionFind { private final int[] parent; private final int[] rank; private int count; // 当前连通分量的数量
public UnionFind(int n) { parent = new int[n]; rank = new int[n]; count = n; for (int i = 0; i < n; i++) { parent[i] = i; // 初始状态下,每个顶点自成一个分量 rank[i] = 0; } }
/** * 查询节点 x 所在分量的根节点,顺带进行路径压缩。 */ public int find(int x) { if (parent[x] != x) { parent[x] = find(parent[x]); } return parent[x]; }
/** * 合并 x 和 y 所在的两个分量。 * @return 若成功合并(原本不在同一分量,未成环)返回 true;若已在同一分量(会成环)则返回 false。 */ public boolean union(int x, int y) { int rx = find(x); int 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]++; } count--; // 成功连通,分量总数减 1 return true; }
public boolean connected(int x, int y) { return find(x) == find(y); }
public int count() { return count; }}有了并查集,Kruskal 的成环判断就可以近似看作 。
三、算法执行步骤图解
我们复用 Prim 最小生成树 中使用过的 6 顶点带权无向图进行演示:
将图中的所有边按权值升序进行排列:
| 序号 | 边 | 权值 |
|---|---|---|
| 1 | D-E | 2 |
| 2 | E-F | 2 |
| 3 | B-C | 3 |
| 4 | C-E | 3 |
| 5 | C-D | 4 |
| 6 | D-F | 5 |
| 7 | B-D | 6 |
| 8 | A-B | 7 |
| 9 | A-C | 8 |
扫描和处理边的真实权值顺序如下:
初始状态
所有顶点各成独立分量,已选边数为 0。
第 1 步:尝试并入 D-E (2)
顶点 和 属于不同分量,予以保留,合并为连通分量 。
森林状态:
{D, E},{A},{B},{C},{F}。已选 1 条边。
第 2 步:尝试并入 E-F (2)
,而 处于独立分量,予以保留,合并为连通分量 。
森林状态:
{D, E, F},{A},{B},{C}。已选 2 条边。
第 3 步:尝试并入 B-C (3)
和 处于不同分量,予以保留,合并为连通分量 。
森林状态:
{B, C},{D, E, F},{A}。已选 3 条边。
第 4 步:尝试并入 C-E (3)
,,分属两个不同的树,予以保留,将两棵树合并。
森林状态:
{B, C, D, E, F},{A}。已选 4 条边。
第 5 步:尝试并入 C-D (4)
和 已属于同一连通分量,说明当前生成森林中已经存在一条从 到 的路径。若再加入边 C-D,则会形成回路,因此跳过。
第 6 步:尝试并入 D-F (5)
和 均在同一分量中,跳过。
第 7 步:尝试并入 B-D (6)
和 均在同一分量中,跳过。
第 8 步:尝试并入 A-B (7)
处于独立分量, 属于连通分量,分属不同分量,予以保留,并入生成树中。
森林状态:
{A, B, C, D, E, F}。已选 5 条边。
已保留边数达到 条,算法结束。
手算提速技巧对于含有 个顶点的图:
- 一旦在草稿纸上选出了 条边,即可立即宣布算法结束。
- 后续排在后面的未扫描边不必再分析是否成环,这能节省手算时间。
最终结果
最小生成树的总权值为:
四、Java 代码实现
下面提供一个包含并查集与测试入口的一站式、可直接编译运行的 Java 完整实现。
4.1 完整 Java 代码
import java.util.*;
public class KruskalMST {
/** * 代表图中的一条带权无向边。 */ public static class Edge implements Comparable<Edge> { int from; int to; int weight;
public 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 + ")"; } }
/** * 采用路径压缩与按秩合并优化的并查集。 */ public static class UnionFind { private final int[] parent; private final int[] rank;
public UnionFind(int n) { parent = new int[n]; rank = new int[n]; for (int i = 0; i < n; i++) { parent[i] = i; rank[i] = 0; } }
public int find(int x) { if (parent[x] != x) { parent[x] = find(parent[x]); // 路径压缩 } return parent[x]; }
public boolean union(int x, int y) { int rx = find(x); int 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 最小生成树算法的主实现。 * @param n 顶点个数(顶点编号为 0 到 n-1) * @param edges 所有的无向边列表 * @return 构成最小生成树的边列表。若图不连通,则返回最小生成森林。 */ public static List<Edge> kruskal(int n, List<Edge> edges) { // 1. 将所有边按权值升序排序 List<Edge> sorted = new ArrayList<>(edges); Collections.sort(sorted);
// 2. 初始化并查集 UnionFind uf = new UnionFind(n); List<Edge> mst = new ArrayList<>();
for (Edge e : sorted) { // 3. 判断是否同分量,不同分量则保留该边并合并 if (uf.union(e.from, e.to)) { mst.add(e); if (mst.size() == n - 1) { break; // 已成功找出 n-1 条边,提前结束 } } } return mst; }
public static void main(String[] args) { int n = 6; List<Edge> edges = Arrays.asList( new Edge(0, 1, 7), // A-B new Edge(0, 2, 8), // A-C new Edge(1, 2, 3), // B-C new Edge(1, 3, 6), // B-D new Edge(2, 3, 4), // C-D new Edge(2, 4, 3), // C-E new Edge(3, 4, 2), // D-E new Edge(3, 5, 5), // D-F new Edge(4, 5, 2) // E-F );
List<Edge> mst = kruskal(n, edges);
System.out.println("Kruskal MST 选中的边:"); int totalWeight = 0; for (Edge e : mst) { System.out.println(" " + e); totalWeight += e.weight; } System.out.println("总权值:" + totalWeight); }}运行结果输出:
Kruskal MST 选中的边: (3-4, w=2) (4-5, w=2) (1-2, w=3) (2-4, w=3) (0-1, w=7)总权值:17输出结果与第三节的手算过程一致。
五、复杂度分析
设图的顶点数为 ,边数为 。
-
时间复杂度:
- 排序开销:将所有边按权值排序,耗时 ,这是算法的瓶颈。
- 并查集操作:最坏情况下对所有边进行 次合并与查询,均摊时间复杂度为 。由于 增长极慢,这部分通常远小于排序开销。
- 总时间复杂度:由排序决定,为 ,在连通无向图中,边数 ,也可写为 。
-
空间复杂度:
- 主要开销在于并查集占用的 辅助空间,以及对边进行排序的辅助空间 。总空间复杂度为 。
复杂度对比Kruskal 算法与堆优化的 Prim 算法的理论时间复杂度均为 。
六、Kruskal 与 Prim 算法对比
两者的构造风格互为补充:
| 维度 | Kruskal 算法 | Prim 算法 |
|---|---|---|
| 基本思路 | 选边:从空边开始,每次选取全局权值最小的边 | 选点:从任意起点出发,每次选取跨切边的最小边 |
| 生成结构 | 长出森林,逐步融合 | 整棵树从单个顶点不断扩大 |
| 主要数据结构 | 并查集 | 最小堆 |
| 边排序要求 | 需要对所有边预先升序排序 | 不需要预先排序,通过堆动态寻找最小边 |
| 不连通图处理 | 运行结束天然得到最小生成森林 | 需显式遍历每个未访问分量分别执行 |
| 适合的图类型 | 稀疏图 | 稠密图 |
- Kruskal 适合稀疏图:其时间复杂度由排序主导,当边数 较少时,排序开销较低。
- Prim 适合稠密图:堆优化版本不依赖对全局边的预先排序,但在稠密图上,使用邻接矩阵实现的 版本性能比 Kruskal 更好。
七、总结
Kruskal 算法可以概括为:
全局边按权值升序排列,依次扫描并入;借助并查集快速判环,将孤立森林逐步融合成树。
要点梳理:
- 贪心选择:优先选取当前全局权值最小的边,并在成环时直接跳过。
- 数据结构支持:并查集高效处理成环判定,其中路径压缩与按秩合并缺一不可。
- 时空开销:时间由边排序决定,为 ,空间开销为 。
- 选型依据:在边稀疏的图上首选 Kruskal,在稠密图上首选 Prim。
口诀记忆:
边按权值排好序,从小到大依次取;并查集判成不成环,不成环就合进去。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时


















