並查集(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 (x > 0) { 3 x = set[x]; 4 } 5 return x; 6 }
合併
我們需要先找到要合併的那個元素所屬集合的代表元素,然後才可以進行合併。
合併的程式碼如下:
1 void merge(int *set, int x, int y) { 2 int x = find(set, x); 3 int 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 (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 int x = find(set, x); 3 int y = find(set, y); 4 if (set[x] > set[y]) { // 注意我們比較的是負數,如果set[x] > set[y],說明abs(set[x]) < abs(set[y]),也就是x所屬的集合的規模小於y所屬集合的規模 5 set[y] += set[x]; // 所以應該把x所屬的集合合併到y所屬的集合。先改變y集合的規模大小 6 set[x] = y; // 再把x所屬的集合合併到y所屬的集合 7 } 8 else { // y所屬的集合的規模小於x所屬集合的規模 9 set[x] += set[y]; // 所以應該把y所屬的集合合併到x所屬的集合。先改變x集合的規模大小 10 set[y] = x; // 再把y所屬的集合合併到x所屬的集合 11 } 12 }
並查集演算法
下面給出改進後的並查集的完整程式碼(路徑壓縮與按秩合併):
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 if (x < 0) return x; 9 else return set[x] = find(set, set[x]); 10 } 11 12 void merge(int *set, int x, int y) { 13 int x = find(set, x); 14 int y = find(set, y); 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 int x = find(set, x); 15 int 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