資料結構之並查集

codercat發表於2019-08-29

定義

並查集是電腦科學中為了解決集合之間的合併和查詢操作而存在的一種樹型資料結構。並查集是由若干個大小不同的子樹來儲存表示的,也可以把整個並查集的儲存結構叫做森林,每顆子樹表示一個集合。集合的數量又叫做連通分量

基本操作

  • find(查詢):確定元素屬於哪一個子集。它可以被用來確定兩個元素是否屬於同一子集。
  • union(合併):將兩個子集合併成同一個集合。
  • isConnected(兩個元素是否相連):確定兩個元素是否屬於同一子集或者確定兩個元素是否相連。

名詞解釋

  • 森林:由若干個大小不同的子樹所表示的資料結構。
  • 連通分量:在這裡簡單的可以理解為並查集中集合的數量。
  • 樹的大小:樹的節點數量。
  • 樹中某個節點的深度:該節點到樹的根節點的路徑上的連結數。
  • 樹的高度:樹中所有節點中的最大深度。

解決的問題

  • 在社交網路中判斷兩個人是否屬於同一個交際圈。
  • 查詢網路中的兩個網路節點是否相連。
  • 數學中判斷兩個元素是否屬於同一個集合。
  • 數學中把兩個不相交的子集合併成一個集合。

並查集的實現

並查集有兩種實現方式,分別是quick findquick union,其中quick union有兩個優化思路,文中都會詳細介紹。

quick find

為每一個集合都選取一個元素做為該集合的唯一編號,如果有兩個元素它們的編號相同,那麼就說明它們屬於同一個集合。

初始化

初始情況下如果有N個元素,我們認為這個N個元素之間都是相互獨立的,也就是一共有N個集合,每個集合只有一個元素。定義一個叫做ids的陣列用來儲存每個集合的編號。每個集合的編號只需要保證不重複即可。可以迴圈N次為每個集合從0到N-1編號。

如圖所示:

程式碼實現:

class UnionFind {
private:
    // 並查集中的元素個數
    unsigned int elementNum = 0;
   // 聯通分量,也是集合的數量
    unsigned int connectedComponent = 0;
    // 儲存每個集合的編號
    int *ids;
public:
    UnionFind(unsigned int elementNum) {
        this->elementNum = elementNum;
        // 初始情況下聯通分量為元素個數
        this->connectedComponent = elementNum;
        this->ids = new int[elementNum];
        // 初始化每個集合的編號為0至size-1
        for (int i = 0; i < elementNum; i++) {
            this->ids[i] = i;
        }
    }
};
查詢

查詢一個元素屬於哪個集合,只需要檢視這個元素的id。這也是為什麼叫quick find,因為查詢操作只需要O(1)的時間複雜度。

下圖中0,1,2這三個元素的id都是0,元素3和元素4id分別是3和4。

程式碼實現:

int find(element) {
    return this->ids[element];
}
兩個元素是否相連

判斷兩個元素是否屬於同一個集合時,只需要對比兩個元素的id是否相等即可。

下圖中0,1,2這三個元素的id都是0,所以它們是相連的。節點3和元素0,1,2都不相連,因為它們id不同。

程式碼實現:

bool isConnected(int p ,int q) {
    return this->find( p) == this->find(q); 
}
合併

合併兩個集合時,只需要把任意其中一個集合中的所有元素的id修改成另外一個集合的id即可,這樣一來原先的兩個集合都指向了同一個id,就完成了合併操作。

如果我們需要把上圖中元素3和元素2合併,就有兩個辦法分別是:

  • 把元素2所在的集合的所有元素的id修改成元素3所在集合的id

  • 把元素3所在的集合的所有元素修改成元素2所在集合的id

不管選擇哪一種方法最終所表示的集合都是等價的,所以兩種方法都可以。

程式碼實現:

void unionElement(int p, int q) {
    int pId = this->find(p);
    int qId = this->find(q);
    // 如果p和q本來就相連就不需要合併
    if (pId == qId) {
        return;
    }
    for (int i = 0; i < this->size; i ++) {
        // 把其中一個集合中的所有元素的id修改成另外一個集合的id
        if  (this->id[i] == pId) {
            this->ids[i] = qId;
        }
    }
    // 合併之後少了一個集合,connectedComponent就應該-1
    this->connectedComponent --;
}

quick find這種實現方式,它的優點在於可以快速的查詢元素屬於哪一個集合。缺點是在每次做合併的時候都需要遍歷整個ids陣列然後去修改其中一個集合的所有元素的id,這樣就會導致合併操作在資料量大的時候時間複雜度很高。

quick union

