Union-Find 算法,也就是常說的並查集算法,主要是解決圖論中「動態連通性」問題的。
什么是動態連通性?
對於一幅圖中,各個節點是否是相連的?如果不相連,就把他們連起來。涉及到幾個操作:
union:連接節點p和節點q
find:查找節點p的父節點
connected:判斷節點p和節點q是否是相連的
count:返回圖中有多少個相連的樹(連通分量)
主要API
class UF {
/* 將 p 和 q 連接 */
public void union(int p, int q);
/* 判斷 p 和 q 是否連通 */
public boolean connected(int p, int q);
/* 返回圖中有多少個連通分量 */
public int count();
}
連通的性質
1、自反性:節點p
和p
是連通的。
2、對稱性:如果節點p
和q
連通,那么q
和p
也連通。
3、傳遞性:如果節點p
和q
連通,q
和r
連通,那么p
和r
也連通。
比如說之前那幅圖,0~9 任意兩個不同的點都不連通,調用connected
都會返回 false,連通分量為 10 個。
如果現在調用union(0, 1)
,那么 0 和 1 被連通,連通分量降為 9 個。
再調用union(1, 2)
,這時 0,1,2 都被連通,調用connected(0, 2)
也會返回 true,連通分量變為 8 個。
判斷這種連通性關系有什么用呢?
比如說編譯器判斷同一個變量的不同引用,比如社交網絡中的朋友圈計算等等。比如JVM中,利用可達性分析算法標記存活的變量。
Union-Find 算法的關鍵就在於union
和connected
函數的效率。那么用什么模型來表示這幅圖的連通狀態呢?用什么數據結構來實現代碼呢?
基本思路
我們從數據結構和算法實現兩個方面來講一下並查集的實現方式。
我們用森林(若干棵樹)來表示圖的動態連通性,用數組來具體實現這個森林。
怎么用森林來表示連通性呢?
我們設定樹的每個節點有一個指針指向其父節點,如果是根節點的話,這個指針指向自己。比如說剛才那幅 10 個節點的圖,一開始的時候沒有相互連通,就是這樣:
class UF {
// 記錄連通分量
private int count;
// 節點 x 的節點是 parent[x]
private int[] parent;
/* 構造函數,n 為圖的節點總數 */
public UF(int n) {
// 一開始互不連通
this.count = n;
// 父節點指針初始指向自己
parent = new int[n];
for (int i = 0; i < n; i++)
parent[i] = i;
}
/* 其他函數 */
}
如果某兩個節點被連通,則讓其中的(任意)一個節點的根節點接到另一個節點的根節點上:
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
// 將兩棵樹合並為一棵
parent[rootP] = rootQ;
// parent[rootQ] = rootP 也一樣
count--; // 兩個分量合二為一
}
/* 返回某個節點 x 的根節點 */
private int find(int x) {
// 根節點的 parent[x] == x
while (parent[x] != x)
x = parent[x];
return x;
}
/* 返回當前的連通分量個數 */
public int count() {
return count;
}
這樣,如果節點p
和q
連通的話,它們一定擁有相同的根節點:
public boolean connected(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
return rootP == rootQ;
}
至此,Union-Find 算法就基本完成了。
那么這個算法的復雜度是多少呢?我們發現,主要 APIconnected
和union
中的復雜度都是find
函數造成的,所以說它們的復雜度和find
一樣。
find
主要功能就是從某個節點向上遍歷到樹根,其時間復雜度就是樹的高度。我們可能習慣性地認為樹的高度就是logN
,但這並不一定。logN
的高度只存在於平衡二叉樹,對於一般的樹可能出現極端不平衡的情況,使得「樹」幾乎退化成「鏈表」,樹的高度最壞情況下可能變成N
。
所以說上面這種解法,find
,union
,connected
的時間復雜度都是 O(N)。這個復雜度很不理想的,你想圖論解決的都是諸如社交網絡這樣數據規模巨大的問題,對於union
和connected
的調用非常頻繁,每次調用需要線性時間完全不可忍受。
問題的關鍵在於,如何想辦法避免樹的不平衡呢?只需要略施小計即可。
平衡性優化
我們要知道哪種情況下可能出現不平衡現象,關鍵在於union
過程:
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
// 將兩棵樹合並為一棵
parent[rootP] = rootQ;
// parent[rootQ] = rootP 也可以
count--;
我們一開始就是簡單粗暴的把p
所在的樹接到q
所在的樹的根節點下面,那么這里就可能出現「頭重腳輕」的不平衡狀況,比如下面這種局面:
長此以往,樹可能生長得很不平衡。我們其實是希望,小一些的樹接到大一些的樹下面,這樣就能避免頭重腳輕,更平衡一些。解決方法是額外使用一個size
數組,記錄每棵樹包含的節點數,我們不妨稱為「重量」:
class UF {
private int count;
private int[] parent;
// 新增一個數組記錄樹的“重量”
private int[] size;
public UF(int n) {
this.count = n;
parent = new int[n];
// 最初每棵樹只有一個節點
// 重量應該初始化 1
size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
}
/* 其他函數 */
}
比如說size[3] = 5
表示,以節點3
為根的那棵樹,總共有5
個節點。這樣我們可以修改一下union
方法:
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--;
}
這樣,通過比較樹的重量,就可以保證樹的生長相對平衡,樹的高度大致在logN
這個數量級,極大提升執行效率。
此時,find
,union
,connected
的時間復雜度都下降為 O(logN),即便數據規模上億,所需時間也非常少。
路徑壓縮
這步優化特別簡單,所以非常巧妙。我們能不能進一步壓縮每棵樹的高度,使樹高始終保持為常數?
這樣find
就能以 O(1) 的時間找到某一節點的根節點,相應的,connected
和union
復雜度都下降為 O(1)。
要做到這一點,非常簡單,只需要在find
中加一行代碼:
private int find(int x) {
while (parent[x] != x) {
// 進行路徑壓縮
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
調用find
函數每次向樹根遍歷的同時,順手將樹高縮短了,最終所有樹高都不會超過 3(union
的時候樹高可能達到 3)。
最后總結
我們先來看一下完整代碼:
class UF {
// 連通分量個數
private int count;
// 存儲一棵樹
private int[] parent;
// 記錄樹的“重量”
private int[] size;
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;
}
}
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--;
}
public boolean connected(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
return rootP == rootQ;
}
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 算法的復雜度可以這樣分析:構造函數初始化數據結構需要 O(N) 的時間和空間復雜度;連通兩個節點union
、判斷兩個節點的連通性connected
、計算連通分量count
所需的時間復雜度均為 O(1)。