mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
2340 字
6 分钟
Kruskal 最小生成树
2026-06-15 12:30:00

本文假设你已经了解最小生成树和切边性质。如果不熟,建议先看 Prim 最小生成树 的前几节。

一、Kruskal 的贪心思想#

Prim 最小生成树 算法是以一个顶点为种子向外蔓延,每一步都在树内与树外的分界线(跨切边)上选择最小权值的边。

Kruskal 算法则换了一个角度:不考虑分界线,而是把所有边按权值从小到大排序,然后依次扫描;每条边只要加入后不成环,就予以保留。

在此过程中,图里会同时长出好几棵互不连通的小树(即森林),它们随着不断并入边而逐渐合并,最终合并为一棵覆盖所有顶点的最小生成树。

森林合并示意图

上图展示了森林合并的过程,虚线表示当前能够连接两棵不同小树且权值最小的候选边。Kruskal 算法就是反复将这种边并入,直至所有小树融为一体。

算法策略对比
  • Prim:从单个顶点开始,向外生长一棵树。
  • Kruskal:从孤立顶点形成的森林开始,逐步合并为一棵树。

Kruskal 算法的骨架很简洁:

  1. 将所有边按权值进行升序排序
  2. 从最小边开始,依次遍历每条边 (u,v)(u, v)
    • 如果 uuvv 已经在同一棵树中(加入该边会构成环路),则跳过
    • 否则保留该边,并将两棵树合并。
  3. 当保留的边数达到 V1|V| - 1 条时,算法结束。

实现上述第 2 步的成环判断,最高效的数据结构就是并查集。需要说明的是,Kruskal 算法的正确性并非来自并查集,而是基于最小生成树的安全边性质(或称切边性质);并查集在此只作为高效判环的辅助工具。


二、关键工具:并查集#

并查集是一种专门处理分组合并的高效数据结构,在 Kruskal 算法中用来动态维护森林的连通分量。

它支持两项操作:

  • find(x):查询元素 xx 属于哪个连通分量(返回所在组的代表元/根节点)。
  • union(x, y):将元素 xxyy 所在的两个连通分量合并为一个。

判断顶点 uuvv 是否属于同一棵树,只需检查是否满足 find(u) == find(v)

2.1 基本结构与数组表示#

我们用数组 parent[] 来记录每个顶点的父亲节点,若顶点是其所在分量的根节点,则其父亲节点是其自身。

  • 查询根节点:沿着父链一直向上爬,直到 parent[x] == x 为止。
  • 合并两个分量:分别找到 xxyy 所在的根节点 rxry,将 rx 的父亲指向 ry(或者反过来)即可完成合并。

若使用朴素查询,每次可能会遍历一条很长的链,时间复杂度最坏为 O(N)O(N)。并查集通过以下两个优化把均摊复杂度压到近似常数。

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]++;
}
}
均摊复杂度

同时使用路径压缩和按秩合并后,对含有 NN 个元素的并查集进行 MM 次操作,总时间复杂度为 O(Mα(N))O(M \cdot \alpha(N))。其中 α\alpha 是反阿克曼函数,增长极慢,实际工程中常近似看作 O(1)O(1)

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 的成环判断就可以近似看作 O(1)O(1)


三、算法执行步骤图解#

我们复用 Prim 最小生成树 中使用过的 6 顶点带权无向图进行演示:

算法图示

将图中的所有边按权值升序进行排列:

序号权值
1D-E2
2E-F2
3B-C3
4C-E3
5C-D4
6D-F5
7B-D6
8A-B7
9A-C8

扫描和处理边的真实权值顺序如下:

D-E(2), E-F(2), B-C(3), C-E(3), C-D(4), D-F(5), B-D(6), A-B(7), A-C(8)\text{D-E}(2),\ \text{E-F}(2),\ \text{B-C}(3),\ \text{C-E}(3),\ \text{C-D}(4),\ \text{D-F}(5),\ \text{B-D}(6),\ \text{A-B}(7),\ \text{A-C}(8)

初始状态#

所有顶点各成独立分量,已选边数为 0。

步骤 0 初始状态

第 1 步:尝试并入 D-E (2)#

顶点 DDEE 属于不同分量,予以保留,合并为连通分量 {D,E}\{D, E\}

森林状态:{D, E}{A}{B}{C}{F}。已选 1 条边。

步骤 1 保留 D-E

第 2 步:尝试并入 E-F (2)#

E{D,E}E \in \{D, E\},而 FF 处于独立分量,予以保留,合并为连通分量 {D,E,F}\{D, E, F\}

