最小生成树问题
接上回,本次介绍另一类问题的相关算法。
在图论领域,有权图(Weight Graph)最经典的问题是最小生成树问题(Minimum Spanning Tree):对于一个 n 个节点的 连通图,求出包含原图 n 个节点、且确保所有边权值之和最小的连通子图,则为 最小生成树(MST)。
有权图与其最小生成树:

先给出有权图的 Java 实现(省略细节),有权边 WeightedEdge
(兼容有向、无向):
1 | public class WeightedEdge implements Comparable<WeightedEdge> { |
有权图 WeightedGraph
:
1 | public class WeightedGraph { |
连通性判断
对于一个图,可求解出最小生成树的前提是该图为 连通图,即图中任意两个节点之间都能找到至少一条路径相连。
对于非连通图,每个连通子图都称为一个 连通分量。
因此判断图是否连通可使用以下深度优先遍历(DFS)算法:
使用变量
int left
记录待访问节点数(初始化为图节点总数V
),数组boolean[] visited
记录节点访问状态(避免重复访问)。从任意一个节点
v
出发深度优先遍历图,每经过一个未被访问的邻接节点w
即标记为已访问visited[w] = true;
,并把待访问节点数 -1left--
,否则跳过。当从
v
出发的深度优先遍历结束,表示已找到一个连通分量。此时判断待访问节点数left
是否为 0,是则表示刚才已完成整图遍历,该图为连通图,否则为非连通图。
基本实现如下:
1 | Stack<Integer> stack = new Stack<>(); |
切分定理(Bipartite)
将一个图中的顶点分为两部分,称为一个 切分。其中一条边的两个端点属于切分不同的两边,则该边称为 横切边。
如果考虑边的权值,横切边的最短边就属于最小生成树(切分定理)。
二分图
基于切分的定义引入二分图的概念:在图中能找到一个切分,其所有的边都是横切边,则该图为 二分图(Bipartite Graph):所有顶点可以分成边不相交的两部分(A 部分的点只于 B 部分相连,不与内部的点相连),所有的边的两个端点隶属于不同的部分。
二分图的概念常见于无权图的匹配问题(Matching)、最大流问题(Max Flow),经典的算法有 Hungarian 算法。
介绍切分的概念,是为了引入在遍历图的过程中,把节点划归到不同子集的技巧,这种做法在介绍 Prim 算法时再做具体说明。
并查集(Union Find)
并查集是一种多叉树,其包含两种基本操作:合并和查询。假设待加入并查集的节点共 n 个。
初始化:对于所有的 n 个点 [0, n-1],代表每个点的集合都初始化为自身,比如 0 => {0}, 1 => {1}, …, n-1 => {n-1}。
合并:将两个节点所表示的集合(表示为树)合并为一个集合,即将代表其中一个节点的树的根,嫁接到另一个节点表示的树的孩子上。
查找:查找节点所在的集合,即根节点。
由于本文旨在总结最小生成树相关算法,并查集不是重点,只做简要说明并直接给出实现代码。
1 | public class UnionFind { |
有了以上基础,就可以开始进入最小生成树的求解了。
Kruskal 算法
Kruskal 算法本质是贪心算法:每次尽量使用短边。
每次按权值从小到大选取图上不会构成环的短边,直到覆盖所有顶点。
每次选择一个最短边,如果这个边没有形成环,则相当于对一个切分选择了最短横切边。
判断是否成环:可以使用 DFS(O(V+E))或 并查集(接近 O(E))。
并查集用于存放图的节点,并判断节点是否直接或间接相连。
比如 1-2、2-3 被添加到 uf
,当 1-3 被尝试添加到 uf
时,会被判断为 1 和 3 已通过 2 相连,因此不再处理。
1 | [3] |
在这里直接给出使用并查集可在判断路径是成环时,优化节点查找效率的结论(接近 O(E)),感兴趣的话可以自己探究一下。
基本实现:
1 | public class WeightedGraph { |
实现细节:
使用一个数组
edges
记录所有的边,并按权值从小到大排列。初始化一个大小为
V
的并查集uf
,默认所有的边都不成环(表示为并查集中的点都不相连)。遍历集合
edges
,如果该边的两个顶点(v
和w
)在uf
中没有相连,则添加到mst
集合中,并在uf
中连接v
和w
。
时间复杂度:O(E*log(E))
点击链接可查看动图:Kruskal MST Visualzation (usfca.edu)
Prim 算法
Prim 算法将图中的顶点分为两部分,其核心是不断操作更新切分:
从边数之比为 1: V-1 开始,选取两个顶点(切分 from)、与其他点(切分 to)分别组成两个切分。
选取这两个切分的最短横切边,添加到 MST 集合,并把顶点更新到切分 from(由于切分更新,此时横切边可能发生改变)。
循环执行,直到切分 to 的所有节点都被转移到切分 from(不存在切分),即得到 MST。
先直接给出基本实现,再进一步分析实现的细节:
1 | public class WeightedGraph { |
与其他 DFS、BFS 算法的实现类似,Prim 算法的实现也是基于 boolean[] visited
数组,只是数组的值有了另一层面的意义:用于表示节点划归到不同的切分。对于初始节点 0,划归到 true
切分,其余节点为 false
切分。
使用最小堆存放节点 0 的所有邻边,判断条件为权值的大小,使得随时可以 O(log(E)) 的代价取出最短边。
循环执行以下步骤直到最小堆为空,其间使用 mst
集合收集最小生成树的边,最终返回即可:
遍历最小堆,每轮循环取出堆顶的最短边,如果该边的两点已归属于
true
切分,表示该边非横切边,因此跳过。否则收集最短边到
mst
集,并把改横切边上,当前归属于false
切分的端点 v 划归到true
切分。遍历新加入
true
的节点v
的所有邻接节点w
,把其中未访问(即还属于false
切分)的节点添加到最小堆。
时间复杂度:由于使用了最小堆,可快速找到最短横切边,时间复杂度从 O(V*E) 优化到 O(E*log(E)),如改用索引堆(Index Heap)则可以进一步优化到 O(E*log(V))。
点击链接可查看动图:Prim MST Visualzation (usfca.edu)
总结
本文介绍了最小生成树问题两种最经典的算法,实际上除此之外还有 Fredman-Tarjan 算法(Time: O(E+V*log(V)))、Chazelle 算法(Time: O(E*)),限于篇幅就不再一一介绍了,感兴趣的话可以自行查阅相关资料。