把每個元素看成一個節點,每個節點指向該節點的父節點。這一點跟傳統的樹行結構不太一樣,傳統的樹行結構是節點指向該節點的孩子節點。並查集中的每棵樹都表示一個集合,整個並查集所表示的就是一個森林。每棵樹會選取一個節點作為代表,用來代表這棵樹所表示的集合,這個代表節點也是樹的根節點。如果兩個元素的根節點相同則它們屬於同一棵樹也就是屬於同一個集合。

初始化

初始情況下,我們還是認為每個元素(節點)之間相互獨立,每棵樹只有一個根節點就是其本身,這個根節點用來代表這棵樹所表示的集合。定義一個叫做parents的陣列用來儲存每個節點的父節點。

如圖所示:

程式碼實現:

class UnionFind {
private:
    // 並查集中的元素個數
    unsigned int elementNum = 0;
    // 聯通分量,也是集合的數量
    unsigned int connectedComponent = 0;
    // 儲存每個節點的父節點
    int *parents;
public:
    UnionFind(unsigned int elementNum) {
        this->elementNum = elementNum;
        // 初始情況下聯通分量為元素個數
        this->connectedComponent = elementNum;
        this->parents = new int[elementNum];
        // 初始化每個節點的父節點是其本身
        for (int i = 0; i < elementNum; i++) {
            this->parents[i] = i;
        }
    }
};
查詢

查詢一個元素屬於哪個集合,只需要檢視該元素所在樹的根節點的那個元素是誰,就屬於哪個集合。

下圖中0,1,2,3這四個元素的根節點都是0,元素3根節點是3。整個並查集是由兩棵樹組成的一個森林。

如果要查詢節點3屬於哪一個集合就需要在parents陣列中遞迴去尋找樹的根節點,直到找到某個節點的父節點是其本身的一個節點,這個節點就是樹的根節點。

遞迴實現:

int find(element) {
    int parent = parents[element];
    // 如果某個節點的父節點是其本身的一個節點,那麼就是一個根節點
    if (parent == element) {
        return parent;
    }
    // 繼續遞迴查詢
    return this->find(parent);
}

迴圈實現:

int find(element) {
    while (parents[element]  != element) {
       element =  parents[element];
    }
    return element;
}
兩個元素是否相連

判斷兩個元素是否屬於同一個集合時,只需要對比兩個元素所在樹的根節點是否是否是同一個節點即可。

下圖中0,1,2,3這四個元素的根節點都是0,證明它們屬於同一棵樹且相連。元素3根節點是3。所以節點3和元素0,1,2,3都不相連,因為它們的根節點不同。

程式碼實現:

bool isConnected(int p ,int q) {
    return this->find( p) == this->find(q); 
}
合併

合併兩個集合時,只需要把其中任意一顆樹的根節點的父節點修改成另一個顆樹的根節點,這樣一來原先兩顆樹的根節點都是同一個節點,就完成了合併操作。

下圖中有兩個集合,包含的元素分別是0,1,2,34,5,6。對應的兩棵樹的根節點分別是04。當合並這兩個集合時,我們可以把這兩棵樹中的任意一棵樹的根節點的父節點修改成另一棵樹的根節點就可以完成合並操作。

我們把根節點為4的這棵樹的根節點的父節點修改成0,就合併成了一個集合。樹的形狀也就變成了下面這個樣子:

程式碼實現:

    void unionElement(int p, int q)  {
        int pRoot = this->find(p);
        int qRoot = this->find(q);

        // 如果兩個元素的根節點相同,則代表它們屬於同一個集合,就不再需要合併
        if ( pRoot == qRoot )  {
            return;
        }
        // 修改其中一棵樹根節點的父節點
        this->parents[qRoot] = pRoot;
       // 合併之後少了一個集合,connectedComponent就應該-1
        this->connectedComponent --;
    }

加權quick union

quick union合併時存在的問題

下圖中兩棵樹所表示的集合相同,都分別表示的是擁有0,1,2,3,4這5個元素的一個集合。

左邊樹中節點4的深度為3,而右邊樹中節點4的深度則為1。通過quick unionfind的實現可以知道,當在左邊樹中執行find(4)時需要的時間複雜度是要高於右邊樹中執行find(4)的,因為。所以得出一個結論就是:quick union中的find操作的時間複雜度是跟要查詢節點在樹中的深度相關的。find操作需要一直向上尋找根節點,如果要查詢的節點在樹中深度很深,那麼需要尋找根節點的次數也就會越多。

優化思路

由於在quick union中對兩個集合進行union操作時,不管是哪棵樹的根節點的父節點修改成另外一棵樹的根節點最終所得到的新樹所表示的集合都是等價的。

