前言:
不少搞IT的朋友聽到“算法”時總是覺得它太難,太高大上了。今天,跟大伙兒分享一個比較俗氣,但是卻非常高效實用的算法,如標題所示Union-Find,是研究關於動態連通性的問題。不保證我能清晰的表述並解釋這個算法,也不保證你可以領會這個算法的絕妙之處。但是,只要跟着思路一步一步來,相信你一定可以理解它,並像我一樣享受它。
-----------------------------------------
為了便於引入算法,下面我們假設一個場景:
假設現在有A,B兩人素不相識,但A通過熟人甲,甲通過熟人乙,乙通過熟人丙,丙通過熟人丁,而丁又剛好與B是熟人。就這樣,A通過一層一層的人際關系最后認識了B。
基於以上介紹的“關系網”,現在給出一道思考題:13億中國人當中一共有幾個“關系網”呢?
------------------------------------------
1.Union-Find初探
是的,想到1,300,000,000這個數字,或許此刻你大腦已經懵了。那好,我們就先從小數據分析:
圖1
從上圖中,其實很好理解。初始每個人都是單獨的一個“點”,用科學語言,我們把它描述為“連通分量”。隨着一個一個關系的確立,即點與點之間的連接,每連接一次,總連通分量數即減1(理解算法的關鍵點之一)。最后的“關系網”幾乎可以很輕易地數出來。所以,只要你把所有國人兩兩之間的聯系給出,然后不斷連線,連線,...,最后再統計一下不就完事兒了麽~
問題是:怎么存儲點的信息?點與點怎么連,怎么判斷該不該連?
因此,我們需要維護2個變量,其中一個變量count表示實時的連通分量數,另一個變量可以用來存儲具體每一個點所屬的連通分量。因為不需要存儲復雜的信息。這里我們選常用的數組 id[N] 存儲即可。然后,我們需要2個函數find(int x)和union(int p,int q)。前者返回點“x”所屬於的連通分量,后者將p,q兩點進行連接。注意,所謂的連接,其實可以簡單的將p的連通分量值賦予q或者將q的連通分量值賦予p,即:
id[p]=q 或者id[q]=p。
有了上面的分析,我們就可以牛刀小試了。且看Java代碼實現第一版。
Code:
1 package com.gdufe.unionfind; 2 3 import java.io.File; 4 import java.util.Scanner; 5 6 public class UF { 7 8 int count; //連通分量數 9 int[] id; //每個數所屬的連通分量 10 11 public UF(int N) { //初始化時,N個點有N個分量 12 count = N; 13 id = new int[N]; 14 for (int i = 0; i < N; i++) 15 id[i] = i; 16 } 17 //返回連通分量數 18 public int getCount(){ 19 return count; 20 } 21 //查找x所屬的連通分量 22 public int find(int x){ 23 return id[x]; 24 } 25 //連接p,q(將q的分量改為p所在的分量) 26 public void union(int p,int q){ 27 int pID=find(p); 28 int qID=find(q); 29 for(int i=0;i<id.length;i++){ 30 if(find(i)==pID){ 31 id[i]=qID; 32 } 33 } 34 count--; //記得每進行一次連接,分量數減“1” 35 } 36 //判斷p,q是否連接,即是否屬於同一個分量 37 public boolean connected(int p,int q){ 38 return find(p)==find(q); 39 } 40 41 public static void main(String[] args) throws Exception { 42 43 //數據從外部文件讀入,“data.txt”放在項目的根目錄下 44 Scanner input = new Scanner(new File("data.txt")); 45 int N=input.nextInt(); 46 UF uf = new UF(N); 47 while(input.hasNext()){ 48 int p=input.nextInt(); 49 int q=input.nextInt(); 50 if(uf.connected(p, q)) continue; //若p,q已屬於同一連通分量不再連接,則故直接跳過 51 uf.union(p, q); 52 System.out.println(p+"-"+q); 53 54 } 55 System.out.println("總連通分量數:"+uf.getCount()); 56 } 57 58 }
測試結果:
2-3
1-0
0-4
5-7
總連通分量數:4
分析:
find()操作的時間復雜度為:O(l),Union的時間復雜度為:O(N)。因為算法可以非常高效地實現find(),所以我們也把它稱為“quick-find”算法。
--------------------
2.Union-find進階:
仔細一想,我們上面再進行union()連接操作時,實際上就是一個進行暴力“標記”的過程,即把所有連通分量id跟點q相同的點找出來,然后全部換成p的id。算法本身沒有錯,但是這樣的代價太高了,得想辦法優化~
因此,這里引入了一個抽象的“樹”結構,即初始時每個點都是一棵獨立的樹,所有的點構成了一個大森林。每一次連接,實際上就是兩棵樹的合並。通過,不斷的合並,合並,再合並最后長成了一棵棵的大樹。
圖2
Code:
1 package com.gdufe.unionfind; 2 3 import java.io.File; 4 import java.util.Scanner; 5 6 public class UF { 7 8 int count; //連通分量數 9 int[] id; //每個數所屬的連通分量 10 11 public UF(int N) { //初始化時,N個點有N個分量 12 count = N; 13 id = new int[N]; 14 for (int i = 0; i < N; i++) 15 id[i] = i; 16 } 17 //返回連通分量數 18 public int getCount(){ 19 return count; 20 } 21 22 //查找x所屬的連通分量 23 public int find(int x){ 24 while(x!=id[x]) x = id[x]; //若找不到,則一直往根root回溯 25 return x; 26 } 27 //連接p,q(將q的分量改為p所在的分量) 28 public void union(int p,int q){ 29 int pID=find(p); 30 int qID=find(q); 31 if(pID==qID) return ; 32 id[q]=pID; 33 count--; 34 } 35 /* 36 //查找x所屬的連通分量 37 public int find(int x){ 38 return id[x]; 39 } 40 41 //連接p,q(將q的分量改為p所在的分量) 42 public void union(int p,int q){ 43 int pID=find(p); 44 int qID=find(q); 45 if(pID==qID) return ; 46 for(int i=0;i<id.length;i++){ 47 if(find(i)==pID){ 48 id[i]=qID; 49 } 50 } 51 count--; //記得每進行一次連接,分量數減“1” 52 } 53 */ 54 //判斷p,q是否連接,即是否屬於同一個分量 55 public boolean connected(int p,int q){ 56 return find(p)==find(q); 57 } 58 59 public static void main(String[] args) throws Exception { 60 61 //數據從外部文件讀入,“data.txt”放在項目的根目錄下 62 Scanner input = new Scanner(new File("data.txt")); 63 int N=input.nextInt(); 64 UF uf = new UF(N); 65 while(input.hasNext()){ 66 int p=input.nextInt(); 67 int q=input.nextInt(); 68 if(uf.connected(p, q)) continue; //若p,q已屬於同一連通分量不再連接,則故直接跳過 69 uf.union(p, q); 70 System.out.println(p+"-"+q); 71 72 } 73 System.out.println("總連通分量數:"+uf.getCount()); 74 } 75 76 }
測試結果:
2-3
1-0
0-4
5-7
總連通分量數:4
分析:
利用樹本身良好的連通性,我們算法僅需要O(l)時間代價進行union()操作,但此時find()操作的時間代價有所增加。結合本算法對quick-find()的優化,我們把它稱為“quick-union”算法。
--------
3.Union-Find再進階
等等,還沒完!
表面上,上述引入“樹”結構的算法時間復雜度由原來的O(N)改進為O(lgN)。但是,不要忽略了這樣一種極端情況,即每連接一個點之后,樹在不斷往下生長,最后長成一棵“禿樹”(沒有任何樹枝)。
圖3
為了不讓我們前面做的工作白費,必須得采取某些措施避免這種惡劣的情況給我們算法帶來的巨大代價。所以...
是的,或許你已經想到了,就是在兩棵樹進行連接之前做一個判斷。每一次都優先選擇將小樹合並到大樹下面,這樣子樹的高度不變,能避免樹一直往下增長了!下圖中,數據增加了“6-2”的一條連接,得知以“2”為根節點的樹比“6”的樹大,對比(f)和(g)兩種連接方式,我們最優選擇應該是(g),即把小樹並到大樹下。
圖4
基於此,我們還得引入一個變量對以每個結點為根節點的樹的大小進行維護,具體我們以sz[i]表示i結點代表的樹(或子樹)的結點數作為它的大小,初始sz[i]=1。因為現在的每一個結點都有了權重,所以我們也把這種樹結構稱為“加權樹”,本算法稱為“weightedUnionFind”。
Code:
1 package com.gdufe.unionfind; 2 3 import java.io.File; 4 import java.util.Scanner; 5 6 public class UF { 7 8 int count; // 連通分量數 9 int[] id; // 每個數所屬的連通分量 10 int[] sz; 11 12 public UF(int N) { // 初始化時,N個點有N個分量 13 count = N; 14 sz = new int[N]; 15 id = new int[N]; 16 for (int i = 0; i < N; i++) 17 id[i] = i; 18 19 for (int i = 0; i < N; i++) 20 sz[i] = 1; 21 22 } 23 24 // 返回連通分量數 25 public int getCount() { 26 return count; 27 } 28 29 // 查找x所屬的連通分量 30 public int find(int x) { 31 while (x != id[x]) 32 x = id[x]; // 若找不到,則一直往根root回溯 33 return x; 34 } 35 36 // 連接p,q(將q的分量改為p所在的分量) 37 public void union(int p, int q) { 38 int pID = find(p); 39 int qID = find(q); 40 if (pID == qID) 41 return; 42 43 if (sz[p] < sz[q]) { //通過結點數量,判斷樹的大小並將小樹並到大樹下 44 id[p] = qID; 45 sz[q] += sz[p]; 46 } else { 47 id[q] = pID; 48 sz[p] += sz[q]; 49 } 50 count--; 51 } 52 53 /* 54 * //查找x所屬的連通分量 public int find(int x){ return id[x]; } 55 * 56 * //連接p,q(將q的分量改為p所在的分量) public void union(int p,int q){ int pID=find(p); 57 * int qID=find(q); if(pID==qID) return ; for(int i=0;i<id.length;i++){ 58 * if(find(i)==pID){ id[i]=qID; } } count--; //記得每進行一次連接,分量數減“1” } 59 */ 60 // 判斷p,q是否連接,即是否屬於同一個分量 61 public boolean connected(int p, int q) { 62 return find(p) == find(q); 63 } 64 65 public static void main(String[] args) throws Exception { 66 67 // 數據從外部文件讀入,“data.txt”放在項目的根目錄下 68 Scanner input = new Scanner(new File("data.txt")); 69 int N = input.nextInt(); 70 UF uf = new UF(N); 71 while (input.hasNext()) { 72 int p = input.nextInt(); 73 int q = input.nextInt(); 74 if (uf.connected(p, q)) 75 continue; // 若p,q已屬於同一連通分量不再連接,則故直接跳過 76 uf.union(p, q); 77 System.out.println(p + "-" + q); 78 79 } 80 System.out.println("總連通分量數:" + uf.getCount()); 81 } 82 83 }
測試結果:
2-3
1-0
0-4
5-7
6-2
總連通分量數:3
4.算法性能比較:
|
讀入數據 |
find() |
union() |
總時間復雜度 |
quick-find |
O(M) |
O(l) |
O(N) |
O(M*N) |
quick-union |
O(M) |
O(lgN~N) |
O(l) |
O(M*N)極端 |
WeightedUF |
O(M) |
O(lgN) |
O(N) |
O(M*lgN) |
----------------------
結語:
讀到了最后,有朋友可能覺得“不就是一個O(N)到O(lgN)的轉變嗎,有必要這么長篇大論麽”?對此,本人就只有無語了。有過算法復雜度分析的朋友應該知道算法由O(N)到O(lgN)所帶來的增長效益是多么巨大。雖然,前文中13億的數據,就算我們用最后的加權樹算法一時半會兒也無法算出。但假如現在同樣是100w的數據,那么我們最后的“加權樹”因為整體的時間復雜度:O(M*lgN)可以在1秒左右跑完,而O(M*N)的算法可能得花費1千倍以上的時間,至少1小時內還沒算出來(當然啦,也可能你機器的是高性能的~)。
最后的最后,羅列本人目前所知曉的本算法適用的幾個領域:
l 網絡通信(比如:是否需要在通信點p,q建立通信連接)
l 媒體社交(比如:向通一個社交圈的朋友推薦商品)
l 數學集合(比如:判斷元素p,q之后選擇是否進行集合合並)