優雅的資料結構–並查集

objc94發表於2019-05-12

概念介紹


先來想想「親戚」這個詞的定義:「指和自己有血親和姻親的人」。你和你女友家屬本身並非是親戚關係,一旦結婚後,兩家人便成為了一家人,你的家人包括你在內和你女友及其家人自動成為了親戚,這就是一個典型的並查集應用。並查集是一種樹形的資料結構,用於處理一些不相交集合的合併及查詢,上面例子中「結婚」其實就是並查集的合併操作

下面我們來演示下並查集的常規操作,我們預設建立6個元素,這6個元素我們可以看成是互不相交的6個集合



進行幾次簡單合併操作,我們把元素0,2,4合併為集合set0,1,3合併為set1,5單獨看成一個set2


查詢操作


實現方法


初始化(make_set)

我們可以把並查集看成是由很多顆樹組成的森林,每棵樹中相連的結點都代表屬於同一集合,樹中parent指向自己的根結點被視為該集合的代表。初始的時候,我們用一個parent陣列儲存所有結點的父結點下標,由於預設情況下每個集合互不相交,所以我們令每個結點的parent都指向自己,這樣就生成了N棵以自己為根的樹組成的森林。


parent陣列的初始化結構如下圖所示:


初始化並查集的程式碼:

void make_set (){
    for (int i=0;i<N;i++){
        parent[i] = i;// 如上圖i的parent指向自己
    }
}
複製程式碼
合併(Union)

Union(a,b)會將a所在的集合與b所在的集合相結合。在資料結構的實現上,只需要將b的根結點指向a的根結點,或a的根結點指向b的根結點即可,本文中預設使用前者。假設我們現在要將0,2,4合併為一個集合,1,3 合併為一個集合,5單獨視為一個集合,那麼運算的過程的可能如下:







接著,如果想要繼續Union(5,3),我們可以先獲得結點3所處樹的根結點1,讓1指向5即可。但是這樣樹的高度要比5指向1的樹要高,隨著並查集規模的增大,樹會多出很多不必要的高度,這將導致並查集的查詢更耗時。



為了讓合併後樹的整體高度相對更矮,在每次合併時,我們讓高度較矮的樹併入高度較高的樹,這種優化會在之後的程式碼中體現出來。

最後,如果我們想要Union(2,3),由於2,3各自所處的樹高度相同,所以按預設方式將「3」的根結點「1」指向「2」的根結點「0」即可。



在實現Union函式之前,我們先增加一個rank[N]陣列記錄高度,預設的時候rank陣列全部設定為0,rank中數值隨著並查集的合併而改變。下面給出Union的程式碼:

void union_set(int a,int b) {
    if (a==b) return; // 相同
    int root_a = find_root(a);//找到a的根結點
    int root_b = find_root(b);//找到b的根結點
    if (root_a == root_b)  return; //根結點相同
    int higher_root = rank[root_a]>rank[root_b] ?root_a:root_b;// 選出較高的樹
    int lower_root = rank[root_a]<rank[root_b]?root_a:root_b;// 選出較低的樹
    if( higher_root == lower_root ) {
        // 兩顆樹高度相等的情況
        parent[root_b] = root_a; //root_b.parent 指向 root_a (預設操作)
        ++rank[root_a];// 高度+1
    }else {
        parent[lower_root] = higher_root; // 較矮的樹指向較高的樹,不會改變整體高度
    }
}
複製程式碼
查詢(Find)

查詢某個元素所在的集合非常簡單,由於parent陣列記錄了每一個元素的父結點,我們只需要遞迴回溯即可。


執行find_root(5)後沿著紅線向上回溯找到0,執行find_root(2)後沿著紅線向上回溯也找到了0,說明5和2同屬一個集合,而執行find_root(7)後沿著紅線回溯找到了6,故7和元素5,2不屬於同一個集合。下面給出實現程式碼:

int find_root(int node) {
    if (parent[node] == node) return node;
    return find_root(parent[node]);
}
複製程式碼
路徑壓縮(Path Compression)

在查詢某個元素的所在集合的時候,上面的find_root(int node)函式會返回元素所在的樹的根結點------這個集合的代表,在這個過程中,我們可以將當前待查詢的元素直接指向這個根結點,降低樹的高度,從而使得查詢速度得到提升。以上圖為例子,執行find_root(3),find_root(5)後樹形結構會變成如下結構:


程式碼實現上的改動非常小:

int find_root(int node) {
    if (parent[node] == node) return node;
    parent[node] = find_root(parent[node]); // 指向根結點
    return parent[node];
}
複製程式碼
完整的程式碼+前面的例子

並查集的程式碼和邏輯都非常精簡,在我看來是非常優雅的資料結構。

#include<iostream>
#include <stdio.h>
#define N 10000+10

int parent[N];
int rank[N];
void make_set (){
    for (int i=0;i<N;i++){
        parent[i] = i;
        rank[i] = 0;
    }
}
int find_root(int node) {
    if (parent[node] == node) return node;
    parent[node] = find_root(parent[node]);
    return parent[node];
}
void union_set(int a,int b) {
    if (a==b) return; // 相同
    int root_a = find_root(a);//找到a的根結點
    int root_b = find_root(b);//找到b的根結點
    if (root_a == root_b)  return; //根結點相同
    int higher_root = rank[root_a]>rank[root_b] ?root_a:root_b;// 選出較高的樹
    int lower_root = rank[root_a]<rank[root_b]?root_a:root_b;// 選出較低的樹
    if( higher_root == lower_root ) {
        // 兩顆樹高度相等的情況
        parent[root_b] = root_a; //root_b.parent 指向 root_a (預設操作)
        ++rank[root_a];// 高度+1
    }else {
        parent[lower_root] = higher_root; // 較矮的樹指向較高的樹,不會改變整體高度
    }
}
int main(){
    make_set();
    union_set(0, 2);
    union_set(2, 4);
    union_set(1, 3);
    union_set(5, 3);
    union_set(2, 3);
    find_root(3);
    find_root(5);
    for(int i=0;i<6;i++) {
        printf("%d ",parent[i]);// 輸出所有指向
    }
}
複製程式碼

參考文章


Union-Find Algorithms
維基百科

推薦題目

PAT- 1118 Birds in Forest
PAT- 1118 Birds in Forest--程式碼


本篇已同步到個人部落格:優雅的資料結構–並查集

相關文章