森林状态:{D, E, F}{A}{B}{C}。已选 2 条边。

步骤 2 保留 E-F

第 3 步:尝试并入 B-C (3)#

BBCC 处于不同分量,予以保留,合并为连通分量 {B,C}\{B, C\}

森林状态:{B, C}{D, E, F}{A}。已选 3 条边。

步骤 3 保留 B-C

第 4 步:尝试并入 C-E (3)#

C{B,C}C \in \{B, C\}E{D,E,F}E \in \{D, E, F\},分属两个不同的树,予以保留,将两棵树合并。

森林状态:{B, C, D, E, F}{A}。已选 4 条边。

步骤 4 保留 C-E

第 5 步:尝试并入 C-D (4)#

CCDD 已属于同一连通分量,说明当前生成森林中已经存在一条从 CCDD 的路径。若再加入边 C-D,则会形成回路,因此跳过

第 6 步:尝试并入 D-F (5)#

DDFF 均在同一分量中,跳过

第 7 步:尝试并入 B-D (6)#

BBDD 均在同一分量中,跳过

第 8 步:尝试并入 A-B (7)#

AA 处于独立分量,BB 属于连通分量,分属不同分量,予以保留,并入生成树中。

森林状态:{A, B, C, D, E, F}。已选 5 条边。

已保留边数达到 V1=5|V| - 1 = 5 条,算法结束。

手算提速技巧

对于含有 nn 个顶点的图:

  • 一旦在草稿纸上选出了 n1n - 1 条边,即可立即宣布算法结束
  • 后续排在后面的未扫描边不必再分析是否成环,这能节省手算时间。

最终结果#

最小生成树结果

最小生成树的总权值为:

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

四、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

输出结果与第三节的手算过程一致。


五、复杂度分析#

设图的顶点数为 VV,边数为 EE

  • 时间复杂度

    • 排序开销:将所有边按权值排序,耗时 O(ElogE)O(E \log E),这是算法的瓶颈。
    • 并查集操作:最坏情况下对所有边进行 EE 次合并与查询,均摊时间复杂度为 O(Eα(V))O(E \cdot \alpha(V))。由于 α(V)\alpha(V) 增长极慢,这部分通常远小于排序开销。
    • 总时间复杂度:由排序决定,为 O(ElogE)O(E \log E),在连通无向图中,边数 EV2E \le V^2,也可写为 O(ElogV)O(E \log V)
  • 空间复杂度

    • 主要开销在于并查集占用的 O(V)O(V) 辅助空间,以及对边进行排序的辅助空间 O(E)O(E)。总空间复杂度为 O(V+E)O(V + E)
复杂度对比

Kruskal 算法与堆优化的 Prim 算法的理论时间复杂度均为 O(ElogV)O(E \log V)


六、Kruskal 与 Prim 算法对比#

两者的构造风格互为补充:

维度Kruskal 算法Prim 算法
基本思路选边:从空边开始,每次选取全局权值最小的边选点:从任意起点出发,每次选取跨切边的最小边
生成结构长出森林,逐步融合整棵树从单个顶点不断扩大
主要数据结构并查集最小堆
边排序要求需要对所有边预先升序排序不需要预先排序,通过堆动态寻找最小边
不连通图处理运行结束天然得到最小生成森林需显式遍历每个未访问分量分别执行
适合的图类型稀疏图稠密图
  • Kruskal 适合稀疏图:其时间复杂度由排序主导,当边数 EE 较少时,排序开销较低。
  • Prim 适合稠密图:堆优化版本不依赖对全局边的预先排序,但在稠密图上,使用邻接矩阵实现的 O(V2)O(V^2) 版本性能比 Kruskal 更好。

七、总结#

Kruskal 算法可以概括为:

全局边按权值升序排列,依次扫描并入;借助并查集快速判环,将孤立森林逐步融合成树。

要点梳理:

  • 贪心选择:优先选取当前全局权值最小的边,并在成环时直接跳过。
  • 数据结构支持:并查集高效处理成环判定,其中路径压缩与按秩合并缺一不可。
  • 时空开销:时间由边排序决定,为 O(ElogE)O(E \log E),空间开销为 O(V+E)O(V + E)
  • 选型依据:在边稀疏的图上首选 Kruskal,在稠密图上首选 Prim。

口诀记忆:

边按权值排好序,从小到大依次取;并查集判成不成环,不成环就合进去。

分享

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

Kruskal 最小生成树
https://dawn114514.site/posts/algorithm/kruskalmst/
作者
黎明
发布于
2026-06-15 12:30:00
许可协议
MIT

部分信息可能已经过时

目录