並查集(union-find)演算法詳解
之前很多連通性問題,其實都是可以通過並查集演算法去實現的,比如城鎮的修路問題:
首先在地圖上給你若干個城鎮,這些城鎮都可以看作點,然後告訴你哪些對城鎮之間是有道路直接相連的。最後要解決的是整幅圖的連通性問題。比如隨意給你兩個點,讓你判斷它們是否連通,或者問你整幅圖一共有幾個連通分支,也就是被分成了幾個互相獨立的塊。像暢通工程這題,問還需要修幾條路,實質就是求有幾個連通分支。如果是1個連通分支,說明整幅圖上的點都連起來了,不用再修路了;如果是2個連通分支,則只要再修1條路,從兩個分支中各選一個點,把它們連起來,那麼所有的點都是連起來的了;如果是3個連通分支,則只要再修兩條路…
讀完《演算法》第4版關於並查集的介紹後,覺得對這個演算法有了比較完整的瞭解,這裡簡單記錄一下。
動態連通性
這幅圖是比較經典的動態連通性圖,這個圖會不斷的接受整數對的輸入,比如(p,q),它代表p和q是相連的,這種相連具有自反性,對稱性和傳遞性。他的動態體現在這個圖隨著輸入的不斷增加,連通性會發生變化。演算法的目標就是,當輸入(p,q)時,就使p和q連線起來。這裡分為兩種情況:
1.p和q本來就是連通的,那麼放棄這個整數對
2.p和q不連通,則在p和q之間加一條路徑
要實現上述目的,我們可以定義一個API,其中,連通分量表示該圖的連通子圖
UnionFind(int n) 建構函式,初始化N個觸點 void union(int p, int q) 在p和q之間增加一條連線,若已連通,則不做更改 int find(int p) 返回p觸點所在的連通分量的標識 boolean connected(int p, int q) 判斷p和q是否存在於同一個連通分量中 int count() 連通分量的數量
程式碼的基本結構如下:
public class UnionFind {
private int[] id; //連通分量,用分量中的某個觸點作為索引
private int count; //分量的數量
private int[] size; //每個分量的節點數量
public UnionFind(int n){
id = new int[n];
count = n;
for(int i = 0; i < n; i++) id[i] = i;
}
public int count(){
return count;
}
public boolean connected(int p, int q){
return quFind(p) == quFind(q);
}
public int find(int p);//下面實現
public void union(int p, int q);//下面實現
public static void main(String[] args){
int[] left = {9, 3, 5, 7, 2, 5, 0, 4, 3};
int[] right = {0, 4, 8, 2, 1, 7, 3, 2, 5};
UnionFind unionFind = new UnionFind(100, true);
for(int i = 0; i < left.length; i++){
if(unionFind.connected(left[i], right[i])) continue;
unionFind.jqUnion(left[i], right[i]);
System.out.println("ID:");
for(int j = 0; j < 10; j++) {
System.out.print(unionFind.id[j] + " ");
}
System.out.println("size:");
for(int j = 0; j < 10; j++) {
System.out.print(unionFind.size[j] + " ");
}
System.out.println();
}
}
其中,find方法和union方法是這個方法的核心,所以並查集又叫做union-find演算法。這兩個方法的實現有三種方法,其中各有優劣。
quick-find 演算法
顧名思義,這個方法是以最快find為目的的,這個演算法可以在o(1)的複雜度判斷兩個觸點是否相連,但是卻只能以o(n)複雜度進行新增。這個演算法的思想是保證在一個分量中所有的觸點都有同樣的標識,為了實現這一點,剛開始的時候,各個觸點各自形成自己的分量,當有輸入的時候,就將觸點合併,union方法通過將p的識別符號變為q(或者將q的識別符號變成p)來實現合併,但是光改p一個點是不夠的,還得把p所在分量的所有點都改成q的識別符號,這就意味著需要遍歷整個陣列,來找到p所在分量的所有點,因此複雜度為o(n),合併過程如下圖,5和9合併,將5所在的分量1全部改為了9所在的分量8。
但是union的高昂代價給find方法帶來了便利,因為find只需要訪問陣列p索引下的值,就可以知道它屬於哪個分量了,判斷兩個點是否在同一個分量將會十分容易,複雜度僅o(1)。假設有m個點,他的union複雜度就為o(mn),平方級的複雜度讓他很難勝任大規模資料的union。程式碼如下:
/*
quick-find
*/
public int qfFind(int p){
return id[p];
}
public void qfUnion(int p, int q){
int pId = qfFind(p);
int qId = qfFind(q);
if(qId == pId) return;
for(int i = 0; i < id.length; i++){
if(id[i] == pId)
id[i] = qId;
}
count--;
}
quick-union
quick-union採用了一種完全不同的思路,就是樹。樹結構有一個特點,就是移動一棵樹不需要把整棵樹的結點都移過去,只需要移動根節點就行了。在上面的分析中我們發現,quick-find演算法的union複雜度之所以高,就是因為每次移動一個分量,需要把所有該分量的識別符號都改變。改成樹結構之後,我把一個分量的所有點用一顆樹串聯起來,當兩個分量合併的時候,只需要把一棵樹的根節點嫁接到另一顆樹的根節點上即可。此時的id陣列結構如下圖
每個陣列元素不再記錄自己分量的標識,而是記錄自己的父結點,根節點的父結點就是自己。所以,find函式的實現就是向上不斷尋找找到任意結點的根節點,也就是id[index]=index的那個點,若兩個點是連通的,他們必定有同一個根節點。union函式就是把p所在樹的根節點連到q所在樹的根節點上(反之也可以),實現樹的合併。程式碼如下
public int quFind(int p){
while(id[p] != p) p = id[p];
return p;
}
public void quUnion(int p, int q){
int pId = quFind(p);
int qId = quFind(q);
if(qId == pId) return;
id[pId] = qId;
count--;
}
quick-union方法看似讓合併變的更加快速,其實不然,因為在合併之前,有兩次find操作,而find操作的複雜度,是樹的高度h,極端情況下,h=n,如下圖。意味著union操作也會達到o(n)複雜度,隨著樹高度的增加,這個演算法的複雜度會越來越高,面對大規模資料的時候仍顯得吃力。
加權quick-union
這是幾乎最優的解法了,前面分析了,quick-union演算法不適合大規模資料的主要原因是因為樹高的限制,那麼只需要控制好樹的高度,quick-union演算法的複雜度就有很好的表現。控制的方法也很簡單,就是總是讓較小的樹嫁接在較大樹的根節點下。實現的方式也很簡單,額外設一個size陣列,記錄每個分量的大小,也就是結點數。合併的時候,總是將size較小的樹合併到size較大的樹上去。可以證明,樹的高度不會超過logN(2為底,高度從0開始),最壞的複雜度也不過是o(mlogn),應付大規模的資料,已經足夠了。
public int jqFind(int p){
while(id[p] != p) p = id[p];
return p;
}
public void jqUnion(int p, int q){
int pId = jqFind(p);
int qId = jqFind(q);
if(connected(p, q)) return;
if(size[pId] < size[qId]){
id[pId] = qId;
size[qId] += size[pId];
}else {
id[qId] = pId;
size[pId] += size[qId];
}
count--;
}
和普通的quick-union比起來,樹的高度是明顯的
但是這個演算法其實還有優化空間,理想情況下,樹是可以做到高度為1的,也就是隻有兩層,這個時候,find和union的複雜度都將是o(1),但這是不可能的。採用壓縮路徑的加權quick-union可以逼近這個複雜度,他的思想就是在find的時候,把經過的結點直接接到根節點上,這隻需要增加一個while迴圈即可,就不再列舉程式碼了。幾種演算法的對比如下
對大規模資料進行處理,使用平方階的演算法是不合適的,比如簡單直觀的Quick-Find演算法,通過發現問題的更多特點,找到合適的資料結構,然後有針對性的進行改進,得到了Quick-Union演算法及其多種改進演算法,最終使得演算法的複雜度降低到了近乎線性複雜度。