1. 程式人生 > >並查集演算法介紹

並查集演算法介紹

我們在一些應用當中,經常會遇到將n個不同的元素分成一組不相交的集合,例如某省調查城鎮交通狀況,得到現有城鎮道路統計表,當我們知道每條道路直接連通的城鎮時,問最少還需要建設多少條道路才能使全省任何兩個城鎮間都可以實現交通。類似這種應用,經常需要進行兩種特別的操作:尋找包含給定元素的唯一集合和和合並兩個集合。這裡,我們介紹如何維護一種被稱為“並查集”的資料結構來實現這些操作。

在此文中,我們綜合了網路部落格以及《演算法導論》書等多處蒐集到的資料(見文末),整理並分析了並查集演算法。

1.動態連通性

首先,我們先介紹“連通性”,連通性在許多領域中都有提到,我最早接觸連通,是在圖論中,基本可以理解為一個圖中的兩個點間有路徑可達,有通路,則稱這兩點連通。而在影象處理領域中,也有類似的概念,例如在二值影象中定義畫素p與畫素q連通,則兩個畫素之間應該存在一系列相互鄰接的畫素值相等。

動態連通性

而對於“動態連通性”,為更好理解,如圖1所示,假設我們輸入了一組整數對,即圖中左側的(4, 3) (3, 8)等等,每對整數代表這兩個points/sites是連通的。那麼隨著資料的不斷輸入,整個圖的連通性也會發生變化,從上圖中可以很清晰的發現這一點。同時,對於已經處於連通狀態的points/sites,直接忽略,比如上圖中的(8, 9)。

2.應用場景

動態連通性在許多領域中有所應用,就像文章開頭處我們所介紹的一種情況,除此之外,我們再列舉三種

  1. 網路連線判斷:
    如果每個pair中的兩個整數分別代表一個網路節點,那麼該pair就是用來表示這兩個節點是需要連通的。那麼為所有的pairs建立了動態連通圖後,就能夠儘可能少的減少佈線的需要,因為已經連通的兩個節點會被直接忽略掉。
  2. 變數名等同性(類似於指標的概念):
    在程式中,可以宣告多個引用來指向同一物件,這個時候就可以通過為程式中宣告的引用和實際物件建立動態連通圖來判斷哪些引用實際上是指向同一物件。
  3. 間接好友關係判斷:
    在社交網站中,假設我們已經知道每兩個人之間的關係,這個時候就可以通過並查集判斷任意兩個人是否存在間接好友關係(例如A和B為好友,B和C為好友,則A和C為間接好友)。

3.問題分析

在建模時,我們首先要明確需要解決的問題。首先,在並查集的問題中,我們只關心給定的節點是否連通,但並不關心具體的連通路徑,例如,我們在道路連通問題中,我們只關心兩個城鎮之間是否能夠走通,而並不關心是通過那條路走通的。所以,我們將相互連通的點表示為一個集合,而不是圖。

另外,該問題還有一個要點,即劃分後的集合是不相交的。我們在操作時,依次檢查每一個元素,並加入對應的集合,我們所要表示的,是這些不相交的集合作為元素所構成的集合。在有些地方,也將此資料結構稱為“不相交集合資料結構”,例如在《演算法導論》中使用ξ={S1,S2,…,Sk}表示一個不相交動態集的集合,在這當中,用一個代表來標識每個集合,它是這個集合ξ的某個成員。Sp與Sq中不存在交集,如果有,則Sp與Sq將會被自動合併為同一個集合,並用一個標識表示。

在一些應用當中,我們不關心哪個成員被用來做代表,僅僅關心的是兩次查詢動態集合的代表中,如果這些查詢沒有修改動態集合,則這兩次查詢得到的結果應該是相同答案。當然,也有些應用中,會預設一個規則來選取這個代表,比如選擇這個集合中最小的成員(當然假設集合中的元素可以被比較次序)。

我們用x表示一個集合中的一個元素(比如一個城鎮),則我們希望可以支援以下幾種操作:

  1. MAKE-SET(x):建立一個新的集合,它的唯一成員(因而為代表)是x,因為每個集合是不相交的,故x不會出現在某個集合中。
  2. UNION(x,y):將包含x和y的兩個動態集合(表示為Sx和Sy)合併成一個新的集合,即這兩個集合的並集。根據問題,兩者應該是不相交的。一般情況下,結果集的代表可以是合併後集合Sx∪Sy的任何成員,但一般實現中都是選擇Sx或Sy的代表作為新集合的代表。另外,合併兩者之後,應該將舊的集合Sx和Sy從ξ中刪除。實際上,我們在操作時,一般採用的方法是將起重工一個集合的元素,直接併入另一個集合中,來代替合併與刪除操作。
  3. FIND-SET(x):返回一個指標,這個指標指向包含x的(唯一)集合的代表。

