一,並查集的介紹
並查集(Union/Find)從名字可以看出,主要涉及兩種基本操作:合並和查找。這說明,初始時並查集中的元素是不相交的,經過一系列的基本操作(Union),最終合並成一個大的集合。
而在某次合並之后,有一種合理的需求:某兩個元素是否已經處在同一個集合中了?因此就需要Find操作。
並查集是一種 不相交集合 的數據結構,設有一個動態集合S={s1,s2,s3,.....sn},每個集合通過一個代表來標識,代表 就是動態集合S 中的某個元素。
比如,若某個元素 x 是否在集合 s1 中(Find操作),返回集合 s1 的代表元素即可。這樣,判斷兩個元素是否在同一個集合中也是很方便的,只要看find(x) 和 find(y) 是否返回同一個代表即可。
為什么是動態集合S呢?因為隨着Union操作,動態集合S中的子集合個數越來越少。
數據結構的基本操作決定了它的應用范圍,對並查集而言,一個簡單的應用就是判斷無向圖的連通分量個數,或者判斷無向圖中任何兩個頂點是否連通。
二,並查集的存儲結構及實現分析
①存儲結構
並查集(大S)由若干子集合si構成,並查集的邏輯結構就是一個森林。si表示森林中的一棵子樹。一般以子樹的根作為該子樹的代表。
而對於並查集的存儲結構,可用一維數組和鏈表來實現。這里主要介紹一維數組的實現。
根據前面介紹的基本操作再加上存儲結構,並查集類的實現架構如下:
public class DisjSets { private int[] s; private int count;//記錄並查集中子集合的個數(子樹的個數) public DisjSets(int numElements) { //構造函數,負責初始化並查集 } public void unionByHeight(int root1, int root2){ //union操作 } public int find(int x){ //find 操作 } }
由於Find操作需要找到該子集合的代表元素,而代表元素是樹根,因此需要保存樹中結點的父親,對於每一個結點,如果知道了父親,沿着父結點鏈就可以最終找到樹根。
為了簡單起見,假設一維數組s中的每個元素 s[i] 表示該元素 i 的父親。這里有兩個需要注意的地方:①我們用一維數組來存儲並查集,數組的元素s[i]表示的是結點的父親的位置。②數組元素的下標 i 則是結點的標識。如:s[5]=4,表示:結點5 的父親 是結點4。
假設有並查集中6個元素,初始時,所有的元素都相互獨立,處在不同的集合中:
對應的一維數組初始化如下:
因為,初始時每個元素代表一個集合,該元素本身就是樹根。樹根的父結點用 -1 來表示。代碼實現如下:
1 public DisjSets(int numElements) { 2 s = new int[numElements]; 3 count = numElements; 4 //初始化並查集,相當於新建了s.length 個互不相交的集合 5 for(int i = 0; i < s.length; i++) 6 s[i] = -1;//s[i]存儲的是高度(秩)信息 7 }
②基本操作實現
Union操作就是將兩個不相交的子集合合並成一個大集合。簡單的Union操作是非常容易實現的,因為只需要把一棵子樹的根結點指向另一棵子樹即可完成合並。
比如合並 節點3 和節點4:
這里的合並很隨意,把任意一棵子樹的結點指向另一棵子樹結點就完成了合並。
1 public void union(int root1, int root2){ 2 s[root2] = root1;//將root1作為root2的新樹根 3 }
但是,這只是一個簡單的情況,如果待合並的兩棵子樹很大,而且高度不一樣時,如何使得合並操作生成的新的子樹的高度最小?因為高度越小的子樹Find操作越快。
后面會介紹一種更好的合並策略,以支持Quick Union/Find。
Find操作就是查找某個元素所在的集合,返回該集合的代表元素。在union(3,4) 和 union(1,2)后,並查集如下:
此時的一維數組如下:
此時一共有4個子集合。第一個集合的代表元素為0,第二個集合的代表元素為1,第三個集合的代表元素為3,第四個集合的代表元素為5,故:
find(2)返回1,find(0)返回0。因為 結點3 和 結點4 在同一個集合內,find(4)返回3,find(3)返回3。
1 public int find(int x){ 2 if(s[x] < 0) 3 return x; 4 else 5 return find(s[x]); 6 }
這里find(int x)返回的是最里層遞歸執行后,得到的值。由於只有樹根的父結點位置小於0,故返回的是樹根結點的標識。
(數組中索引 i 處的元素 s[i] 小於0,表示 結點i 是根結點.....)
Union/Find的改進----Quick Union/Find
上面介紹的Union操作很隨意:任選一棵子樹,將另一棵子樹的根指向它即完成了合並。如果一直按照上述方式合並,很可能產生一棵非常不平衡的子樹。
比如在上面的基礎上union(2,3)后
樹越來越高了,此時會影響到Find操作的效率。比如,find(4)時,會一直沿着父結點遍歷直到根,4-->3-->2-->1
這里引入一種新的合並策略,這是一種啟發式策略,稱之為按秩合並:將秩小的子樹的根指向秩大的子樹的根。
秩的定義:對每個結點,用秩表示結點高度的一個上界。為什么是上界?
因為路徑壓縮不完全與按高度求並兼容。路徑壓縮會改變樹的高度,這樣在Union操作之前,我們就無法獲得子樹的高度的精確值,因此就不計算高度的精確值,而是存儲每棵樹的高度的估計值,這個值稱之為秩。(關於路徑壓縮在后面的Find操作中會詳細介紹)
說了這么多,按秩求並就是在合並之前,先判斷下哪棵子樹更高,讓矮的子樹的根指向高的子樹的根。
除了按高度求並之外,還可以按大小求並,即先判斷下哪棵子樹含有的結點數目多,讓較小的子樹的根指向較大的子樹的根。
對於按高度求並,需要解釋下數組中存儲的元素:是高度的負值再減去1。這樣,初始時,所有元素都是-1,而樹根節點的高度為0,s[i]=-1。
按高度求並的代碼如下:
1 /** 2 * 3 * @param root1 並查集中以root1為代表的某個子集 4 * @param roo2 並查集中以root2為代表的某個子集 5 * 按高度(秩)合並以root1 和 root2為代表的兩個集合 6 */ 7 public void unionByHeight(int root1, int root2){ 8 if(find(root1) == find(root2)) 9 return;//root1 與 root2已經連通了 10 11 if(s[root2] < s[root1])//root2 is deeper 12 s[root1] = root2; 13 else{ 14 if(s[root1] == s[root2])//root1 and root2 is the same deeper 15 s[root1]--;//將root1的高度加1 16 s[root2] = root1;//將root2的根(指向)更新為root1 17 } 18 19 count--;//每union一次,子樹數目減1 20 }
使用了路徑壓縮的Find的操作
上面程序代碼find方法只是簡單地把待查找的元素所在的根返回。路徑壓縮是指,在find操作進行時,使find查找路徑中的頂點(的父親)都直接指向為樹根(這很明顯地改變了子樹的高度)
如何使find查找路徑中經過的每個頂點都直接指向樹根呢?只需要小小改動一下就可以了,這里用到了非常神奇的遞歸。修改后的find代碼如下:
1 public int find(int x){ 2 if(s[x] < 0)//s[x]為負數時,說明 x 為該子集合的代表(也即樹根), 且s[x]的值表示樹的高度 3 return x; 4 else 5 return s[x] = find(s[x]);//使用了路徑壓縮,讓查找路徑上的所有頂點都指向了樹根(代表節點) 6 //return find(s[x]); 沒有使用 路徑壓縮 7 }
因為遞歸最終得到的返回值是根元素。第5行將根元素直接賦值給s[x],s[x]在每次遞歸過程中相當於結點x的父結點指針。
關於路徑壓縮對按”秩“求並的兼容性問題
上面的unionByHeight(int , int)是按照兩棵樹的高度來進行合並的。但是find操作中的路徑壓縮會對樹的高度產生影響。使用了路徑壓縮后,樹的高度變化了,但是數組並沒有更新這個變化。因為無法更新!!(我們沒有在Find操作中去計算原來的樹的高度,然后再計算新的樹的高度,這樣不現實,復雜度太大了)
舉個例子:
依次高度unionByHeight(3, 4)、unionByHeight(1, 3)、unionByHeight(1, 0)后,並查集如下:
此時,數組中的元素如下:
可以看出,此時只有兩棵子樹,一棵根結點為1,另一棵只有一個結點5。結點1的s[1]=-3,它所表示是該子樹的高度為2,如果此時執行find(4),會改變這棵樹的高度!但是,數組s中存儲的根的高度卻沒有更新,只會更新查找路徑上的頂點的高度。執行完find(4)后,變成:
查找路徑為 4-->3-->1,find(4)使得查找路徑上的所有頂點的父結點指向了根。如,將結點4 指向了根。但是沒有根結點的高度(沒有影響樹根的秩),因為s[1]的值仍為-3
-3表示的高度為2,但是樹的高度實際上已經變成了1
執行find(4)之后,樹實際上是這樣的:
(關於路徑壓縮對按秩合並有影響,我一直有個疑問,希望有大神指點啊)。。。。
路徑壓縮改變了子樹的高度,而這個高度是按秩求的依據。,而且當高度改變之后,我們是無法更新這個變化了的高度的。那這會不會影響按秩求並的正確性?或者說使按秩求並達不到減小新生成的子樹的高度的效果?
四,並查集的應用
並查集數據結構非常簡單,基本操作也很簡單。但是用途感覺很大。比如,求解無向圖中連通分量的個數,生成迷宮……
這些應用本質上就是:初始時都是一個個不連通的對象,經過一步步處理,變成連通的了。。。。。
如迷宮,初始時,起點和終點不連通,隨機地打開起點到終點路徑上的一個方向,直至起點和終點連通了,就生成了一個迷宮。
如,無向圖的連通分量個數,初始時,將無向圖中各個頂點視為不連通的子集合,對圖中每一條邊,相當於union這條邊對應的兩個頂點分別所在的集合,直至所有的邊都處理完后,還剩下的集合的個數即為連通分量的個數。
五,完整代碼如下:
1 package mark_allen_weiss.c8; 2 3 public class DisjSets { 4 private int[] s; 5 private int count;//記錄並查集中子集合的個數(子樹的個數) 6 7 8 public DisjSets(int numElements) { 9 s = new int[numElements]; 10 count = numElements; 11 //初始化並查集,相當於新建了s.length 個互不相交的集合 12 for(int i = 0; i < s.length; i++) 13 s[i] = -1;//s[i]存儲的是高度(秩)信息 14 } 15 16 /** 17 * 18 * @param root1 並查集中以root1為代表的某個子集 19 * @param roo2 並查集中以root2為代表的某個子集 20 * 按高度(秩)合並以root1 和 root2為代表的兩個集合 21 */ 22 public void unionByHeight(int root1, int root2){ 23 if(find(root1) == find(root2)) 24 return;//root1 與 root2已經連通了 25 26 if(s[root2] < s[root1])//root2 is deeper 27 s[root1] = root2; 28 else{ 29 if(s[root1] == s[root2])//root1 and root2 is the same deeper 30 s[root1]--;//將root1的高度加1 31 s[root2] = root1;//將root2的根(指向)更新為root1 32 } 33 34 count--;//每union一次,子樹數目減1 35 } 36 37 public void union(int root1, int root2){ 38 s[root2] = root1;//將root1作為root2的新樹根 39 } 40 41 42 public void unionBySize(int root1, int root2){ 43 44 if(find(root1) == find(root2)) 45 return;//root1 與 root2已經連通了 46 47 if(s[root2] < s[root1])//root2 is deeper 48 s[root1] = root2; 49 else{ 50 if(s[root1] == s[root2])//root1 and root2 is the same deeper 51 s[root1]--;//將root1的高度加1 52 s[root2] = root1;//將root2的根(指向)更新為root1 53 } 54 55 count--;//每union一次,子樹數目減1 56 } 57 58 59 public int find(int x){ 60 if(s[x] < 0)//s[x]為負數時,說明 x 為該子集合的代表(也即樹根), 且s[x]的值表示樹的高度 61 return x; 62 else 63 return s[x] = find(s[x]);//使用了路徑壓縮,讓查找路徑上的所有頂點都指向了樹根(代表節點) 64 //return find(s[x]); 沒有使用 路徑壓縮 65 } 66 67 public int find0(int x){ 68 if(s[x] < 0) 69 return x; 70 else 71 return find0(s[x]); 72 } 73 74 75 public int size(){ 76 return count; 77 } 78 79 //for test purpose 80 public static void main(String[] args) { 81 DisjSets dSet = new DisjSets(6); 82 dSet.unionBySize(1, 2); 83 84 for(int i : dSet.s) 85 System.out.print(i + " "); 86 87 dSet.unionBySize(3, 4); 88 89 System.out.println(); 90 for(int i : dSet.s) 91 System.out.print(i + " "); 92 93 System.out.println(); 94 dSet.unionBySize(1, 3); 95 for(int i : dSet.s) 96 System.out.print(i + " "); 97 98 System.out.println(); 99 dSet.unionBySize(1, 0); 100 for(int i : dSet.s) 101 System.out.print(i + " "); 102 103 System.out.println(); 104 int x = dSet.find(4); 105 System.out.println(x); 106 107 for(int i : dSet.s) 108 System.out.print(i + " "); 109 110 System.out.println("\nsize:" + dSet.size()); 111 } 112 }
六,參考資料
http://blog.csdn.net/dm_vincent/article/details/7655764
《算法導論》第二版
《數據結構與算法分析》JAVA語言描述--Mark Allen Weiss