讀完本文,你不僅學會了演算法套路,還可以順便去 LeetCode 上拿下如下題目:
- 以圖判樹(中等)
- 最低成本聯通所有城市(中等)
- 連線所有點的最小費用(中等)
-----------
圖論中知名度比較高的演算法應該就是 Dijkstra 最短路徑演算法,環檢測和拓撲排序,二分圖判定演算法 以及今天要講的最小生成樹(Minimum Spanning Tree)演算法了。
最小生成樹演算法主要有 Prim 演算法(普里姆演算法)和 Kruskal 演算法(克魯斯卡爾演算法)兩種,這兩種演算法雖然都運用了貪心思想,但從實現上來說差異還是蠻大的,本文先來講 Kruskal 演算法,Prim 演算法另起一篇文章寫。
Kruskal 演算法其實很容易理解和記憶,其關鍵是要熟悉並查集演算法,如果不熟悉,建議先看下前文 Union-Find 並查集演算法。
接下來,我們從最小生成樹的定義說起。
什麼是最小生成樹
先說「樹」和「圖」的根本區別:樹不會包含環,圖可以包含環。
如果一幅圖沒有環,完全可以拉伸成一棵樹的模樣。說的專業一點,樹就是「無環連通圖」。
那麼什麼是圖的「生成樹」呢,其實按字面意思也好理解,就是在圖中找一棵包含圖中的所有節點的樹。專業點說,生成樹是含有圖中所有頂點的「無環連通子圖」。
容易想到,一幅圖可以有很多不同的生成樹,比如下面這幅圖,紅色的邊就組成了兩棵不同的生成樹:
對於加權圖,每條邊都有權重,所以每棵生成樹都有一個權重和。比如上圖,右側生成樹的權重和顯然比左側生成樹的權重和要小。
那麼最小生成樹很好理解了,所有可能的生成樹中,權重和最小的那棵生成樹就叫「最小生成樹」。
PS:一般來說,我們都是在無向加權圖中計算最小生成樹的,所以使用最小生成樹演算法的現實場景中,圖的邊權重一般代表成本、距離這樣的標量。
在講 Kruskal 演算法之前,需要回顧一下 Union-Find 並查集演算法。
Union-Find 並查集演算法
剛才說了,圖的生成樹是含有其所有頂點的「無環連通子圖」,最小生成樹是權重和最小的生成樹。
那麼說到連通性,相信老讀者應該可以想到 Union-Find 並查集演算法,用來高效處理圖中聯通分量的問題。
前文 Union-Find 並查集演算法詳解 詳細介紹了 Union-Find 演算法的實現原理,主要運用 size
陣列和路徑壓縮技巧提高連通分量的判斷效率。
如果不瞭解 Union-Find 演算法的讀者可以去看前文,為了節約篇幅,本文直接給出 Union-Find 演算法的實現:
class UF {
// 連通分量個數
private int count;
// 儲存一棵樹
private int[] parent;
// 記錄樹的「重量」
private int[] size;
// n 為圖中節點的個數
public UF(int n) {
this.count = n;
parent = new int[n];
size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
}
// 將節點 p 和節點 q 連通
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
// 小樹接到大樹下面,較平衡
if (size[rootP] > size[rootQ]) {
parent[rootQ] = rootP;
size[rootP] += size[rootQ];
} else {
parent[rootP] = rootQ;
size[rootQ] += size[rootP];
}
// 兩個連通分量合併成一個連通分量
count--;
}
// 判斷節點 p 和節點 q 是否連通
public boolean connected(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
return rootP == rootQ;
}
// 返回節點 x 的連通分量根節點
private int find(int x) {
while (parent[x] != x) {
// 進行路徑壓縮
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
// 返回圖中的連通分量個數
public int count() {
return count;
}
}
前文 Union-Find 並查集演算法運用 介紹過 Union-Find 演算法的一些演算法場景,而它在 Kruskal 演算法中的主要作用是保證最小生成樹的合法性。
因為在構造最小生成樹的過程中,你首先得保證生成的那玩意是棵樹(不包含環)對吧,那麼 Union-Find 演算法就是幫你幹這個事兒的。
怎麼做到的呢?先來看看力扣第 261 題「以圖判樹」,我描述下題目:
給你輸入編號從 0
到 n - 1
的 n
個結點,和一個無向邊列表 edges
(每條邊用節點二元組表示),請你判斷輸入的這些邊組成的結構是否是一棵樹。
函式簽名如下:
boolean validTree(int n, int[][] edges);
比如輸入如下:
n = 5
edges = [[0,1], [0,2], [0,3], [1,4]]
這些邊構成的是一棵樹,演算法應該返回 true:
但如果輸入:
n = 5
edges = [[0,1],[1,2],[2,3],[1,3],[1,4]]
形成的就不是樹結構了,因為包含環:
對於這道題,我們可以思考一下,什麼情況下加入一條邊會使得樹變成圖(出現環)?
顯然,像下面這樣新增邊會出現環:
而這樣新增邊則不會出現環:
總結一下規律就是:
對於新增的這條邊,如果該邊的兩個節點本來就在同一連通分量裡,那麼新增這條邊會產生環;反之,如果該邊的兩個節點不在同一連通分量裡,則新增這條邊不會產生環。
而判斷兩個節點是否連通(是否在同一個連通分量中)就是 Union-Find 演算法的拿手絕活,所以這道題的解法程式碼如下:
// 判斷輸入的若干條邊是否能構造出一棵樹結構
boolean validTree(int n, int[][] edges) {
// 初始化 0...n-1 共 n 個節點
UF uf = new UF(n);
// 遍歷所有邊,將組成邊的兩個節點進行連線
for (int[] edge : edges) {
int u = edge[0];
int v = edge[1];
// 若兩個節點已經在同一連通分量中,會產生環
if (uf.connected(u, v)) {
return false;
}
// 這條邊不會產生環,可以是樹的一部分
uf.union(u, v);
}
// 要保證最後只形成了一棵樹,即只有一個連通分量
return uf.count() == 1;
}
class UF {
// 見上文程式碼實現
}
如果你能夠看懂這道題的解法思路,那麼掌握 Kruskal 演算法就很簡單了。
Kruskal 演算法
所謂最小生成樹,就是圖中若干邊的集合(我們後文稱這個集合為 mst
,最小生成樹的英文縮寫),你要保證這些邊:
1、包含圖中的所有節點。
2、形成的結構是樹結構(即不存在環)。
3、權重和最小。
有之前題目的鋪墊,前兩條其實可以很容易地利用 Union-Find 演算法做到,關鍵在於第 3 點,如何保證得到的這棵生成樹是權重和最小的。
這裡就用到了貪心思路:
將所有邊按照權重從小到大排序,從權重最小的邊開始遍歷,如果這條邊和 mst
中的其它邊不會形成環,則這條邊是最小生成樹的一部分,將它加入 mst
集合;否則,這條邊不是最小生成樹的一部分,不要把它加入 mst
集合。
這樣,最後 mst
集合中的邊就形成了最小生成樹,下面我們看兩道例題來運用一下 Kruskal 演算法。
第一題是力扣第 1135 題「最低成本聯通所有城市」,這是一道標準的最小生成樹問題:
每座城市相當於圖中的節點,連通城市的成本相當於邊的權重,連通所有城市的最小成本即是最小生成樹的權重之和。
int minimumCost(int n, int[][] connections) {
// 城市編號為 1...n,所以初始化大小為 n + 1
UF uf = new UF(n + 1);
// 對所有邊按照權重從小到大排序
Arrays.sort(connections, (a, b) -> (a[2] - b[2]));
// 記錄最小生成樹的權重之和
int mst = 0;
for (int[] edge : connections) {
int u = edge[0];
int v = edge[1];
int weight = edge[2];
// 若這條邊會產生環,則不能加入 mst
if (uf.connected(u, v)) {
continue;
}
// 若這條邊不會產生環,則屬於最小生成樹
mst += weight;
uf.union(u, v);
}
// 保證所有節點都被連通
// 按理說 uf.count() == 1 說明所有節點被連通
// 但因為節點 0 沒有被使用,所以 0 會額外佔用一個連通分量
return uf.count() == 2 ? mst : -1;
}
class UF {
// 見上文程式碼實現
}
這道題就解決了,整體思路和上一道題非常類似,你可以認為樹的判定演算法加上按權重排序的邏輯就變成了 Kruskal 演算法。
再來看看力扣第 1584 題「連線所有點的最小費用」:
比如題目給的例子:
points = [[0,0],[2,2],[3,10],[5,2],[7,0]]
演算法應該返回 20,按如下方式連通各點:
很顯然這也是一個標準的最小生成樹問題:每個點就是無向加權圖中的節點,邊的權重就是曼哈頓距離,連線所有點的最小費用就是最小生成樹的權重和。
所以解法思路就是先生成所有的邊以及權重,然後對這些邊執行 Kruskal 演算法即可:
int minCostConnectPoints(int[][] points) {
int n = points.length;
// 生成所有邊及權重
List<int[]> edges = new ArrayList<>();
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
int xi = points[i][0], yi = points[i][1];
int xj = points[j][0], yj = points[j][1];
// 用座標點在 points 中的索引表示座標點
edges.add(new int[] {
i, j, Math.abs(xi - xj) + Math.abs(yi - yj)
});
}
}
// 將邊按照權重從小到大排序
Collections.sort(edges, (a, b) -> {
return a[2] - b[2];
});
// 執行 Kruskal 演算法
int mst = 0;
UF uf = new UF(n);
for (int[] edge : edges) {
int u = edge[0];
int v = edge[1];
int weight = edge[2];
// 若這條邊會產生環,則不能加入 mst
if (uf.connected(u, v)) {
continue;
}
// 若這條邊不會產生環,則屬於最小生成樹
mst += weight;
uf.union(u, v);
}
return mst;
}
這道題做了一個小的變通:每個座標點是一個二元組,那麼按理說應該用五元組表示一條帶權重的邊,但這樣的話不便執行 Union-Find 演算法;所以我們用 points
陣列中的索引代表每個座標點,這樣就可以直接複用之前的 Kruskal 演算法邏輯了。
通過以上三道演算法題,相信你已經掌握了 Kruskal 演算法,主要的難點是利用 Union-Find 並查集演算法向最小生成樹中新增邊,配合排序的貪心思路,從而得到一棵權重之和最小的生成樹。
最後說下 Kruskal 演算法的複雜度分析:
假設一幅圖的節點個數為 V
,邊的條數為 E
,首先需要 O(E)
的空間裝所有邊,而且 Union-Find 演算法也需要 O(V)
的空間,所以 Kruskal 演算法總的空間複雜度就是 O(V + E)
。
時間複雜度主要耗費在排序,需要 O(ElogE)
的時間,Union-Find 演算法所有操作的複雜度都是 O(1)
,套一個 for 迴圈也不過是 O(E)
,所以總的時間複雜度為 O(ElogE)
。
本文就到這裡,關於這種貪心思路的簡單證明以及 Prim 最小生成樹演算法,我們留到後續的文章再聊。
_____________
檢視更多優質演算法文章 點選我的頭像,手把手帶你刷力扣,致力於把演算法講清楚!我的 演算法教程 已經獲得 90k star,歡迎點贊!