4.實現

並查集的實現原理也比較簡單,就是使用樹來表示集合,樹的每個節點就表示集合中的一個元素,樹根對應的元素就是該集合的代表,如圖2所示。

並查集的樹的表示

圖中有兩棵樹,分別代表兩個集合,第一個集合為{a,b,c,d},代表元素為a。第二個集合為{e,f,g},代表元素為e。

樹的節點表示集合中的元素,指標表示指向父節點的指標,根節點的指標指向自己,表示其沒有父節點。沿著每個節點的父節點不斷向上查詢,最終就可以找到該樹的根節點,即該集合的代表元素。

現在,應該可以很容易的寫出 makeSet和find的程式碼了,假設使用一個足夠長的陣列來儲存樹節點(很類似之前講到的靜態連結串列),那麼 makeSet要做的就是構造出如圖3的森林,其中每個元素都是一個單元素集合,即父節點是其自身:

構造並查集初始化

為簡單起見,我們將所有的節點以整數表示,即對N個節點使用0到N-1的整數表示。而在處理輸入的Pair之前,每個節點必然都是孤立的,即他們分屬於不同的組,可以使用陣列來表示這一層關係,陣列的index是節點的整數表示,而相應的值就是該節點的組號了。

在此處,我們首先介紹Quick-Find演算法與Quick-Union演算法,然後,再介紹優化策略Weighted quick-union、Union by rank和path compression

4.1 Quick-Find

/**
* @brief 並查集演算法,Quick-Find
*/
class DisjointSet
{
public:
    /**
    * 建構函式,並設定元素個數
    * @param[in] size 初始化的並查集,設定的元素個數
    */
    DisjointSet(int size)
    {
        id = new int[size];
        for (int i = 0; i < size; ++i)
        {
            makeSet(i);
        }
        num = size;
    }

    /**
    * @brief 解構函式
    */
    ~DisjointSet()
    {
        delete[] id;
    }

    /**
     * @brief 將包含p和q的兩個動態集合(表示為Sp和Sq)合併成一個新的集合
     * @param[in] p 需要進行合併的其中一個集合元素
     * @param[in] q 需要進行合併的其中一個集合元素
     */
    inline void unionElem(int p, int q)
    {
        int pID = find(p);
        int qID = find(q);
        if (pID == qID) return;
        //遍歷一次,該表所有的組號,並該表其中一組的組號,使兩組合並
        for (int i = 0; i < num; ++i)
        {
            if (id[i] == pID)
                id[i] = qID;
        }
        --num;
    }

    /**
    * @brief 找到元素p的代表
    * @param[in] p需要獲取代表的元素
    * @return 元素p的代表
    */
    inline int find(int p)
    {
        return id[p];
    }

    /**
    * @brief 獲取集合的個數
    * @return 當前集合的個數
    */
    inline int getSetNum()
    {
        return num;
    }

private:
    /**
    * @brief 建立一個新的集合,它的唯一成員(因而為代表)是p
    * @param p 建立集合的唯一成員元素
    */
    inline void makeSet(int p)
    {
        id[p] = p;
    }

private:
    int * id;   //並查集的元素
    int num;    //並查集的組數
};

舉個例子,比如輸入的Pair是(5,9),那麼首先通過find方法發現它們的組號並不相同,然後在union的時候通過一次遍歷,將組號1都改成8。當然,由8改成1也是可以的,保證操作時都使用一種規則就行。

Quick-Find演算法過程概覽

上述程式碼的find方法十分高效,因為僅僅需要一次陣列讀取操作就能夠找到該節點的組號,但是問題隨之而來,對於需要新增新路徑的情況,就涉及到對於組號的修改,因為並不能確定哪些節點的組號需要被修改,因此就必須對整個陣列進行遍歷,找到需要修改的節點,逐一修改,這一下每次新增新路徑帶來的複雜度就是線性關係了,如果要新增的新路徑的數量是M,節點數量是N,那麼最後的時間複雜度就是MN,顯然是一個平方階的複雜度,對於大規模的資料而言,平方階的演算法是存在問題的,這種情況下,每次新增新路徑就是“牽一髮而動全身”,想要解決這個問題,關鍵就是要提高union方法的效率,讓它不再需要遍歷整個陣列。

4.2 Quick-Union

