並查集算法詳解

並查集


在計算機科學中,並查集是一種樹型的數據結構,用於處理一些不交集(Disjoint Sets)的合併及查詢問題。有一個聯合- 查找算法( union-findalgorithm)定義了兩個用於此數據結構的操作:

  • Find:確定元素屬於哪一個子集。它可以被用來確定兩個元素是否屬同一子集。
  • Union:將兩個子集合併成同一個集合。

由於支持這兩種操作,一個不相交集也常被稱爲聯合-查找數據結構(union-find data structure)或合併-查找集合(merge-find set)。其他的重要方法,MakeSet,用於建立單元素集合。有了這些方法,許多經典的劃分問題可以被解決。

爲了更加精確的定義這些方法,需要定義如何表示集合。一種常用的策略是爲每個集合選定一個固定的元素,稱爲代表,以表示整個集合。接着,Find(x) 返回 x 所屬集合的代表,而 Union 使用兩個集合的代表作爲參數。

 

並查集森林


並查集森林是一種將每一個集合以樹表示的數據結構,其中每一個節點保存着到它的父節點的引用(見意大利麪條堆棧)。這個數據結構最早由 Bernard A. Galler 和 Michael J. Fischer 於 1964 年提出,但是經過了數年才完成了精確的分析。
在並查集森林中,每個集合的代表即是集合的根節點。“查找”根據其父節點的引用向根行進直到到底樹根。“聯合”將兩棵樹合併到一起,這通過將一棵樹的根連接到另一棵樹的根。實現這樣操作的一種方法是

function MakeSet(x)
    x.parent := x
function Find(x)
    if x.parent == x
        return x
    else
        return Find(x.parent)
function Union(x, y)
    xRoot := Find(x)
    yRoot := Find(y)
    xRoot.parent := yRoot

這是並查集森林的最基礎的表示方法,這個方法不會比鏈表法好,這是因爲創建的樹可能會嚴重不平衡;然而,可以用兩種辦法優化。
第一種方法,稱爲“按秩合併”,即總是將更小的樹連接至更大的樹上。因爲影響運行時間的是樹的深度,更小的樹添加到更深的樹的根上將不會增加秩除非它們的秩相同。在這個算法中,術語“秩”替代了“深度”,因爲同時應用了路徑壓縮時(見下文)秩將不會與高度相同。單元素的樹的秩定義爲 o,當兩棵秩同爲r的樹聯合時,它們的秩 r+1。只使用這個方法將使最壞的運行時間提高至每個 MakeSet,\,Union 或Find操作 O(log\,n)。優化後的 MakeSet 和 Union 僞代碼:

function MakeSet(x)
    x.parent := x
    x.rank := 0
function Union(x, y)
    xRoot := Find(x)
    yRoot := Find(y)
    if xRoot == yRoot
        return

    // x和y不在同一個集合,合併它們。
    if xRoot.rank < yRoot.rank
        xRoot.parent := yRoot
    else if xRoot.rank > yRoot.rank
        yRoot.parent := xRoot
    else
        yRoot.parent := xRoot
        xRoot.rank := xRoot.rank + 1

第二個優化,稱爲“路徑壓縮”,是一種在執行“查找”時扁平化樹結構的方法。關鍵在於在路徑上的每個節點都可以直接連接到根上;他們都有同樣的表示方法。爲了達到這樣的效果,Find 遞歸地經過樹,改變每一個節點的引用到根節點。得到的樹將更加扁平,爲以後直接或者間接引用節點的操作加速。這兒是 Find

function Find(x)
    if x.parent != x
        x.parent := Find(x.parent)
    return x.parent

這兩種方法的優勢互補,同時使用二者的程序每個操作的平均時間僅爲 O(a(n)), a(n) 是 n = f(x) = A(x, x) 的反函數,其中 A 是急速增加的阿克曼函數。因爲 a(n) 是其的反函數,故 a(n) 在 n 十分巨大時還是小於 5。因此,平均運行時間是一個極小的常數。
實際上,這是漸近最優算法:Fredman 和 Saks 在 1989 年解釋了 \Omega (a(n)) 的平均時間內可以獲得任何並查集。

 

主要操作


需要注意的是,一開始我們假設元素都是分別屬於一個獨立的集合裏的。

合併兩個不相交集合

操作很簡單:先設置一個數組(陣列) Father[x],表示 x 的“父親”的編號。 那麼,合併兩個不相交集合的方法就是,找到其中一個集合最父親的父親(也就是最久遠的祖先),將另外一個集合的最久遠的祖先的父親指向它。

void Union(int x,int y)
{
    int fx = getfather(x);
    int fy = getfather(y);
    if(fy != fx)
        father[fx] = fy;
}

判斷兩個元素是否屬於同一集合

仍然使用上面的數組。則本操作即可轉換爲尋找兩個元素的最久遠祖先是否相同。尋找祖先可以採用遞歸實現,見後面的
路徑壓縮算法。

bool same(int x,int y)
{
    return getfather(x) == getfather(y);
}
/*返回true 表示相同根結點,返回false不相同*/

 

並查集的優化


路徑壓縮

剛纔我們說過,尋找祖先時採用遞歸,但是一旦元素一多起來,或退化成一條鏈,每次 GetFather 都將會使用 O(n) 的複雜度,這顯然不是我們想要的。
對此,我們必須要進行路徑壓縮,即我們找到最久遠的祖先時“順便”把它的子孫直接連接到它上面。這就是路徑壓縮了。使用路徑壓縮的代碼如下:

int getfather(int v)
{
    if (father[v] == v)
        return v;
    else
    {
        father[v] = getfather(father[v]); //路徑壓縮
        return father[v];
    }
}

Rank合併

合併時將元素所在深度小的集合合併到元素所在深度大的集合。

void judge(int x ,int y)
{
    int fx = getfather(x);
    int fy = getfather(y);
    if (rank[fx] > rank[fy])
        father[fy] = fx;
    else
    {
        father[fx] = fy;
        if(rank[fx] == rank[fy])
            ++rank[fy];//重要的是祖先的rank,所以只用修改祖先的rank就可以了,子節點的rank不用管
    }
}

初始化:

memset(rank, 0, sizeof(rank));

 

時間及空間複雜度


時間複雜度

同時使用路徑壓縮、按秩( rank) 合併優化的程序每個操作的平均時間僅爲 O(a(n)), 其中 a(n) 是 n = f(x) = A(x, x) 的反函數,A 是急速增加的阿克曼函數。因爲 a(n) 是其反函數,故 a(n) 在 n 十分巨大時還是小於 5 。因此,平均運行時間是一個極小的常數。實際上,這是漸近最優算法:FredmanSaks1989 年解釋了 \Omega (a(n)) 的平均時間內可以獲得任何並查集。

空間複雜度

O(n) (n 爲元素的數量)

 

應用


Kruskal 算法的優化

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章