最小生成树
加权图,我们发现它的边关联了一个权重,那么我们就可以根据这个权重解决最小成本问题,但如何才能找到最小成本对应的顶点和边呢?最小生成树相关算法可以解决。
1. 最小生成树定义以及相关约定
定义:
图的生成树是它的一棵含有其所有顶点的无环连通子图,一副加权无向图的最小生成树它的一棵权值(树中所有边的权重之和)最小的生成树
约定:
- 只考虑连通图。最小生成树的定义说明它只能存在于连通图中,如果图不是连通的,那么分别计算每个连通图子图的最小生成树,合并到一起称为最小生成森林。
- 所有边的权重都各不相同。如果不同的边权重可以相同,那么一副图的最小生成树就可能不唯一了,虽然我们的算法可以处理这种情况,但为了好理解,我们约定所有边的权重都各不相同。
2.最小生成树原理
2.1 树的性质
-
用一条边接树中的任意两个顶点都会产生一个新的环;
-
从树中删除任意一条边,将会得到两棵独立的树;
2.2 切分定理
要从一副连通图中找出该图的最小生成树,需要通过切分定理完成。
切分:
将图的所有顶点按照某些规则分为两个非空且没有交集的结合。
横切边:
连接两个属于不同集合的顶点的边称之为横切边
例如我们将图中的顶点切分为两个集合,灰色顶点属于一个集合,白色顶点属于另外一个集合,那么效果如下:黑色加粗的为横切边
切分定理:
在一副加权图中,给定任意的切分,它的横切边中的权重最小者必然属于图中的最小生成树。
**注意:**一次切分产生的多个横切边中,权重最小的边不一定是所有横切边中唯一属于图的最小生成树的边。
3. 贪心算法
贪心算法是计算图的最小生成树的基础算法,它的基本原理就是切分定理,使用切分定理找到最小生成树的一条边,不断的重复直到找到最小生成树的所有边。如果图有V个顶点,那么需要找到V-1条边,就可以表示该图的最小生成树。
计算图的最小生成树的算法有很多种,但这些算法都可以看做是贪心算法的一种特殊情况,这些算法的不同之处在于保存切分和判定权重最小的横切边的方式。
4. Prim算法
我们学习第一种计算最小生成树的方法叫Prim算法,它的每一步都会为一棵生成中的树添加一条边。一开始这棵树只有一个顶点,然后会向它添加V-1条边,每次总是将下一条连接树中的顶点与不在树中的顶点且权重最小的边加入到树中。
Prim算法的切分规则:
把最小生成树中的顶点看做是一个集合,把不在最小生成树中的顶点看做是另外一个集合。
4.1 Prim算法API设计
类名 | PrimMST |
---|---|
构造方法 | PrimMST(EdgeWeightedGraph G):根据一副加权无向图,创建最小生成树计算对象; |
成员方法 | 1.private void visit(EdgeWeightedGraph G, int v):将顶点v添加到最小生成树中,并且更新数据 2.public Queue edges():获取最小生成树的所有边 |
成员变量 | 1.private Edge[] edgeTo: 索引代表顶点,值表示当前顶点和最小生成树之间的最短边 2.private double[] distTo: 索引代表顶点,值表示当前顶点和最小生成树之间的最短边的权重 3.private boolean[] marked:索引代表顶点,如果当前顶点已经在树中,则值为true,否则为false 4.private IndexMinPriorityQueue pq:存放树中顶点与非树中顶点之间的有效横切边 |
4.2 Prim算法的实现原理
Prim算法始终将图中的顶点切分成两个集合,最小生成树顶点和非最小生成树顶点,通过不断的重复做某些操作,可以逐渐将非最小生成树中的顶点加入到最小生成树中,直到所有的顶点都加入到最小生成树中。
我们在设计API的时候,使用最小索引优先队列存放树中顶点与非树中顶点的有效横切边,那么它是如何表示的呢?我们可以让最小索引优先队列的索引值表示图的顶点,让最小索引优先队列中的值表示从其他某个顶点到当前顶点的边权重。
初始化状态,先默认0是最小生成树中的唯一顶点,其他的顶点都不在最小生成树中,此时横切边就是顶点0的邻接表中0-2,0-4,0-6,0-7这四条边,我们只需要将索引优先队列的2、4、6、7索引处分别存储这些边的权重值就可以表示了。
现在只需要从这四条横切边中找出权重最小的边,然后把对应的顶点加进来即可。所以找到0-7这条横切边的权重最小,因此把0-7这条边添加进来,此时0和7属于最小生成树的顶点,其他的不属于,现在顶点7的邻接表中的边也成为了横切边,这时需要做两个操作:
-
0-7这条边已经不是横切边了,需要让它失效:
只需要调用最小索引优先队列的delMin()方法即可完成;
-
2和4顶点各有两条连接指向最小生成树,需要只保留一条:
4-7的权重小于0-4的权重,所以保留4-7,调用索引优先队列的change(4,0.37)即可,
0-2的权重小于2-7的权重,所以保留0-2,不需要做额外操作。
我们不断重复上面的动作,就可以把所有的顶点添加到最小生成树中。
4.3 代码
/**
* 最小生成树--prim算法
*/
public class PrimMST {
// 索引代表顶点,值表示当前顶点和最小生成树之间的最短边
private Edge[] edgeTo;
// 索引代表顶点,值表示当前顶点和最小生成树之间的最短边的权重
private double[] distTo;
// 索引代表顶点,如果当前顶点已经在树中,则值为true,否则为false
private boolean[] marked;
// 存放树中顶点与非树中顶点之间的有效横切边
private IndexMinPriorityQueue<Double> pq;
// 根据一副加权无向图,创建最小生成树计算对象
public PrimMST(EdgeWeightedGraph G) {
// 创建一个和图的顶点数一样大小的Edge数组,表示边
this.edgeTo = new Edge[G.V()];
// 创建一个和图的顶点数一样大小的double数组,表示权重,并且初始化数组中的内容为无穷大,无穷大即表示不存在这样的边
this.distTo = new double[G.V()];
for (int i = 0; i < distTo.length; i++) {
distTo[i] = Double.POSITIVE_INFINITY;
}
// 创建一个和图的顶点数一样大小的boolean数组,表示当前顶点是否已经在树中
this.marked = new boolean[G.V()];
// 创建一个和图的顶点数一样大小的索引优先队列,存储有效横切边
this.pq = new IndexMinPriorityQueue<>(G.V());
// 默认让顶点0进入树中(也就是做根节点),所以让其与最小生成树之间的最短边的权重设置为0.0
marked[0] = true;
distTo[0] = 0.0;
// 使用顶点0和权重0.0初始化有效横切边队列pq
pq.insert(0, 0.0);
// 遍历有效边队列
while (!pq.isEmpty()) {
// 找到权重最小的横切边对应的顶点,加入到最小生成树中
visit(G, pq.delMin());
}
}
// 将顶点v添加到最小生成树中,并且更新数据
private void visit(EdgeWeightedGraph G, int v) {
// 把顶点v加入树中
marked[v] = true;
// 遍历顶点v的连接表,得到每一条边Edge e
for (Edge e : G.adj(v)) {
// 得到与v相连的边的另一个顶点w
int w = e.other(v);
/*
检测w是否已经在树中
在,则进行下一次循环,寻找与顶点v相连的不在树中的顶点
不在,则判断此边e的权重是否<此顶点w目前到最小生成树的权重
是:修改此顶点w到最小生成树的权重为此边的权重
修改此顶点w到最小生成树的边为此条边
判断pq中的有效横切边是否存在顶点w,存在则进行更新,不存在则进行添加
*/
//检测w是否已经在树中,在,则进行下一次循环,寻找与顶点v相连的不在树中的顶点
if (marked[w]) {
continue;
}
// 不在,则判断此边e的权重是否<此顶点w目前到最小生成树的权重
if (e.weight() < distTo[w]) {
// 修改此顶点w到最小生成树的权重为此边的权重
distTo[w] = e.weight();
// 修改此顶点w到最小生成树的边为此条边
edgeTo[w] = e;
// 判断pq中的有效横切边是否存在顶点w,存在则进行更新,不存在则进行添加
if (pq.contains(w)) {
pq.changeItem(w, e.weight());
} else {
pq.insert(w, e.weight());
}
}
}
}
// 获取最小生成树的所有边
public Queue<Edge> edges() {
// 创建队列
Queue<Edge> edges = new Queue<>();
// 遍历edgeTo数组,找到每一条边,添加到队列中
for (int i = 0; i < edgeTo.length; i++) {
if (edgeTo[i] != null) {
edges.enqueue(edgeTo[i]);
}
}
return edges;
}
}