考慮一下,為什麼以上的解法會造成“牽一髮而動全身”?因為每個節點所屬的組號都是單獨記錄,各自為政的,沒有將它們以更好的方式組織起來,當涉及到修改的時候,除了逐一通知、修改,別無他法。所以現在的問題就變成了,如何將節點以更好的方式組織起來,組織的方式有很多種,但是最直觀的還是將組號相同的節點組織在一起,想想所學的資料結構,什麼樣子的資料結構能夠將一些節點給組織起來?常見的就是連結串列,圖,樹,什麼的了。但是哪種結構對於查詢和修改的效率最高?毫無疑問是樹,因此考慮如何將節點和組的關係以樹的形式表現出來。

如果不改變底層資料結構,即不改變使用陣列的表示方法的話。可以採用parent-link的方式將節點組織起來,舉例而言,id[p]的值就是p節點的父節點的序號,如果p是樹根的話,id[p]的值就是p,因此最後經過若干次查詢,一個節點總是能夠找到它的根節點,即滿足id[root] = root的節點也就是組的根節點了,然後就可以使用根節點的序號來表示組號。所以在處理一個pair的時候,將首先找到pair中每一個節點的組號(即它們所在樹的根節點的序號),如果屬於不同的組的話,就將其中一個根節點的父節點設定為另外一個根節點,相當於將一棵獨立的樹程式設計另一棵獨立的樹的子樹。直觀的過程如下圖所示。但是這個時候又引入了問題。

Quick-union演算法過程概覽

在實現上,和之前的Quick-Find只有find和union兩個方法有所不同:

/**
    * @brief 將包含p和q的兩個動態集合(表示為Sp和Sq)合併成一個新的集合
    * @param[in] p 需要進行合併的其中一個集合元素
    * @param[in] q 需要進行合併的其中一個集合元素
    */
    inline void unionElem(int p, int q)
    {
        // Give p and q the same root.  
        int pRoot = find(p);
        int qRoot = find(q);
        if (pRoot == qRoot)
            return;
        id[pRoot] = qRoot;    // 將一顆樹(即一個組)變成另外一課樹(即一個組)的子樹 
        --num;
    }

    /**
    * @brief 找到元素p的代表
    * @param[in] p需要獲取代表的元素
    * @return 元素p的代表
    */
    inline int find(int p)
    {
        //尋找p節點所在組的根節點,根節點具有性質id[root] = root
        while (p != id[p]) p = id[p];
        return p;
    }

樹這種資料結構容易出現極端情況,因為在建樹的過程中,樹的最終形態嚴重依賴於輸入資料本身的性質,比如資料是否排序,是否隨機分佈等等。比如在輸入資料是有序的情況下,構造的BST會退化成一個連結串列。在我們這個問題中,也是會出現的極端情況的,如下圖所示。

Quick-union最差情況樣例

4.3 Weighted quick-union

實際上,在大部分應用場景中,我們希望獲得一個不錯的Union效率,也不希望Find的時間太長。這時,我們有一些優化的策略對待這種情況。在上述Quick-Union中,當我們分析所出現的極端情況時,不難發現,造成這種查詢鏈太長的原因在於,我們在Union兩個集合時,直接約定了將p所在的樹掛在q所在的樹上,即“id[pRoot] = qRoot”。這種情況,當p所在的樹規模比q所在的樹規模大的多時,p和q結合之後形成的樹就是十分不和諧的一頭輕一頭重的”畸形樹“了。

所以,我們可以做些改進,來避免這種情況,這時,就出現了Weighted quick-union,總是使規模較小的樹作為規模較大的樹的子樹進行合併,從而保證整個樹儘量平衡。

Weighted quick-union原理

那麼,我們如何來衡量一個樹的大小呢?有一種非常直觀的方式,使用樹中節點的個數。我們在根節點中記錄這棵樹中總共有的節點個數,然後,根據樹的節點個數多少,來判定樹的大小。具體完整的程式碼如下,其中,主要增加了一個weight序列來記錄每棵樹的權重。已經將其相對於Quick-Union改動的地方標記了出來,

/**
* @brief 並查集演算法,Weighted Quick-Union
*/
class DisjointSet
{
public:
    /**
    * 建構函式,並設定元素個數
    * @param[in] size 初始化的並查集,設定的元素個數
    */
    DisjointSet(int size)
    {
        id = new int[size];
        weight = new int[size];<
        for (int i = 0; i < size; ++i)
        {
            makeSet(i);
        }
        num = size;
    }

    /**
    * @brief 解構函式
    */
    ~DisjointSet()
    {
        delete[] id;
        delete[] weight;
    }