所以我們可以在union操作時把樹的高度較小的那棵樹的根節點的父節點修改成樹的高度較大的那顆樹的根節點,在兩棵樹的高度相等時,就跟先前一樣不管是哪棵樹的根節點的父節點修改成另外一棵樹的根節點最終所得到的新樹所表示的集合和新樹的高度都是一樣的。

優化思路證明

存在兩顆樹分別是AB,它們樹的高度分別是AhBhAh <Bh。我們有兩種辦法可以來完成union操作,分別是:

  • A的根節點的父節點修改成樹B的根節點(優化思路)

    修改之後由於在原來樹A的根節點新增了一個節點,所以在新的樹中原來樹A的高度為Ah+1。由於Ah <Bh,所以Ah+1<=Bh。得到新樹的最大深度為Bh

  • B的根節點的父節點修改成樹A的根節點

    修改之後由於在原來樹B的根節點新增了一個節點,所以在新的樹中原來樹B的高度為Bh+1。由於Ah <Bh,所以Ah<Bh+1。得到新樹的最大深度為Bh+1

得到結果Bh<Bh+1就可以證明我們的優化思路是可以降低節點在樹中的深度的。

具體實現

初始化的時候跟quick union一樣,只是多了陣列用來存放每個節點的深度,我們定義這個陣列叫ranks。初始化情況下我們讓每個節點在樹中的深度為1findisConnected的實現跟之前的quick union一樣。

程式碼實現:

class UnionFind {
private:
    // 並查集中的元素個數
    unsigned int elementNum = 0;
    // 聯通分量,也是集合的數量
    unsigned int connectedComponent = 0;
    // 儲存每個節點的父節點
    int *parents;
    // 記錄每個根節點所在樹的深度
    int *ranks;
public:
    UnionFind(unsigned int elementNum) {
        this->elementNum = elementNum;
        // 初始情況下聯通分量為元素個數
        this->connectedComponent = elementNum;
        this->parents = new int[elementNum];
        this->ranks = new int[elementNum];
        for (int i = 0; i < elementNum; i++) {
            // 初始化每個節點的父節點是其本身
            this->parents[i] = i;
            this->ranks[i] = 1;
        }
    }

    // 合併元素
    void unionElement(int p, int q) {
        int pRoot = this->find(p);
        int qRoot = this->find(q);
        // 如果兩個元素的根節點相同,則代表它們屬於同一個集合,就不再需要合併
        if (pRoot == qRoot) {
            return;
        }
        int pRank = this->ranks[pRoot];
        int qRank = this->ranks[qRoot];
        // 把深度低的樹的根節點指向深度高的樹的根節點。
        if (pRank > qRank) {
            this->parents[qRoot] = pRoot;
        } else if (pRank < qRank) {
            this->parents[pRoot] = qRoot;
        } else {
            this->parents[pRoot] = qRoot;
            this->ranks[qRoot]++;
        }
        // 合併之後少了一個集合,connectedComponent就應該-1
        this->connectedComponent --;
    }
};

路徑壓縮

最優樹結構

通過我們優化之後的加權quick union還是沒有達到我們最優樹結構。下圖的兩棵樹都表示的是兩個相同的集合。左邊樹中的每個節點與根節點的距離大於等於1。右邊樹中的每個節點與根節點的距離等於1,也就是我們想要的最優樹結構。

我們需要一個演算法要壓縮路徑使得樹中的每個節點與根節點的距離為1

路徑壓縮思路

把某個節點的父節點修改成該節點父節點的父節點,一直向上對這條連結上的每個節點做相同的操作直到遇到根節點終止,就可以壓縮每個節點到根節點的距離。

路徑壓縮過程演示

左邊樹中節點4的父節點的父節點是2,所以把左邊樹中節點4的父節點修改成2,得到右邊的樹:

左邊樹中節點4的父節點的父節點是0,所以把左邊樹中節點4的父節點修改成0,得到右邊的樹:

左邊樹中節點3的父節點的父節點是0,所以最後把左邊樹中節點3的父節點修改成0,就得到右邊的樹:

這樣一番操作之後最終得到結果就是我們想要的樹結構。

具體實現

由於路徑壓縮的過程跟find操作有些類似,都是向上尋找節點,且都是遇到根節點終止,所以把路徑壓縮的過程就直接放在了find操作中。union操作不需要做任何改動。

find程式碼實現:

    int find(int element) {
        int parent = parents[element];
        if (parent == element) {
            return parent;
        }
        // 路徑壓縮,parents[parent]得到的就是當前節點父節點的父節點
        parents[element] = parents[parent];
        return this->find(parents[element]);
    }

完整原始碼

原始碼:https://github.com/acodercat/cpp-algorithm...

使用示例:https://github.com/acodercat/cpp-algorithm...

相關文章