並查集(union-find disjoint sets)是一種十分精巧和簡潔的數據結構,主要用於處理不相交集合的合並問題。正如它的名字一樣,並查集的主要的操作有合並(union)與查找(find)。一些算法也會用到並查集,比如求最小生成樹的Kruskal算法。下面先通過舉例說明並查集的基本概念。
並查集的引入
首先,我們怎么樣來表示一個集合呢?其實很簡單,只需要在這個集合里面隨便找一個元素作為這個集合的代表就可以了。用哪個元素作為代表並不是我們關心的問題,我們關心的是,給定一個元素要找到它所屬的那個集合。所謂找到所屬的集合,也就是找到這個集合的代表元素。
比如我們現在有0,1,2,3,4,5,6,7這8個元素,他們各自所在的集合如下:
圖中有3個集合,這3個集合的代表元素分別為1,6,5。其中代表元素為1的集合含有的元素有0,1,2,4;代表元素為6的集合含有元素有3,6,7;代表元素為5的集合含有的元素就只有5。
所以,如果我們要查找某一個元素屬於哪一個集合,只需要從這個元素的節點開始,根據箭頭方向一直向上找就可以了。當某個元素沒有向外指出的箭頭,就說明這個元素就是這個集合的代表元素。
比如,如果現在要找4是屬於哪一個集合,根據上面的方法我們可以知道4這個元素在代表元素為1的這個集合中。如果要找5這個元素,那么5這個元素就在代表元素為5的集合中,同時代表元素為5的集合中就只有5這個元素。
現在我們要把代表元素為7的集合合並到代表元素為4的集合,我們需要先找到7所屬的集合的代表元素6,以及4所屬的集合的代表元素1,然后再讓6指向1就完成了合並了。
上面大致講解了查找和合並的實現過程,下面我們用代碼來實現。先講我常用的並查集算法。
先說明,集合用數組set來表示,數組的下標就是對應的元素,而數組存放的是該元素的上一個元素。就拿上面這個圖來舉例,我們的set數組是這樣的:
set數組中存放的值有正數和負數。其中只有代表元素存放負數,這里的"-1"代表5是對應集合的代表元素,"-1"的絕對值也就是"1"說明在代表元素為5的這個集合中,含有元素的個數為1。同理,"-7"代表1是對應集合的代表元素,"-7"的絕對值也就是"7"說明在代表元素為1的這個集合中,含有元素的個數為7。而非代表元素存放正數,相應的值表示該元素的前一個元素(父節點或是索引)。
初始化
首先我們需要將每一個元素所屬的集合初始化為其本身,也就是每一個元素所屬集合的代表元素就是它自己,初始化為"-1"。
假如我們有n個元素,初始化的代碼如下:
1 void initSet(int *set, int n) { 2 for (int i = 0; i < n; i++) { 3 set[i] = -1; 4 } 5 }
當然,你也可以簡簡單單的就一句話: std::fill(set, set + n, -1); ,達到同樣的效果。
查找
與上面所說的方法一樣,如果我們要找某一個元素所在的集合(的代表元素),就先找到它的前一個元素,如果沒有前一個元素,那么它自己就是那個代表元素;如果有前一個元素,那么再找前一個元素的前一個元素,這樣總是可以找到代表元素。
查找的代碼如下:
1 int find(int *set, int x) { 2 while (set[x] > 0) { 3 x = set[x]; 4 } 5 return x; 6 }
合並
我們需要先找到要合並的那個元素所屬集合的代表元素,然后才可以進行合並。
合並的代碼如下:
1 void merge(int *set, int x, int y) { 2 x = find(set, x); 3 y = find(set, y); 4 set[y] = x; 5 }
這里我們始終把y所屬的集合合並到x所屬的集合。
路徑壓縮
考慮一種情況,如果有元素0,1,2,3,4。
上面合並后的結果就像一條鏈,隨着鏈越來越長,每次我們從底部查找到根節點所需的時間也會越長。有沒有什么方法可以減少鏈的長度,以提高查找的效率?當然有,那就是路徑壓縮。只需要在查詢的過程中,把沿途的每個元素都指向代表元素就可以了,所以經過路徑壓縮后,上面的合並情況應該變成這個樣子:
通過路徑壓縮改善后的查找代碼也很簡單,這里我們用遞歸的方法實現:
1 int find(int *set, int x) { 2 if (set[x] < 0) return x; 3 else return set[x] = find(set, set[x]); 4 }
我們並不是直接返回集合的代表元素,而是先把集合的代表元素賦值給沿途的那個元素,讓這個元素指向代表元素,然后再返回集合的代表元素。這樣就實現了路徑壓縮。
按秩合並
這里我是按照集合的規模大小來進行合並的。
在上面的合並函數代碼中,我們總是把后一個集合合並到前一個集合,也正是這種方法使得之前我們合並出一條很長的鏈。所以我們應該用一種更高效的方法來進行合並,避免合並出一條很長的鏈。
所以我們采用了按秩合並的方法,就是每次我們都是把規模小的集合合並到規模大的集合。而集合的規模,也就是元素的數量,可以通過代表元素在set數組中存放的值的絕對值知道。所以我們只需要找到合並元素所屬集合的代表元素,然后比較兩個集合元素個數大小,按照小並到大的規則來合並就可以了。
按秩合並(按規模大小)的代碼如下:
1 void merge(int *set, int x, int y) { 2 x = find(set, x); 3 y = find(set, y); 4 if (x == y) return; // 如果這兩個元素本來就在同一個集合里,就不需要合並了 5 if (set[x] > set[y]) { // 注意我們比較的是負數,如果set[x] > set[y],說明abs(set[x]) < abs(set[y]),也就是x所屬的集合的規模小於y所屬集合的規模 6 set[y] += set[x]; // 所以應該把x所屬的集合合並到y所屬的集合。先改變y集合的規模大小 7 set[x] = y; // 再把x所屬的集合合並到y所屬的集合 8 } 9 else { // y所屬的集合的規模小於x所屬集合的規模 10 set[x] += set[y]; // 所以應該把y所屬的集合合並到x所屬的集合。先改變x集合的規模大小 11 set[y] = x; // 再把y所屬的集合合並到x所屬的集合 12 } 13 }
並查集算法
下面給出改進后的並查集的完整代碼(路徑壓縮與按秩合並):
1 void initSet(int *set, int n) { 2 for (int i = 0; i < n; i++) { 3 set[i] = -1; 4 } 5 } 6 7 int find(int *set, int x) { 8 return set[x] < 0 ? x : set[x] = find(set, set[x]); 9 } 10 11 void merge(int *set, int x, int y) { 12 x = find(set, x); 13 y = find(set, y); 14 if (x == y) return; 15 if (set[x] > set[y]) { 16 set[y] += set[x]; 17 set[x] = y; 18 } 19 else { 20 set[x] += set[y]; 21 set[y] = x; 22 } 23 }
另外一種並查集算法
這種方法需要額外的一個rank數組,rank[n],用來存放代表元素所屬集合的秩(深度)。或者說,是存放每個根節點對應的樹的深度(如果該元素不是根節點,其rank存放的值是以它作為根節點的子樹的深度)。比如:
對應的按秩合並的規則是,把rand小的集合合並到rank大的集合。如果兩個集合的秩大小相同,則讓其中一個集合合並另外一個集合,同時把合並后的那個集合的rank加1。
在初始化時,把每個元素對應的rank初始化為1,同時set數組的初始化的值不再是-1,而是元素本身的值。以及在查找函數中,找到代表元素的條件需要改變。剩下的部分都基本相同。
有個問題就是,如果將路徑壓縮和按秩合並一起使用,很可能會破壞rank的准確性。
相應的代碼如下:
1 void initSet(int *set, int *rank, int n) { 2 for (int i = 0; i < n; i++) { 3 set[i] = i; 4 rank[i] = 1; 5 } 6 } 7 8 int find(int *set, int x) { 9 if (x == set[x]) return x; 10 else return set[x] = find(set, set[x]); 11 } 12 13 void merge(int *set, int *rank, int x, int y) { 14 x = find(set, x); 15 y = find(set, y); 16 if (rank[x] <= rank[y]) { 17 set[x] = y; 18 if (rank[x] == rank[y] && x != y) rank[y]++; 19 } 20 else { 21 set[y] = x; 22 } 23 }
參考資料
算法學習筆記(1) : 並查集:https://zhuanlan.zhihu.com/p/93647900
並查集:https://www.cnblogs.com/cyjb/p/UnionFindSets.html