    /**
    * @brief 將包含p和q的兩個動態集合(表示為Sp和Sq)合併成一個新的集合
    * @param[in] p 需要進行合併的其中一個集合元素
    * @param[in] q 需要進行合併的其中一個集合元素
    */
    inline void unionElem(int p, int q)
    {
        // Give p and q the same root.  
        int pRoot = find(p);
        int qRoot = find(q);
        if (pRoot == qRoot)
            return;

        //將小樹作為大樹的子樹
        if (weight[pRoot] < weight[qRoot])
        {
            id[pRoot] = qRoot;
            weight[qRoot] += weight[pRoot];
        }
        else
        {
            id[qRoot] = pRoot;
            weight[pRoot] += weight[qRoot];
        }

        --num;
    }

    /**
    * @brief 找到元素p的代表
    * @param[in] p需要獲取代表的元素
    * @return 元素p的代表
    */
    inline int find(int p)
    {
        //尋找p節點所在組的根節點,根節點具有性質id[root] = root
        while (p != id[p]) p = id[p];
        return p;
    }

    /**
    * @brief 獲取集合的個數
    * @return 當前集合的個數
    */
    inline int getSetNum()
    {
        return num;
    }

private:
    /**
    * @brief 建立一個新的集合,它的唯一成員(因而為代表)是p
    * @param p 建立集合的唯一成員元素
    */
    inline void makeSet(int p)
    {
        id[p] = p;
        weight[p] = 1;  //每個節點初始化權重都是(都只含有一個節點)
    }

private:
    int * id;       //並查集的元素
    int * weight;   //對應一棵樹中節點的個數,標識樹的大小,並將其作為權重
    int num;        //並查集的組數
};

經過以上的修改,用少許的Union代價,從而換取Find的效率提升,如下圖,生成的樹狀結構的改變:

Quick-union and weighted quick-union演算法結果樣例

經過比較,可以發現,通過使用weighted quick union方法,最後得到的樹的高度大幅度減少了。這十分有意義,因為在Quick-Union演算法中任何操作,都不可避免的需要呼叫find方法,而該方法的執行效率依賴於樹的高度。樹的高度減小了,find方法的效率就增加了,從而也就增加了整個Quick-Union演算法的效率。

4.4 Union by rank

通過分析,我們知道通過使用weighted quick-union方法,可以在整體上降低樹的高度,從而加快find的效率。但是,你是否發現了一些問題?我們在選取樹的大小時,選取的是樹中節點的個數。而實際上與find效率有關的量是樹的高度!也就是說,weighted quick-union之所以有效,是通過間接的影響樹的高度完成的。那麼,反過來說,我們為什麼不直接拿樹的高度作為權重引數,從而來指導樹的構造。這時,就產生一個新的演算法,即Union by rank。

我們只需要將weighted quick-union演算法中的weight中的樹的節點數目,更改為儲存樹的高度即可(在大部分實現中,使用rank一詞,所以,稱為union by rank)。為了實現這個演算法,我們僅僅需要改動unionElem方法即可,將原有修改權重為樹的大小的方法改為修改為樹的高度。

/**
    * @brief 將包含p和q的兩個動態集合(表示為Sp和Sq)合併成一個新的集合
    * @param[in] p 需要進行合併的其中一個集合元素
    * @param[in] q 需要進行合併的其中一個集合元素
    */
    inline void unionElem(int p, int q)
    {
        // Give p and q the same root.  
        int pRoot = find(p);
        int qRoot = find(q);
        if (pRoot == qRoot)
            return;
        //將高度較低的樹做為高度較低的樹的子樹
        if (weight[pRoot] < weight[qRoot])
        {
            id[pRoot] = qRoot;
        }
        else
        {
            id[qRoot] = pRoot;
            if (weight[pRoot] == weight[qRoot])
                ++weight[pRoot];
        }
        --num;
    }

4.5 Path compression

通過對weighted quick-union與union by rank的分析,我們已經意識到,find的速度是與樹的高度有關的,樹越是扁平,則find速度越快。在find方法的實現當中,是經過一個while迴圈實現的。如果我們儲存所有路過的中間節點到一個數組中,然後在while迴圈結束之後,將這些中間節點的父節點指向根節點,不就行了麼?但是這個方法也有問題,因為find操作的頻繁性,會造成頻繁生成中間節點陣列,相應的分配銷燬的時間自然就上升了。那麼有沒有更好的方法呢?還是有的,即將節點的父節點指向該節點的爺爺節點,這一點很巧妙,十分方便且有效,相當於在尋找根節點的同時,對路徑進行了壓縮,使整個樹結構扁平化。相應的實現如下,實際上只需要新增一行程式碼,即更改find方法如下:

/**
    * @brief 找到元素p的代表
    * @param[in] p需要獲取代表的元素
    * @return 元素p的代表
    */
    inline int find(int p)
    {
        //尋找p節點所在組的根節點,根節點具有性質id[root] = root
        while (p != id[p])
        {
            //將p節點的父節點設定為它的爺爺節點,完成路徑壓縮
            id[p] = id[id[p]];
            p = id[p];
        }
        return p;
    }

到這裡,是不是已經結束了?沒錯。但是,我們再討論另外一種實現,遞迴版本的find實現。我們在這裡直接給出帶path compression的實現:
   
/**
    * @brief 找到元素p的代表
    * @param[in] p需要獲取代表的元素
    * @return 元素p的代表
    */
    inline int find(int p)
    {
        if (p != id[p])
            id[p] = find(id[p]); //path compression
        return id[p];
    }

該過程是一種兩趟方法:當它遞迴時,第一趟沿著查詢路徑向上直到找到根,當遞歸回溯時,第二趟沿著搜尋樹向下更新每個節點,使其直接指向根。如果p節點不是根節點,則尋找其指向的父節點的根節點,然後將自身直接指向根節點;如果p節點是根節點,則直接返回根節點,不再操作。

5. 複雜度分析

至此,並查集演算法基本介紹完畢,我們列出這幾種演算法的複雜度如下:

Algorithm

Constructor

Union

Find

Quick-Find

N

N

1

Quick-Union

N

Tree height

Tree height

Weighted Quick-Union

N

lgN

lgN

Union by rank

N

lgN

lgN

Weighted Quick-Union With Path Compression

N

Very near to 1 (amortized)

Very near to 1 (amortized)

Union by rank

 With Path Compression

N

Very near to 1 (amortized)

Very near to 1 (amortized)


對大規模資料進行處理,使用平方階的演算法是不合適的,比如簡單直觀的Quick-Find演算法,通過發現問題的更多特點,找到合適的資料結構,然後有針對性的進行改進,得到了Quick-Union演算法及其多種改進演算法,最終使得演算法的複雜度降低到了近乎線性複雜度。

在最後,我們附上最終完整的C++實現程式碼:

/**
 * @brief 並查集演算法,Union by rank With Path Compression
 */
class DisjointSet
{
public:
    /**
    * 建構函式,並設定元素個數
    * @param[in] size 初始化的並查集,設定的元素個數
    */
    DisjointSet(int size)
    {
        id = new int[size];
        weight = new int[size];
        for (int i = 0; i < size; ++i)
        {
            makeSet(i);
        }
        num = size;
    }

    /**
    * @brief 解構函式
    */
    ~DisjointSet()
    {
        delete[] id;
        delete[] weight;
    }

    /**
    * @brief 將包含p和q的兩個動態集合(表示為Sp和Sq)合併成一個新的集合
    * @param[in] p 需要進行合併的其中一個集合元素
    * @param[in] q 需要進行合併的其中一個集合元素
    */
    inline void unionElem(int p, int q)
    {
        // Give p and q the same root.  
        int pRoot = find(p);
        int qRoot = find(q);
        if (pRoot == qRoot)
            return;
        //將高度較低的樹做為高度較低的樹的子樹
        if (weight[pRoot] < weight[qRoot])
        {
            id[pRoot] = qRoot;
        }
        else
        {
            id[qRoot] = pRoot;
            if (weight[pRoot] == weight[qRoot])
                ++weight[pRoot];
        }
        --num;
    }

    /**
    * @brief 找到元素p的代表
    * @param[in] p需要獲取代表的元素
    * @return 元素p的代表
    */
    inline int find(int p)
    {
        if (p != id[p])
            id[p] = find(id[p]);
        return id[p];
    }

    /**
    * @brief 獲取集合的個數
    * @return 當前集合的個數
    */
    inline int getSetNum()
    {
        return num;
    }

private:
    /**
    * @brief 建立一個新的集合,它的唯一成員(因而為代表)是p
    * @param p 建立集合的唯一成員元素
    */
    inline void makeSet(int p)
    {
        id[p] = p;
        weight[p] = 1;  //每個節點初始化權重都是(都只含有一層)
    }

private:
    int * id;       //並查集的元素
    int * weight;   //對應一棵樹的高度,並將其作為權重,即rank
    int num;        //並查集的組數
};

本文參考資料:

《演算法導論》Thomas H.Cormen,Charles E.Leiserson等,第21章

特此說明,文章內的圖片以及文字,很多摘自上述參考博文中,本人只進行了些整理,再次感謝原作者!