應用場景-公交站問題
某城市新增 7 個站點(A, B, C, D, E, F, G) ,現在 需要修路把 7 個站點連通,各個站點的距離用邊線表示(權) ,比如 A – B 距離 12公里 問:如何修路保證 各個站點都能連通,並且 總的修建公路總里程最短?
如上圖所示:要求和前面的普利姆演算法中的修路問題是一樣的要求,只是換了一個背景。
克魯斯卡爾演算法介紹
克魯斯卡爾(Kruskal)演算法,是用來求加權連通圖的最小生成樹的演算法。
基本思想:按照權值從小到大的順序選擇 n-1
條邊,並保證這 n-1
條邊不構成迴路
具體做法:
- 首先構造一個只含 n 個頂點的森林
- 然後依權值從小到大從連通網中選擇邊加入到森林中,並使森林中不產生迴路,直至森林變成一棵樹為止
克魯斯卡爾演算法圖解
在含有 n 個頂點的連通圖中選擇 n-1 條邊,構成一棵極小連通子圖,並使該連通子圖中 n-1 條邊上權值之和達到最小,則稱其為連通網的最小生成樹。
例如,對於如上圖 G4 所示的連通網可以有多棵權值總和不相同的生成樹。
有多種不同的連通方式,但是哪一種權值才是最優的呢?下面是克魯斯卡爾演算法的圖解步驟:
以上圖 G4 為例,來對克魯斯卡爾進行演示(假設,用陣列 R 儲存最小生成樹結果)。
-
第 1 步:將邊
E,F [2]
加入 R 中。邊
E,F
的權值最小,因此將它加入到最小生成樹結果 R 中。 -
第 2 步:將邊
C,D [3]
加入 R 中。上一步操作之後,邊
C,D
的權值最小,因此將它加入到最小生成樹結果 R 中。 -
第 3 步:將邊
D,E [4]
加入 R 中。同理,權值最小
-
第 4 步:將邊
B,F [7]
加入 R 中。上一步操作之後,邊
C,E [5]
的權值最小,但C,E
會和已有的邊構成迴路;因此,跳過邊C,E
。同理,跳過邊C,F [6]
。將邊B,F
加入到最小生成樹結果R中。 -
第 5 步:將邊
E,G [8]
加入 R 中。 同理 -
第 6 步:將邊
A,B [12]
加入 R 中。上一步操作之後,邊
F,G [9]
的權值最小,但F,G
會和已有的邊構成迴路;因此,跳過邊F,G
。同理,跳過邊B,C [10]
。將邊A,B
加入到最小生成樹結果R中。 此時,最小生成樹構造完成!它包括的邊依次是:<E,F> <C,D> <D,E> <B,F> <E,G> <A,B>
總里程為 36。
動圖:
克魯斯卡爾演算法分析
根據前面介紹的克魯斯卡爾演算法的基本思想和做法,我們能夠了解到,克魯斯卡爾演算法重點需要解決的以下兩個問題:
-
對圖的所有邊按照權值大小進行排序。
此問題採用排序演算法進行排序即可
-
將邊新增到最小生成樹中時,怎麼樣 判斷是否形成了迴路。
處理方式是:記錄頂點在 最小生成樹 中的終點,頂點的終點是 在最小生成樹中與它連通的最大頂點。然後每次需要將一條邊新增到最小生存樹時,判斷該邊的兩個頂點的終點是否重合,重合的話則會構成迴路。
如何判斷迴路?
在將 E,F
、 C,D
D,E
加入到最小生成樹 R 中之後,這幾條邊的頂點就都有了終點:
- C 的終點是 F。
- D 的終點是 F。
- E 的終點是 F。
- F 的終點是 F。
終點的說明:(備註:光看這個沒有接觸過該演算法的不明白,在程式碼後面有詳細的解釋)
-
就是將所有頂點按照從小到大的順序排列好之後;某個頂點的終點就是 與它連通的最大頂點。
難道是說 CD、DE、EF 他們是一條線,從小到大,所以他們的終點都是 F?
-
因此,接下來,雖然
C,E
是權值最小的邊。但是 C 和 E 的終點都是 F,即它們的終點相同,因此,將C,E
加入最小生成樹的話,會形成迴路。這就是判斷迴路的方式。也就是說,我們加入的邊的兩個頂點 不能都指向同一個終點,否則將構成迴路。【後面有程式碼說明】
程式碼實現
無向圖構建
這裡使用上一章的普利姆演算法中實現的無向圖構建,簡單修改下
/**
* 克魯斯而
*/
public class KruskalCase {
// 不連通的預設值:0 則代表同一個點
int INF = 100000;
/**
* 圖:首先需要有一個帶權的連通無向圖
*/
class MGraph {
int vertex; // 頂點個數
int[][] weights; // 鄰接矩陣
char[] datas; // 村莊資料
int edgeNum; // 共有多少條邊
/**
* @param vertex 村莊數量, 會按照數量,按順序生成村莊,如 A、B、C...
* @param weights 需要你自己定義好那些點是連通的,那些不是連通的
*/
public MGraph(int vertex, int[][] weights) {
this.vertex = vertex;
this.weights = weights;
this.datas = new char[vertex];
for (int i = 0; i < vertex; i++) {
// 大寫字母 A 從 65 開始
datas[i] = (char) (65 + i);
}
// 計算有多少條邊
for (int i = 0; i < weights.length; i++) {
/*
A B C D E F G
A 0 12 100000 100000 100000 16 14
B 12 0 10 100000 100000 7 100000
j = i + 1:比如:
i=0,j=1, 那麼就是 A,B 從而跳過了 A,A
i=1,j=2, 那麼就是 B,C 從而跳過了 B,A B,B
那麼含義就出來了:跳過雙向邊的統計,也跳過自己對自己值得為 0 的統計
*/
for (int j = i + 1; j < weights.length; j++) {
if (weights[i][j] != INF) {
edgeNum++;
}
}
}
}
public void show() {
System.out.printf("%-8s", " ");
for (char vertex : datas) {
// 控制字串輸出長度:少於 8 位的,右側用空格補位
System.out.printf("%-8s", vertex + " ");
}
System.out.println();
for (int i = 0; i < weights.length; i++) {
System.out.printf("%-8s", datas[i] + " ");
for (int j = 0; j < weights.length; j++) {
System.out.printf("%-8s", weights[i][j] + " ");
}
System.out.println();
}
}
}
@Test
public void mGraphTest() {
int[][] weights = new int[][]{
// A B C D E F G
/*A*/ {0, 12, INF, INF, INF, 16, 14},
/*B*/ {12, 0, 10, INF, INF, 7, INF},
/*C*/ {INF, 10, 0, 3, 5, 6, INF},
/*D*/ {INF, INF, 3, 0, 4, INF, INF},
/*E*/ {INF, INF, 5, 4, 0, 2, 8},
/*F*/ {16, 07, INF, INF, 2, 0, 9},
/*G*/ {14, INF, INF, INF, 8, 9, INF}
};
MGraph mGraph = new MGraph(7, weights);
mGraph.show();
System.out.printf("共有 %d 條邊", mGraph.edgeNum);
}
}
測試輸出
A B C D E F G
A 0 12 100000 100000 100000 16 14
B 12 0 10 100000 100000 7 100000
C 100000 10 0 3 5 6 100000
D 100000 100000 3 0 4 100000 100000
E 100000 100000 5 4 0 2 8
F 16 7 100000 100000 2 0 9
G 14 100000 100000 100000 8 9 100000
共有 12 條邊
克魯斯卡爾演算法實現
// 不連通的預設值:0 則代表同一個點
int INF = 100000;
/**
* 圖:首先需要有一個帶權的連通無向圖
*/
class MGraph {
int vertex; // 頂點個數
int[][] weights; // 鄰接矩陣
char[] datas; // 村莊資料
int edgeNum; // 共有多少條邊
/**
* @param vertex 村莊數量, 會按照數量,按順序生成村莊,如 A、B、C...
* @param weights 需要你自己定義好那些點是連通的,那些不是連通的
*/
public MGraph(int vertex, int[][] weights) {
this.vertex = vertex;
this.weights = weights;
this.datas = new char[vertex];
for (int i = 0; i < vertex; i++) {
// 大寫字母 A 從 65 開始
datas[i] = (char) (65 + i);
}
// 計算有多少條邊
for (int i = 0; i < weights.length; i++) {
/*
A B C D E F G
A 0 12 100000 100000 100000 16 14
B 12 0 10 100000 100000 7 100000
j = i + 1:比如:
i=0,j=1, 那麼就是 A,B 從而跳過了 A,A
i=1,j=2, 那麼就是 B,C 從而跳過了 B,A B,B
那麼含義就出來了:跳過雙向邊的統計,也跳過自己對自己值得為 0 的統計
*/
for (int j = i + 1; j < weights.length; j++) {
if (weights[i][j] != INF) {
edgeNum++;
}
}
}
}
public void show() {
System.out.printf("%-8s", " ");
for (char vertex : datas) {
// 控制字串輸出長度:少於 8 位的,右側用空格補位
System.out.printf("%-8s", vertex + " ");
}
System.out.println();
for (int i = 0; i < weights.length; i++) {
System.out.printf("%-8s", datas[i] + " ");
for (int j = 0; j < weights.length; j++) {
System.out.printf("%-8s", weights[i][j] + " ");
}
System.out.println();
}
}
}
/**
* 將無向圖中的邊 轉換成物件陣列
*
* @param graph
* @return
*/
public Edata[] convertEdatas(MGraph graph) {
Edata[] datas = new Edata[graph.edgeNum];
int[][] weights = graph.weights;
char[] vertexs = graph.datas;
int index = 0;
for (int i = 0; i < weights.length; i++) {
for (int j = i + 1; j < weights.length; j++) {
if (weights[i][j] != INF) {
datas[index++] = new Edata(vertexs[i], vertexs[j], weights[i][j]);
}
}
}
return datas;
}
/**
* 將邊按權值從小到大排序
*
* @param edata
*/
public void sort(Edata[] edata) {
Arrays.sort(edata, Comparator.comparingInt(o -> o.weight));
}
//演算法主體
public Edata[] kruskal(MGraph mGraph, Edata[] edatas) {
// 存放結果,陣列最大容量為所有邊的容量
Edata[] rets = new Edata[mGraph.edgeNum];
int retsIndex = 0;
/*
按照演算法思路:
記錄頂點在 **最小生成樹** 中的終點,頂點的終點是 **在最小生成樹中與它連通的最大頂點**。
然後每次需要將一條邊新增到最小生存樹時,判斷該邊的兩個頂點的終點是否重合,重合的話則會構成迴路。
*/
// 用於存所有的終點:該陣列中的內容隨著被選擇的邊增加,終點也會不斷的增加
int[] ends = new int[mGraph.edgeNum];//解釋:陣列下標含義為起點索引,ends[i] 為起點i的終點索引
// 對所有邊進行遍歷
for (Edata edata : edatas) {
// 獲取這條邊的兩個頂點下標:
// 第一次:E,F -> 4,5
int p1 = getPosition(mGraph.datas, edata.start);
int p2 = getPosition(mGraph.datas, edata.end);
// 獲取對應頂點的 終點
/*
第 1 次:E,F -> 4,5
ends = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
m: 獲取 4 的終點:ends[4] 為 0,說明此點 還沒有一個終點,那麼就返回它自己 4
n: 獲取 5 的終點:同上
m != n , 選擇這一條邊。那麼此時 E,F -> 4,5 已有邊的終點就是 5
ends = [0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0]
終點表中讀法: ↑ index=4,value=5 那麼表示 4 這個頂點的終點為 5
第 2 次:C,D -> 2,3
ends = [0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0]
m: 獲取 2 的終點,ends[2] = 0,說明此點 還沒有一個終點,則返回它自己 2
n: 獲取 3 的終點
m != n , 選擇這一條邊。那麼此時 C,D -> 2,3 已有邊的終點就是 3
ends = [0, 0, 3, 0, 5, 0, 0, 0, 0, 0, 0, 0]
第 3 次:D,E -> 3,4
ends = [0, 0, 3, 0, 5, 0, 0, 0, 0, 0, 0, 0]
m: 獲取 3 的終點,ends[3] = 0,說明此點 還沒有一個終點,則返回它自己 3
n: 獲取 4 的終點,!! 前面第一次,已經將 4 的終點 5 放進來了
那麼將獲取到的終點為 5,getEnd() 還會嘗試去獲取 5 的終點,發現為 0,則 4 的終點是 5
m != n -> 3 != 5 , 選擇這一條邊。那麼此時 D,E -> 3,4 已有邊的終點就是 5
ends = [0, 0, 3, 5, 5, 0, 0, 0, 0, 0, 0, 0]
*/
int m = getEnd(ends, p1);
int n = getEnd(ends, p2);
//判斷終點是否重合
if (m != n) {
ends[m] = n;
rets[retsIndex++] = edata;
}
}
return rets;
}
/**
* 獲取該頂點的:終點
* 這個演算法值得好好想想,下面有解析
* @param ends
* @param vertexIndex
* @return
*/
private int getEnd(int[] ends, int vertexIndex) {
int temp = vertexIndex;
while (ends[temp] != 0) {
temp = ends[temp];
}
return temp;
}
/**
* 獲取此頂點的下標
*
* @param vertexs
* @param vertex
* @return
*/
private int getPosition(char[] vertexs, char vertex) {
for (int i = 0; i < vertexs.length; i++) {
if (vertexs[i] == vertex) {
return i;
}
}
return 0;
}
/**
* 描述一條邊
*/
class Edata {
// 一條邊的開始和結束,比如 A,B
char start;
char end;
int weight; // 這條邊的權值
public Edata(char start, char end, int weight) {
this.start = start;
this.end = end;
this.weight = weight;
}
@Override
public String toString() {
return start + "," + end + " [" + weight + "]";
}
}
@Test
public void kruskalTest() {
int[][] weights = new int[][]{
// A B C D E F G
/*A*/ {0, 12, INF, INF, INF, 16, 14},
/*B*/ {12, 0, 10, INF, INF, 7, INF},
/*C*/ {INF, 10, 0, 3, 5, 6, INF},
/*D*/ {INF, INF, 3, 0, 4, INF, INF},
/*E*/ {INF, INF, 5, 4, 0, 2, 8},
/*F*/ {16, 07, INF, INF, 2, 0, 9},
/*G*/ {14, INF, INF, INF, 8, 9, INF}
};
MGraph mGraph = new MGraph(7, weights);
mGraph.show();
System.out.printf("共有 %d 條邊 \n", mGraph.edgeNum);
System.out.println("邊陣列為:");
Edata[] edatas = convertEdatas(mGraph);
printEdatas(edatas);
System.out.println("排序後的邊陣列為:");
sort(edatas);
printEdatas(edatas);
Edata[] kruskal = kruskal(mGraph, edatas);
System.out.println("克魯斯卡爾演算法計算結果的邊為:");
printEdatas(kruskal);
int total = Arrays.stream(kruskal).filter(item -> item != null).mapToInt(item -> item.weight).sum();
System.out.println("總里程數為:" + total);
}
private void printEdatas(Edata[] edatas) {
for (Edata edata : edatas) {
if (edata == null) {
continue;
}
System.out.println(edata);
}
}
測試輸出
A B C D E F G
A 0 12 100000 100000 100000 16 14
B 12 0 10 100000 100000 7 100000
C 100000 10 0 3 5 6 100000
D 100000 100000 3 0 4 100000 100000
E 100000 100000 5 4 0 2 8
F 16 7 100000 100000 2 0 9
G 14 100000 100000 100000 8 9 100000
共有 12 條邊
邊陣列為:
A,B [12]
A,F [16]
A,G [14]
B,C [10]
B,F [7]
C,D [3]
C,E [5]
C,F [6]
D,E [4]
E,F [2]
E,G [8]
F,G [9]
排序後的邊陣列為:
E,F [2]
C,D [3]
D,E [4]
C,E [5]
C,F [6]
B,F [7]
E,G [8]
F,G [9]
B,C [10]
A,B [12]
A,G [14]
A,F [16]
克魯斯卡爾演算法計算結果的邊為:
E,F [2]
C,D [3]
D,E [4]
B,F [7]
E,G [8]
A,B [12]
總里程數為:36
獲取一個點的終點解釋
/**
* 獲取該頂點的:終點
*
* @param ends
* @param vertexIndex
* @return
*/
private int getEnd(int[] ends, int vertexIndex) {
int temp = vertexIndex;
while (ends[temp] != 0) {
temp = ends[temp];
}
return temp;
}
....
int p1 = getPosition(mGraph.datas, edata.start);
int p2 = getPosition(mGraph.datas, edata.end);
int m = getEnd(ends, p1);
int n = getEnd(ends, p2);
if (m != n) {
ends[m] = n;
rets[retsIndex++] = edata;
}
第 1 次:E,F -> 4,5
ends = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
m: 獲取 4 的終點:ends[4] 為 0,說明此點 還沒有一個終點,那麼就返回它自己 4
n: 獲取 5 的終點:同上
m != n , 選擇這一條邊。那麼此時 E,F -> 4,5 已有邊的終點就是 5
ends = [0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0]
終點表中讀法: ↑ index=4,value=5 那麼表示 4 這個頂點的終點為 5
第 2 次:C,D -> 2,3
ends = [0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0]
m: 獲取 2 的終點,ends[2] = 0,說明此點 還沒有一個終點,則返回它自己 2
n: 同理,獲取 3 的終點
m != n , 選擇這一條邊。那麼此時 C,D -> 2,3 已有邊的終點就是 3
ends = [0, 0, 3, 0, 5, 0, 0, 0, 0, 0, 0, 0]
第 3 次:D,E -> 3,4
ends = [0, 0, 3, 0, 5, 0, 0, 0, 0, 0, 0, 0]
m: 獲取 3 的終點,ends[3] = 0,說明此點 還沒有一個終點,則返回它自己 3
n: 獲取 4 的終點,!! 前面第一次,已經將 4 的終點 5 放進來了
那麼將獲取到的終點為 5,getEnd() 還會嘗試去獲取 5 的終點,發現為 0,返回 5,則 4 的終點是 5
m != n -> 3 != 5 , 選擇這一條邊。那麼此時 D,E -> 3,4 已有邊的終點就是 5
ends = [0, 0, 3, 5, 5, 0, 0, 0, 0, 0, 0, 0]
從以上的步驟執行來看,這個終點的判定是這樣的:
-
E,F -> 4,5
由於 終點列表中沒有,那麼第一條邊的 起點 E 的終點就是 F記為:
ends = [0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0] 終點表中讀法: ↑ index=4,value=5 那麼表示 4 這個頂點的終點為 5
-
C,D -> 2,3
[0, 0, 3, 0, 5, 0, 0, 0, 0, 0, 0, 0]
選擇這一條邊後,終點列表中成了上面這樣,如何理解? 看上圖:這兩條邊新增之後,他們並不能連通,所以此時的 ends 列表裡面的含義就是這樣的:
- 2:3 , C 的終點是 D,因為這條邊暫時沒有和其他邊連線
- 4:5,E 的終點是 F,因為這條邊暫時沒有和其他邊連線
-
D,E -> 3,4
此時:可以看到,加入我們要選擇這條邊,那麼 DE 就會和 EF 相連,獲取邊的終點時
[0, 0, 3, 0, 5, 0, 0, 0, 0, 0, 0, 0]
-
先獲取 D 的終點:
ends[3]=0
,返回它自己; -
獲取 E 的終點時:
edns[4]=5
,你會發現,其實 E 它已經和 F 連通了 -
那麼此時:往終點列表裡面存放的則是 D 的終點是 F,而不是 E (這裡可以看到獲取終點的演算法中的那個迴圈的妙用了)
ends = [0, 0, 3, 5, 5, 0, 0, 0, 0, 0, 0, 0]
-
-
最終選擇完後的終點列表中的資料為
[6, 5, 3, 5, 5, 6, 0, 0, 0, 0, 0, 0] A B C D E F G 0 1 2 3 4 5 6
- A 的終點是 G
- B 的終點是 F → G
- C 的終點是 D → F → G
- D 的終點是 F → G
- E 的終點是 F → G
- F 的終點是 G
選擇看明白了嗎?它利用這一個陣列,對於每次新增的邊 和 已經存在的邊,計算出,新增加的邊的起始點,對應的終點是什麼。 當整個最小生成樹都完成的時候,他們最終所對應的終點都是一樣的。