數據結構與算法--最小生成樹之Prim算法
加權圖是一種為每條邊關聯一個權值或稱為成本的圖模型。所謂生成樹,是某圖的一棵含有全部n個頂點的無環連通子圖,它有n - 1條邊。最小生成樹(MST)是加權圖的一棵權值和(所有邊的權值相加之和)最小的生成樹。
要注意以下幾點:
- 最小生成樹首先是一個生成樹,所以我們研究的是無環連通分量;
- 邊的權值可能是0也可能是負數
- 邊的權值不一定表示距離,還可以是費用等
加權無向圖的實現
之前圖的實現都沒有考慮權值,而權值存在于邊上,所以最好是將“邊”這個概念抽象出來,用一個Edge類來表示。如下
package Chap7;
public class Edge implements Comparable<Edge> {
private int either;
private int other;
private double weight;
public Edge(int either, int other, double weight) {
this.either = either;
this.other = other;
this.weight = weight;
}
public double weight() {
return weight;
}
public int either() {
return either;
}
public int other(int v) {
if (v == either) {
return other;
} else if (v == other) {
return either;
} else throw new RuntimeException("該邊無此頂點!");
}
@Override
public int compareTo(Edge that) {
return Double.compare(this.weight, that.weight);
}
@Override
public String toString() {
return "(" +
either +
"-" + other +
" " + weight +
')';
}
}
Edge類實現了Comparable<Edge>
,使得Edge本身可以進行比較(就像Double
類那樣)而比較的依據是邊上的權值。Edge類中的other(int v)
方法,接收一個頂點,如果v在該邊中,返回該邊的另一個頂點,否則拋出異常。
接下來是加權無向圖的實現。
package Chap7;
import java.util.*;
public class EdgeWeightedGraph<Item> {
private int vertexNum;
private int edgeNum;
// 鄰接表
private List<List<Edge>> adj;
// 頂點信息
private List<Item> vertexInfo;
public EdgeWeightedGraph(List<Item> vertexInfo) {
this.vertexInfo = vertexInfo;
this.vertexNum = vertexInfo.size();
adj = new ArrayList<>();
for (int i = 0; i < vertexNum; i++) {
adj.add(new LinkedList<>());
}
}
public EdgeWeightedGraph(List<Item> vertexInfo, int[][] edges, double[] weight) {
this(vertexInfo);
for (int i = 0; i < edges.length;i++) {
Edge edge = new Edge(edges[i][0], edges[i][1], weight[i]);
addEdge(edge);
}
}
public EdgeWeightedGraph(int vertexNum) {
this.vertexNum = vertexNum;
adj = new ArrayList<>();
for (int i = 0; i < vertexNum; i++) {
adj.add(new LinkedList<>());
}
}
public EdgeWeightedGraph(int vertexNum, int[][] edges, double[] weight) {
this(vertexNum);
for (int i = 0; i < edges.length;i++) {
Edge edge = new Edge(edges[i][0], edges[i][1], weight[i]);
addEdge(edge);
}
}
public void addEdge(Edge edge) {
int v = edge.either();
int w = edge.other(v);
adj.get(v).add(edge);
adj.get(w).add(edge);
edgeNum++;
}
// 返回與某個頂點依附的所有邊
public Iterable<Edge> adj(int v) {
return adj.get(v);
}
public List<Edge> edges() {
List<Edge> edges = new LinkedList<>();
for (int i = 0; i < vertexNum; i++) {
for (Edge e: adj(i)) {
// i肯定是邊e的一個頂點,我們只取other大于i的邊,避免添加重復的邊
// 比如adj(1)中的1-3邊會被添加,但是adj(3)中的3-1就不會被添加
if (e.other(i) > i) {
edges.add(e);
}
}
}
return edges;
}
public int vertexNum() {
return vertexNum;
}
public int edgeNum() {
return edgeNum;
}
public Item getVertexInfo(int i) {
return vertexInfo.get(i);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(vertexNum).append("個頂點, ").append(edgeNum).append("條邊。\n");
for (int i = 0; i < vertexNum; i++) {
sb.append(i).append(": ").append(adj.get(i)).append("\n");
}
return sb.toString();
}
public static void main(String[] args) {
List<String> vertexInfo = Arrays.asList("v0", "v1", "v2", "v3", "v4");
int[][] edges = {{0, 1}, {0, 2}, {0, 3},
{1, 3}, {1, 4},
{2, 4}};
double[] weight = {30.0, 40.0, 20.5, 10.0, 59.5, 20.0};
EdgeWeightedGraph<String> graph = new EdgeWeightedGraph<>(vertexInfo, edges, weight);
System.out.println("該圖的鄰接表為\n"+graph);
System.out.println("該圖的所有邊:"+ graph.edges());
}
}
edges()
方法可以返回圖中的所有邊。下面的判斷比較關鍵
for (Edge e: adj(i)) {
if (e.other(i) > i) {
edges.add(e);
}
}
i肯定是邊e的一個頂點,我們只取other大于i的邊,避免添加重復的邊。 比如adj(1)中的1-3邊會被添加,但是adj(3)中的3-1就不會被添加。
這份代碼只實現了部分方法,像獲取某個頂點的度,圖的平均度數,這些實現起來都很簡單,而且可以直接照搬無權圖的代碼,我們討論的重點是最小生成樹,所以就不貼那些代碼了。
最小生成樹有兩個經典的算法,一個是Prim算法,另外一個是Kruskal算法,接下來會依次介紹它們。
Prim算法
該算法的主要思想是:從生成樹鄰近的邊中,選出一條權值最小的,加入到樹中;如果選出的邊會導致成環,舍棄之,選擇下一條權值最小的邊。
首先我們需要一個優先隊列存放頂點的鄰接邊,一個布爾數組標記已經訪問過的頂點,一個隊列存儲最小生成樹的邊。
- 從某一個頂點開始(不妨假設從頂點0開始),標記它,并將它鄰接表中的邊全部加入到隊列中;
- 從隊列中選出并刪除權值最小的那條邊,先檢查這條邊的兩個頂點是否都已經被標記過,若是,加入這條邊會導致成環。跳過這條邊,選擇并刪除下一個權值最小的邊,直到某條邊的兩個頂點不是都被標記過,然后將其加入到MST中,將該邊的另一個頂點標記,并將所有與這個頂點相鄰且未被標記的頂點的邊加入隊列。
- 重復上述步驟,直到列表中的元素都被刪除。
package Chap7;
import java.util.*;
public class LazyPrim {
private boolean marked[];
Queue<Edge> edges;
private Queue<Edge> mst;
public LazyPrim(EdgeWeightedGraph<?> graph) {
marked = new boolean[graph.vertexNum()];
edges = new PriorityQueue<>();
mst = new LinkedList<>();
// 從頂點0開始訪問
visit(graph, 0);
// 只要邊還沒被刪除完,就循環
while (!edges.isEmpty()) {
// 優先隊列,將權值最小的選出并刪除
Edge edge = edges.poll();
int v = edge.either();
int w = edge.other(v);
// 這樣的邊會導致成環,跳過
if (marked[v] && marked[w]) {
continue;
}
// 加入到MST中
mst.offer(edge);
// 因為edges中的邊肯定是有一個頂點已經visit過了,但是不知道是either還是other
// 如果v沒被標記,那么訪問它;否則v被標記了,那么w肯定沒被標記(marked[v] && marked[w]的情況已經被跳過了)
if (!marked[v]) {
visit(graph, v);
} else {
visit(graph, w);
}
}
}
private void visit(EdgeWeightedGraph<?> graph, int v) {
marked[v] = true;
for (Edge e : graph.adj(v)) {
// v的鄰接邊中,將另一個頂點未被標記的邊加入列表中。若另一個頂點標記了還加入,就會重復添加
if (!marked[e.other(v)]) {
edges.offer(e);
}
}
}
public Iterable<Edge> edges() {
return mst;
}
public double weight() {
return mst.stream().mapToDouble(Edge::weight).sum();
}
public static void main(String[] args) {
List<String> vertexInfo = Arrays.asList("v0", "v1", "v2", "v3", "v4", " v5", "v6", "v7");
int[][] edges = {{4, 5}, {4, 7}, {5, 7}, {0, 7},
{1, 5}, {0, 4}, {2, 3}, {1, 7}, {0, 2}, {1, 2},
{1, 3}, {2, 7}, {6, 2}, {3, 6}, {6, 0}, {6, 4}};
double[] weight = {0.35, 0.37, 0.28, 0.16, 0.32, 0.38, 0.17, 0.19,
0.26, 0.36, 0.29, 0.34, 0.40, 0.52, 0.58, 0.93};
EdgeWeightedGraph<String> graph = new EdgeWeightedGraph<>(vertexInfo, edges, weight);
LazyPrim prim = new LazyPrim(graph);
System.out.println("MST的所有邊為:" + prim.edges());
System.out.println("最小成本和為:" + prim.weight());
}
}
/* Outputs
MST的所有邊為:[(0-7 0.16), (1-7 0.19), (0-2 0.26), (2-3 0.17), (5-7 0.28), (4-5 0.35), (6-2 0.4)]
最小成本和為:1.81
*/
上述代碼,mst
是一個隊列,用來存放MST中的邊(按照加入的順序)。edges
是一個優先隊列,每次訪問一個點,就將它的鄰接邊中,另一頂點未被標記的那些邊加入。每次要從edges里選出權值最小的邊刪除...解釋代碼始終讓人頭暈,還是結合圖來看下最小生成樹的那些邊是怎么選出來的。
- 從頂點0開始,標記它,并將0-7, 0-2, 0-6, 0-4加入edges。這體現在一開始的
visit(graph, 0)
中 - 此時edges不為空,只要edges不為空,while循環就一直持續下去。選擇并刪除第一個元素(也就是權值最小的邊0-7),加入到MST中。
- 接著訪問頂點7,將其所有鄰接邊加入到edges中,選出1-7這條權值最小的邊,加入到MST中。然后訪問頂點1,將除了1-7外的其他和1鄰接的邊都加入到edges中,從edges中選出權值最小的邊為0-2加入到MST,2的鄰接邊中2-7, 1-2由于會導致成環,所以不加入edges,將2-3, 2-6加入。
- 選擇權值最小的2-3,加入MST。將3-6加入到edges。
- 選出權值最小的5-7加入MST,4-5加入edges。
- 接下來1-3,1-5,2-7由于兩個頂點都被標記過,所以被跳過。選擇4-5加入MST,同時6-4加入edges。
- 1-2, 4-7,0-4連個頂點被標記,跳過。選擇6-2加入MST。至此n個頂點和n - 1條邊都被加入到MST中,最小生成樹完成了。
- 后續工作,edges中剩余的邊,因為兩個頂點都標記過,所以一直跳過直到edges為空,程序結束。
可以看到,我們依次選出了(0-7 0.16), (1-7 0.19), (0-2 0.26), (2-3 0.17), (5-7 0.28), (4-5 0.35), (6-2 0.4)這些邊,他們的總和(最小權值和)為1.81。
Prim算法的優化
上述Prim算法是延時實現,因為它在列表中保留了無效的邊(會導致成環的邊),每次都要判斷并跳過,甚至最小生成樹完成后,還要歷經后續的檢查。究其原因,是因為每次訪問一個頂點幾乎將其所有鄰接邊都加入了edges列表里面。我們對此進行優化,優化的版本稱為Prim算法的即時實現。如下圖
0為起點,一開始,0-4、0-7、0-2、0-6會加入到edges,然后選出權值最小的0-7邊。關鍵來了,延時實現中,會將7-1, 7-2, 7-5, 7-4全加入edges。我們知道7-2和7-4最后因為是無效邊會被跳過。0和7都已經在MST中,那么7-4和0-4兩條邊不可能被同時選出作為MST的邊,否則成環;所以兩者只能有一條有可能成為MST的邊,自然選權值小的那條啊!所以到頂點4的邊應該選7-4,但是0-4已經被加入到edges中,我們要做的就是用7-4取代0-4;再看另一邊,同樣7-2和0-2也是只有一條有可能作為MST的邊,但是已經加入edges的0-2權值本來就比7-2要小,所以7-2不能加入到edges。優化后的Prim算法在這一步中只將7-1和7-5存入,加上一次將0-4修改為7-4的操作。
上面的意思其實就是說:MST以及一個MST外的頂點,我們總是選擇該頂點到MST各個頂點權值最小的那條邊。
基于此思想,我們來實現Prim算法的即時版本,首先要了解到,由于我們要時常更新存入的邊,所以改用一個edgeTo[]
存放到某頂點權值最小的那條邊,distTo[]
存放到該頂點的最小權值,也就是說distTo[w] = edgeTo[w].weight()
。使用一個Map<Integer, Double> minDist
代替原來的edges,用來存放頂點和到該頂點的最小權值,使用Map是因為頂點和到該頂點的最小權值存在一對一的映射關系,而且頂點作為鍵,本來就不存在重復的說法,可以放心用。
package Chap7;
import java.util.*;
public class Prim {
private boolean marked[];
private Edge[] edgeTo;
private double[] distTo;
private Map<Integer, Double> minDist;
public Prim(EdgeWeightedGraph<?> graph) {
marked = new boolean[graph.vertexNum()];
edgeTo = new Edge[graph.vertexNum()];
distTo = new double[graph.vertexNum()];
minDist = new HashMap<>();
// 初始化distTo,distTo[0]不會被賦值,默認0.0正好符合我們的要求,使余下的每個值都為正無窮,
for (int i = 1; i < graph.vertexNum(); i++) {
distTo[i] = Double.POSITIVE_INFINITY; // 1.0 / 0.0為INFINITY
}
visit(graph, 0);
while (!minDist.isEmpty()) {
visit(graph, delMin());
}
}
private int delMin() {
Set<Map.Entry<Integer, Double>> entries = minDist.entrySet();
Map.Entry<Integer, Double> min = entries.stream().min(Comparator.comparing(Map.Entry::getValue)).get();
int key = min.getKey();
minDist.remove(key);
return key;
}
private void visit(EdgeWeightedGraph<?> graph, int v) {
marked[v] = true;
for (Edge e: graph.adj(v)) {
int w = e.other(v);
if (marked[w]) {
continue;
}
if (e.weight() < distTo[w]) {
distTo[w] = e.weight();
edgeTo[w] = e;
if (minDist.containsKey(w)) {
minDist.replace(w, distTo[w]);
} else {
minDist.put(w, distTo[w]);
}
}
}
}
public Iterable<Edge> edges() {
List<Edge> edges = new ArrayList<>();
edges.addAll(Arrays.asList(edgeTo).subList(1, edgeTo.length));
return edges;
}
public double weight() {
return Arrays.stream(distTo).reduce(0.0, Double::sum);
}
public static void main(String[] args) {
List<String> vertexInfo = Arrays.asList("v0", "v1", "v2", "v3", "v4", " v5", "v6", "v7");
int[][] edges = {{4, 5}, {4, 7}, {5, 7}, {0, 7},
{1, 5}, {0, 4}, {2, 3}, {1, 7}, {0, 2}, {1, 2},
{1, 3}, {2, 7}, {6, 2}, {3, 6}, {6, 0}, {6, 4}};
double[] weight = {0.35, 0.37, 0.28, 0.16, 0.32, 0.38, 0.17, 0.19,
0.26, 0.36, 0.29, 0.34, 0.40, 0.52, 0.58, 0.93};
EdgeWeightedGraph<String> graph = new EdgeWeightedGraph<>(vertexInfo, edges, weight);
Prim prim = new Prim(graph);
System.out.println("MST的所有邊為:" + prim.edges());
System.out.println("最小成本和為:" + prim.weight());
}
}
/* Outputs
MST的所有邊為:[(1-7 0.19), (0-2 0.26), (2-3 0.17), (4-5 0.35), (5-7 0.28), (6-2 0.4), (0-7 0.16)]
最小成本和為:1.8099999999999998
*/
一開始將distTo[]
初始化,除了distTo[0]
因為永遠也訪問不到,其余都初始化為正無窮。然后開始訪問頂點0,visit
方法基本和延時實現差不多,只是多了判斷,如果有到w權值更小的邊,就更新edgeTo
數組和distTo
數組,現在edgeTo數組存的是到頂點w權值最小的邊,除了edgeTo[0]
永遠不會被訪問到,其值為null外,里面存的分別是到頂點1、2、3...n -1的權值最小的邊,共n - 1條。只要始終維護這個數組,保證到程序結束時到每個頂點的還是權值最小的那條邊,那么由這些邊組成的就是我們要求的最小生成樹。上面有提到,加了判斷后可避免一些遲早會成無效的邊加入。
再看delMin()
方法,該方法選出字典中value(也就是權值)最小的那個鍵值對,獲得key(也就是頂點),刪除該鍵值對,緊接著訪問key頂點。和延時實現操作一樣。
最后字典為空時,edgeTo數組也隨之確定好了,不存在后續檢查步驟。輸出那么一長串,是double的鍋,精確值其實為1.81,使用BigDecimal
可以得到精確值。
我們來詳細地走一遍。
- 首先訪問頂點0,邊0-7, 0-2, 0-4, 0-6被加入Map中,因為這些邊是目前(唯一)MST外頂點與MST連接的最小權值邊,也就是說edgeTo[7] = 0-7、edgeTo[2] = 0-2、edgeTo[4] = 0-4、edgeTo[6] = 0-6。
- 然后刪除權值最小的邊0-7,并開始訪問頂點7,將7-5, 7-1加入Map,7-4因為比0-4權值小(更靠近MST),所以edgeTo[4] 改為 7-4;7-2不加入Map因為0-2的權值本來就比7-2小。現在edgeTo[5] = 7-5, edgeTo[1] = 7-1;
- 刪除邊7-1,并訪問頂點1。將1-3加入Map,1-5不加入因為7-5的權值本來就比它小。edgeTo[3] = 1-3
- 刪除0-2,并訪問頂點2,2-3的權值比1-3小,所以更新edgeTo[3] = 2-3,2-6權值小于0-6,更新edgeTo[6] = 2-6
- 刪除2-3,并訪問頂點3,3-6不加入Map因為2-6權值本來就比它小。
- 刪除5-7,5-4權值比7-4小,更新edgeTo[4] = 5-4
- 刪除4-5,訪問頂點4,4-6不加入Map,因為2-6的權值本來就比它小
- 刪除6-2,至此所有頂點都已訪問過,且Map為空,最小生成樹完成。
Prim算法,就好比:從一株小樹苗開始,不斷從附近找到離它最近的一根樹枝,安在自己身上,小樹慢慢長大成大樹,最后找到n-1條樹枝后就成了最小生成樹。
上面說的太有畫面感了...由于篇幅原因,Kruskal算法在下一節中介紹。
by @sunhaiyu
2017.9.21