说明:本章节的讨论基于“带权无向图”,即给定一幅加权无向图,找到它的一棵最小生成树。
最小生成树问题
前提:是一幅连通图(结点不形成环),并且各个边之和最短(连通的路径的总费用最少)。
如果一张图有 个结点,那么相应地,就会有 条边,这 条边连接了 个顶点,形成一棵树,最小生成树上所有的边的权值相加最小。
通常是针对带权无向图,并且是针对连通图,如果不连通,就在各个连通分量上求最小生成树,组成最小生成森林。
图的生成树是它的一棵含有其所有顶点的无环连通子图。一幅加权图的最小生成树(MST)是它的一棵权值(树中所有边的权值之和)最小的生成树。
在实际生活中的应用
电缆布线设计。
带权图的数据结构设计、从一个文本文本中读出图的信息到带权图对象
首先我们设计一个边的对象,让它保存一条边的两个结点以及边的权重
下面的设计中,当图是有向图的时候,我们总是认为 a
是边的起点, b
是边的终点。
Java 代码:
// 带权图的边
public class Edge implements Comparable {
// 边的两个端点的编号(编号是只是为了区分,没有大小关系,即没有比较的含义)
private int a;
private int b;
private Weight weight;
public Edge(int a, int b, Weight weight) {
this.a = a;
this.b = b;
this.weight = weight;
}
public Edge(Edge edge) {
this.a = edge.a;
this.b = edge.b;
this.weight = edge.weight;
}
public int v() {
return a;
}
public int w() {
return b;
}
public Weight weight() {
return weight;
}
public int other(int x) {
assert x == a || x == b;
return x == a ? b : a;
}
// 输出边的信息
public String toString() {
return "" + a + "-" + b + ": " + weight;
}
@Override
public int compareTo(Edge that) {
if (weight.compareTo(that.weight()) < 0) {
return -1;
} else if (weight.compareTo(that.weight()) > 0) {
return 1;
} else {
return 0;
}
}
}
带权图接口
Java 代码:
// 带权图接口
public interface WeightGraph {
int V(); // 顶点数
int E(); // 边数
// 向图中添加一个边, 权值为 weight
void addEdge(Edge edge);
// 验证图中是否有从 v 到 w 的边
boolean hasEdge(int v, int w);
// 显示图的信息
void show();
// 返回图中一个顶点的所有邻边
Iterable> adj(int v);
}
实现了带权图接口的稀疏图类和稠密图类、读取一个文本文件的内容到带权图对象
请见:带权图数据结构设计、读取一个文本文件的内容到带权图对象。
本章节使用的带权图文件(testG1.txt)数据如下:
8 16
4 5 .35
4 7 .37
5 7 .28
0 7 .16
1 5 .32
0 4 .38
2 3 .17
1 7 .19
0 2 .26
1 2 .36
1 3 .29
2 7 .34
6 2 .40
3 6 .52
6 0 .58
6 4 .93
MST 算法的理论基础
切分以及切分定理
把图中的结点分为两个部分,称为一个切分(Cut)。如果一个边的两个端点,属于切分(Cut) 不同的两边,这个边称为横切边(Crossing Edge)。
切分定理:在一幅加权图中,给定任意切分,所有横切边中权重最小的边一定属于图的最小生成树。
对给定的任意切分都成立,这是非常重要的。有了切分定理,就可以从一个点开始,一点一点扩散,直至找到最小生成树。
理解切分定理的关键是关于树的两条性质:
性质1:一棵树任意连接两个顶点,形成环;
性质2:一棵树任意删除一条边,就会分裂成两棵树。
最小生成树的 Prim 算法的延时实现
从起点出发(并不一定非得是起点,任意一点都可以),每次访问到一个顶点的时候,把所有这个顶点直接相连的、并且还未加入到最小堆中的边加入最小堆,这里的最小堆存储的是待查考的 MST 中的边,即从它们之中选出符合 MST 要求的边,规则是:取出当前最小堆中最小的边,如果它是横切边,就一定是 MST 中的边,如果它不是横切边,就不是 MST 中的边,这一步也是 lazy prim 称之为懒惰的地方。
实现的难点在于:
1、如何判断是不是横切边,与判断是否构成环是否一致?
判断是否构成横切边的方法:判断当前考虑的结点的另一端是否已经被访问过。
2、算法停止的条件是什么,是最小堆中的元素已经为空了,还是我们已经找到了 条边?
最小堆
lazy prim 需要借助最小堆,当然,你不用最小堆也可以,那么每一次从待考虑的边中取出最小值,就会比较费时了。
最小堆实现(Java)
lazy prim 算法实现
Java 代码:
import java.util.ArrayList;
import java.util.List;
// LazyPrim :懒惰的 Prim 实现,Prim(普里姆)
// MST:Minimum Spanning Tree,最小生成树
public class LazyPrimMST {
private WeightGraph G;// 图的引用
private MinHeap> pq;// 最小堆
private boolean[] marked; // 标记数组,在算法运行过程中标记结点 i 是否被访问
private List> mst; // 最小生成树所包含的所有边
private Number mstWeight;//最小生成树的权值
// 在构造方法中完成最小生成树的计算
public LazyPrimMST(WeightGraph graph) {
// 初始化一些成员变量和辅助的数据结构
this.G = graph;
pq = new MinHeap(graph.E());// lazy prim 算法每次考虑的边的条数就是图的最多的边数
marked = new boolean[this.G.V()];
mst = new ArrayList<>();
// lazt prim
visit(0);
while (!pq.isEmpty()) { // 每次都拿出考虑的边的最小值,看看它是不是横切边,这就是 lazy prim 称之为 lazy 的原因
// 使用最小堆找出已经访问的边中权值最小的边
Edge e = pq.extractMin();
// 如果这条边的两端都已经访问过了, 则扔掉这条边
if (marked[e.v()] == marked[e.w()]) {
continue;
}
// 否则,这条边就是最小生成树中的一条边
mst.add(e);
// 可以认为下面这一步是深度优先遍历
// 将这条边中还没有被访问过的那个顶点,执行和一开始一样的 visit 操作
if (!marked[e.v()]) {
visit(e.v());
} else {
visit(e.w());
}
}
// 最小堆中的元素都考虑完以后(根据上面的逻辑,要考虑完那些不是横切边的边)
this.mstWeight = mst.get(0).weight();
for (int i = 1; i < mst.size(); i++) {
this.mstWeight = this.mstWeight.doubleValue() + mst.get(i).weight().doubleValue();
}
}
// 访问一个顶点,只做一件事情,将和这个顶点直接相连的还未加入最小堆的边加入最小堆
private void visit(int v) {
assert !marked[v];
marked[v] = true;
for (Edge e : this.G.adj(v)) {
// 【注意】技巧在这里:只要是另一个端点还没有被标记过,那么就表示这条边还未加入到最小堆中
if (!marked[e.other(v)]) {
pq.insert(e);
}
}
}
// 返回最小生成树的所有边
public List> mstEdges() {
return mst;
}
// 返回最小生成树的权值
public Number result() {
return mstWeight;
}
}
测试方法:
Java 代码:
import java.util.List;
public class LazyPrimMSTTest {
public static void main(String[] args) {
String filename = "testG1.txt";
int v = 8;
// 无权图
SpareWeightedGraph g = new SpareWeightedGraph<>(v, false);
ReadWeightedGraphUtil readWeightedGraphUtil = new ReadWeightedGraphUtil(g, filename);
g.show();
System.out.println("最小生成树的 Prim 算法的延时实现:");
LazyPrimMST lazyPrimMST = new LazyPrimMST<>(g);
List> mst = lazyPrimMST.mstEdges();
mst.forEach(System.out::println);
}
}
运行结果:
Prim 算法的即时实现
我们要借助最小索引堆完成 Prim 算法。
最小索引堆的实现:最小索引堆(Java 代码)。
Java 代码:
import java.util.ArrayList;
import java.util.List;
// Prim 算法的即时实现
public class PrimMST {
private WeightGraph G;// 图的引用
private IndexMinHeap ipq;// 最小索引堆
private boolean[] marked;
private Edge[] edgeTo;// 访问的点所对应的边
private List> mst; // 最小生成树所包含的所有边
private Number mstWeight;//最小生成树的权值
public PrimMST(WeightGraph graph) {
G = graph;
ipq = new IndexMinHeap<>(graph.V());
// 初始化辅助的数据结构和一些成员变量
marked = new boolean[G.V()];
edgeTo = new Edge[G.V()];
for (int i = 0; i < G.V(); i++) {
marked[i] = false;
edgeTo[i] = null;
}
mst = new ArrayList<>();
// Prim
visit(0);
while (!ipq.isEmpty()){
int v = ipq.extractMinIndex();
assert edgeTo[v]!=null;
mst.add(edgeTo[v]);
visit(v);
}
// 最小堆中的元素都考虑完以后(根据上面的逻辑,要考虑完那些不是横切边的边)
this.mstWeight = mst.get(0).weight();
for (int i = 1; i < mst.size(); i++) {
this.mstWeight = this.mstWeight.doubleValue() + mst.get(i).weight().doubleValue();
}
}
private void visit(int v) {
assert !marked[v];
marked[v]=true;
for(Edge e: this.G.adj(v)){
int w = e.other(v);
// 如果顶点的另一个端点还没有访问过,那么这条边就可以加入索引堆
if(!marked[w]){
if(edgeTo[w]==null){
edgeTo[w]= e;
ipq.insert(w,e.weight());
}else if(e.weight().compareTo(edgeTo[w].weight())<0){
edgeTo[w]= e;
ipq.change(w,e.weight());
}
}
}
}
// 返回最小生成树的所有边
public List> mstEdges() {
return mst;
}
// 返回最小生成树的权值
public Number result() {
return mstWeight;
}
}
测试方法:
import java.util.List;
public class PrimMSTTest {
public static void main(String[] args) {
String filename = "testG1.txt";
int v = 8;
// 无权图
SpareWeightedGraph g = new SpareWeightedGraph<>(v, false);
ReadWeightedGraphUtil readWeightedGraphUtil = new ReadWeightedGraphUtil(g, filename);
g.show();
System.out.println("最小生成树的 Prim 算法的即时实现:");
PrimMST primMST = new PrimMST<>(g);
List> mst = primMST.mstEdges();
mst.forEach(System.out::println);
}
}
运行结果:
使用 Kruskal(克鲁斯卡尔)算法计算带权图的最小生成树
需要使用的辅助的数据结构有:
1、最小堆;
2、并查集。
Java 代码:
import java.util.ArrayList;
import java.util.List;
// Kruskal(克鲁斯卡尔)算法
public class KruskalMST {
private List> mst;
private Number mstWeight;
// 使用 Kruskal 算法计算带权图的最小生成树
public KruskalMST(WeightGraph graph) {
mst = new ArrayList<>();
// 马上我们就会看到,Kruskal 算法会把所有的边都考虑进去
MinHeap> pq = new MinHeap<>(graph.E());
// 把所有的边都加入到这个最小堆中
for (int i = 0; i < graph.V(); i++) {
for (Edge e : graph.adj(i)) {
// 为了防止重复加入边
// 为了防止重复加入边
// 为了防止重复加入边
if (e.v() < e.w()) {
pq.insert(e);
}
}
}
// 为了判断顶点是否相连接,所以应该开辟顶点数规模的并查集
UnionFind uf = new UnionFind(graph.V());
while (!pq.isEmpty() && mst.size() < graph.V()-1){// 凑够数了,就不必进行下去了
// 从最小堆中拿出当前未考虑到的最小权重的边
Edge e = pq.extractMin();
// 如果这条边的两个端点是连通的,说明加入这条边会产生环,那么它肯定不是 MST 中的一条边
if(uf.connected(e.v(),e.w())){
continue;
}
// 如果这条边的两个端点不是连通的,这条边就是 MST 中的一条边,加入之后,不要忘记标记两个结点相连
mst.add(e);
uf.union(e.v(),e.w());
}
this.mstWeight = mst.get(0).weight();
for (int i = 1; i < mst.size(); i++) {
this.mstWeight = this.mstWeight.doubleValue() + mst.get(i).weight().doubleValue();
}
}
public List> mstEdges() {
return mst;
}
public Number result() {
return mstWeight;
}
}
测试方法:
Java 代码:
import java.util.List;
public class KruskalMSTTest {
public static void main(String[] args) {
String filename = "testG1.txt";
int v = 8;
// 无权图
SpareWeightedGraph g = new SpareWeightedGraph<>(v, false);
ReadWeightedGraphUtil readWeightedGraphUtil = new ReadWeightedGraphUtil(g, filename);
g.show();
System.out.println("最小生成树的 Kruskal 算法实现:");
KruskalMST kruskalMST = new KruskalMST<>(g);
List> mst = kruskalMST.mstEdges();
mst.forEach(System.out::println);
}
}
运行结果:
本文源代码
Python:代码文件夹,Java:代码文件夹。
参考资料
本章节中的所有专业插图都来自《算法(第 4 版)》这本书的配套网站。
(本节完)