# 最小生成树问题
在有权的无向连通图 中,我们希望寻找一棵树,使得树上全体边的权重和最小,即
最小。我们称这样的树为最小生成树 (minimum spanning tree, MST). 称其为最小的,因为其权重和是最小的。对于给定的图 , 求其最小生成树的问题就称为最小生成树问题。
显然,最小生成树不是唯一的。对于最小生成树问题,我们只要求其求出一棵最小生成树。
对于最小生成树问题,常见的算法包括 Kruskal 算法和 Prim 算法,其都是通用最小生成树算法的具体实现,其本质都是贪心算法。
# 通用最小生成树算法
# 描述
我们假设 是某个最小生成树边集的子集。则通用算法的思想是,对每条边 , 若 仍然是某个最小生成树边集的子集(此时我们称边 是安全的 (safe)),那么就将 加入集合 , 直至 成为最小生成树的边集。
下面是通用最小生成树算法的代码描述:
List<Edge> A = new ArrayList<Edge>(); //initialize | |
while (!A.isMST()) { | |
for (Edge e : G){ //find a safe edge | |
if (!A.has(e) && e.isSafe()) { | |
A.add(e); //add the safe edge into set A | |
} | |
} | |
} | |
return A; |
对于特定的最小生成树算法,需要做的就是快速地确定安全边 . 对于 Kruskal 算法和 Prim 算法,其策略不同。
下面,我们将给出识别安全边的规则。但我们需要先给出一些基本的定义。
# 基本概念
对于无向图 , 我们定义其上的一个割 (cut) 是集合 的一个二划分。
如果边 的两端点 满足 且 , 那么称边 通过 (cross) 了割 . 于是,若 , 均有 同属于 或 , 那么称 不妨碍 (respect) 边集 .
对于图 及其上的割 , 若边 是通过割 的权值最小的边,那么我们称 是割 的轻边 (light edge). 更一般地,也可以称满足所有性质中边权最小的边为满足该性质的轻边。
# 安全边寻找法则
设 是一个有权无向连通图。若 是 的一个包含于 的某个最小生成树的子集。对于不妨碍 的割 , 割 的轻边 关于 是安全的。
该定理的证明是简单的:
假设 是包含 的一棵最小生成树,且对于割 , 不含其对应的轻边 . 设 中通过割 的边为 (根据树的性质,边 是唯一的)。则对 , 其是连通的,边数为 子图,于是 也是一棵树。则 . 于是 , 又 为轻边,于是夹逼得 , 则 也是轻边。
定理的证明虽然简短,但思路值得学习。首先,证明采用了
对于此法则,我们可以作一个显然的推论:
设 是一个有权无向连通图。 是森林 的一个连通分支,若边 是连通 与其余某连通分支的一条轻边,那么 对于 是安全的。
# 通用最小生成树算法的正确性
根据上述安全边寻找法则,我们显然可以证明通用最小生成树算法是正确的。
首先,每一次将某条安全边 加入集合 的过程都是使图 的连通分支数减少 的过程。且若图 不是连通的,那么一定存在一个划分,使得 中的任意顶点与 中的任意顶点不连通。而图 是连通图,于是存在通过割 的轻边,根据安全边寻找法则,该轻边是安全边。于是,寻找安全边的操作能成功进行 次,因此通用最小生成树算法最终可以生成一棵树。
若生成的树 是最小生成树。
# Kruskal 算法
# 算法描述
Kruskal 算法的代码描述如下:
List<Edge> A = new ArrayList<Edge>(); | |
for (Vertex v : V) { | |
v.root = v; //every vertex now in the different disjoint-set | |
} | |
Arrays.sort(E); | |
for(int i=0; i<sizeof(E), i++) { | |
Edge e = E.get(i); | |
if (e.left.root.equals(e.right.root)) { | |
A.add(e); //means e is a safe edge | |
merge(e.left, e.right); //union two disjoint-set of e's endpoint | |
} | |
} | |
return A; |
算法是这样的,首先将 初始化为空集,然后将边按升序排列。按权值从小到大依次验证边 是否是安全的。验证安全的过程需要判断 的两顶点 ( e.left
与 e.right
) 是否为连通的,一般采用并查集辅助判断。
# 复杂度分析
初始化过程的复杂度为,将边按权值排序的过程复杂度为。对每条边,验证其是否安全的复杂度为,于是总复杂度为,而对简单图,有,于是复杂度也可以表述为。
# Prim 算法
# 算法描述
在 Prim 算法中,我们首先任选一个顶点 加入顶点集,于是对于割,我们可以从中选取轻边加入边集,然后将 的位于 的顶点移入,完成维护。这样,在不断维护和更新 的过程中,会形成一棵最小生成树。其代码描述如下:
// r is the beginning vertex | |
for (Vertex u : V) { | |
u.key = Double.POSITIVE_INFINITY; | |
u.pi = null; | |
} | |
r.key = 0; | |
Heap<Vertex> Q = new Heap<Vertex>(); //use heap to store the vertex queue, key is the weight | |
for (Vertex u : G) { | |
if (!u.equal(r)) Q.add(u); | |
} | |
while (!Q.empty()) { | |
Vertex u = Q.pop(); //get the vertex in Q, which has the minimum key | |
for (Vertex v : G.Adj[u]) { | |
if (Q.has(v) && weight(u,v) < v.key) { | |
v.pi = u; | |
v.key = weight(u,v); | |
} | |
} | |
} |
在上面的代码中,边集 . 对于每个顶点,其存在一个 key
域,维护到边集 的最短距离。队列 采用小根堆存储,堆顶为 key
值最小的顶点。
# 复杂度分析
初始化过程复杂度为,
# 最小生成树的性质
最小生成树存在一些有趣的性质:
- 图中最短边一定属于某棵最小生成树。
- 若图中存在多棵最小生成树,则其边的权值由小到大排成的序列是相同的。