1. 程式人生 > >資料結構與演算法(十二)並查集(Union Find)

資料結構與演算法(十二)並查集(Union Find)

本文主要包括以下內容:

  1. 並查集的概念
  2. 並查集的操作
  3. 並查集的實現和優化
    1. Quick Find
    2. Quick Union
    3. 基於size的優化
    4. 基於rank的優化
    5. 路徑壓縮優化
  4. 並查集的時間複雜度

並查集的概念

在電腦科學中,並查集 是一種樹形的資料結構,用於處理不交集的合併(union)及查詢(find)問題。

並查集 可用於查詢 網路 中兩個節點的狀態, 這裡的網路是一個抽象的概念, 不僅僅指網際網路中的網路, 也可以是人際關係的網路、交通網路等。

並查集 除了可以用於查詢 網路 中兩個節點的狀態, 還可以用於數學中集合相關的操作, 如求兩個集合的並集等。

並查集 對於查詢兩個節點的 連線狀態

非常高效。對於兩個節點是否相連,也可以通過求解 查詢路徑 來解決, 也就是說如果兩個點的連線路徑都求出來了,自然也就知道兩個點是否相連了,但是如果僅僅想知道兩個點是否相連,使用 路徑問題 來處理效率會低一些,並查集 就是一個很好的選擇。

並查集的操作

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

並查集的實現和優化

Quick Find方式實現的並查集

Quick Find 顧名思義就是並查集查詢操作快,合併比較慢。

我們通過一個數組來實現一個並查集,陣列索引作為資料編號:

這裡寫圖片描述

從上面的圖可以知道:0、1、2、3、4

屬於一個集合,5、6、7、8、9屬於一個集合。

45 兩個元素就不屬於同一個集合(或者不相連),因為他們對應的編號不一樣。4 對應的編號是 25對應的編號是 4

如果要合併兩個集合(union(1,5)),因為 15 是屬於兩個不同的集合
合併後,以前分別和元素 1 連線的元素;和 5 連線的元素,也都連線起來了:

這裡寫圖片描述

根據上面的描述得知,基於上面實現方案的並查集,查詢操作的時間複雜度為 O(1),合併操作的時間複雜度為 O(n)

程式碼如下所示:

public class UnionFind1 implements UF {

    private int
[] array; public UnionFind1(int size) { array = new int[size]; for (int i = 0; i < array.length; i++) { array[i] = i; } } @Override public int size() { return array.length; } private int find(int p) { return array[p]; } @Override public boolean isConnected(int p, int q) { return find(p) == find(q); } @Override public void unionElements(int p, int q) { int pID = find(p); int qID = find(q); //如果本身就是相連的 if (qID == pID) { return; } for (int i = 0; i < array.length; i++) { if (array[i] == pID) { array[i] = qID; } } } }

Quick Union 實現的並查集

從上面的實現的並查集我們知道,查詢的時間複雜度為 O(1) ,合併的時間複雜度為 O(n),如果資料量一大 O(n) 複雜度就顯得很慢了。 下面我們就來優化下上面實現的並查集。

通過樹形結構來描述節點之間的關係,底層儲存通過陣列來儲存。

以前我們介紹到樹都是父節點指向子節點的,這裡我們是通過子節點來指向父節點,根節點指向它自己。

陣列索引用來表示元素編號,儲存的是元素編號對應的父節點編號。如下圖所示:

這裡寫圖片描述

從上圖可以看出,每個節點的父節點編號都是它自己,說明每個節點都是一個根節點,那麼這個陣列就表示一個森林:

這裡寫圖片描述

例如:合併 12, 合併34,就變成:

這裡寫圖片描述

合併 13 ,找到 13 的對應的根節點,然後讓 1 的根節點指向 3 的根節點:

這裡寫圖片描述

從上面的分析,合併和查詢操作的時間複雜度為 O(h)h就是樹的高度。
相對 Quick Find 實現的並查集 Quick Union 實現的並查集犧牲了一點查詢的效能,提高了合併的效能。

程式碼如下:

public class UnionFind2 implements UF {

    private int[] parents;

    public UnionFind2(int size) {
        parents = new int[size];
        for (int i = 0; i < parents.length; i++) {
            parents[i] = i;
        }
    }

    @Override
    public int size() {
        return parents.length;
    }

    @Override
    public boolean isConnected(int p, int q) {
        return find(p) == find(q);
    }

    /**
     * 查詢某個節點的根節點
     * 時間複雜度為O(h)
     *
     * @param p
     * @return
     */
    private int find(int p) {
        while (p != parents[p]) {
            p = parents[p];
        }
        return p;
    }

    /**
     * 合併操作
     * @param p
     * @param q
     */
    public void unionElements(int p, int q) {
        int pRoot = find(p);
        int qRoot = find(q);
        if (pRoot == qRoot)  {
            return;
        }
        parents[pRoot] = qRoot;
    }

}

上面兩個實現並查集的效能對比:

測試方法:

private static double test(UF uf, int m) {

    long startTime = System.nanoTime();
    Random random = new Random();
    for (int i = 0; i < m; i++) {
        int p = random.nextInt(uf.size());
        int q = random.nextInt(uf.size());
        uf.unionElements(p, q);
    }

    for (int i = 0; i < m; i++) {
        int p = random.nextInt(uf.size());
        int q = random.nextInt(uf.size());
        uf.isConnected(p, q);
    }
    long endTime = System.nanoTime();
    return (endTime - startTime) / 1000000000.0;
}

int size = 100000; 元素個數
int p = 10000; //操作次數
double countTime1 = test(new UnionFind1(size), p);
double countTime2 = test(new UnionFind2(size), p);

輸出結果:

int size = 100000;
int p = 10000;
UnionFind1 = 0.546094344
UnionFind2 = 0.00757277

效能差距還是很顯著的。但是我們把運算元改成 p = 100000 ,效能對比如下:

UnionFind1 = 7.639128918
UnionFind2 = 12.913540497

發現 Quick Union 版本的並查集比 Quick Find 版本的並查集慢很多。
這是因為對於Quick Find 的並查集查詢的操作時間複雜度為 O(1),Quick Union的合併和查詢都是O(h),並且生成的樹深度可能很深。

下面就對 Quick Union 版本的並查集進行優化。

基於size的優化

上面Quick Union版本的並查集基於樹形結構實現的,但是沒有對樹的高度進行任何優化和限制。

所以導致在上面的效能比對中 Quick Union 的並查集效能很差。

我們來看下 Quick Union 版本的並查集是怎麼導致樹的高度變得很高的

假設我們經過了這樣的幾次 union 操作:

union(0,1)

union(0,1)

union(0,2)

union(0,2)

union(0,3)

union(0,3)

那麼基於size優化的思路就是:節點個數少的往節點個數多的樹去合併。

例如執行上面的 union(0,2)

這裡寫圖片描述

程式碼實現如下:

public class UnionFind3 implements UF {

    private int[] parents;
    private int[] sz;//記錄每棵樹的節點個數

    public UnionFind3(int size) {
        parents = new int[size];
        sz = new int[size];
        for (int i = 0; i < parents.length; i++) {
            parents[i] = i;
            sz[i] = 1;//每個根節點的一開始都只有一個節點
        }
    }

    @Override
    public int size() {
        return parents.length;
    }

    @Override
    public boolean isConnected(int p, int q) {
        return find(p) == find(q);
    }

    /**
     * 查詢某個節點的根節點
     * 時間複雜度為O(h)
     *
     * @param p
     * @return
     */
    private int find(int p) {
        while (p != parents[p]) {
            p = parents[p];
        }
        return p;
    }

    public void unionElements(int p, int q) {
        int pRoot = find(p);
        int qRoot = find(q);
        if (pRoot == qRoot) {
            return;
        }
        //根據根節點的子節點個數來判斷合併方向
        if (sz[pRoot] < sz[qRoot]) {
            parents[pRoot] = qRoot;
            sz[qRoot] += sz[pRoot];
        } else {
            parents[qRoot] = pRoot;
            sz[pRoot] += sz[qRoot];
        }

    }

}

現在來對比下上面三個版本的並查集效能:

//十萬級別
int size = 100000;
int p = 100000; 
UnionFind1 = 7.707820549
UnionFind2 = 12.475811439
UnionFind3 = 0.036647197    //基於size的優化

通過上面的優化,效能得到了極大的改善。基於size的並查集優化方案,主要是降低每棵樹的高度。

基於rank優化

上面基於size的優化方案,是節點數少的樹往節點數多的樹合併。

但是節點數多不代表樹的高度高,比如按照size的優化方案,執行 Union(2, 5),元素 2 所在的樹總節點數有4個,但只有2層;元素 5 所在的樹有3個節點,有3層。

合併如下過程:

這裡寫圖片描述

這個時候可以使用rank來優化,rank代表數的高度或深度。高度低的樹向高度高的樹合併。

使用rank的優化方案,執行 Union(2, 5),如下的合併過程:

這裡寫圖片描述

實現程式碼如下:

public class UnionFind4 implements UF {

    private int[] parents;
    //rank[i]表示i為根的集合所表示的樹的層數
    private int[] rank;

    public UnionFind4(int size) {
        parents = new int[size];
        rank = new int[size];
        for (int i = 0; i < parents.length; i++) {
            parents[i] = i;
            //每個根節點所在的樹一開始都只有一個層
            rank[i] = 1;
        }
    }

    @Override
    public int size() {
        return parents.length;
    }

    @Override
    public boolean isConnected(int p, int q) {
        return find(p) == find(q);
    }

    /**
     * 查詢某個節點的根節點
     * 時間複雜度為O(h)
     *
     * @param p
     * @return
     */
    private int find(int p) {
        while (p != parents[p]) {
            p = parents[p];
        }
        return p;
    }

    public void unionElements(int p, int q) {
        int pRoot = find(p);
        int qRoot = find(q);
        if (pRoot == qRoot) {
            return;
        }
        //根據根節點所在樹的層級來判斷合併方向
        //層級矮的樹往層級高的樹合併不需要維護rank
        if (rank[pRoot] < rank[qRoot]) {
            parents[pRoot] = qRoot;
        } else if (rank[pRoot] > rank[qRoot]) {
            parents[qRoot] = pRoot;
        } else {//只有rank相等的情況才需要維護rank
            parents[pRoot] = qRoot;
            rank[qRoot] += 1;
        }
    }

}

在我機器上的效能對比:

//十萬級別
int size = 100000;
int p = 100000;
UnionFind1 = 7.439281844  //quick find
UnionFind2 = 12.273788926 //quick union
UnionFind3 = 0.024156916  //基於size的優化
UnionFind4 = 0.02013447   //基於rank的優化

//千萬級別
int size = 10000000;
int p = 10000000;
//資料量太大,不測試UnionFind1和UnionFind2
UnionFind3 = 5.023308892   //基於size的優化
UnionFind4 = 4.741167168   //基於rank的優化

路徑壓縮優化

路徑壓縮基於rank的基礎上來做優化的。優化時機是在執行 find操作 的時候對其進行路徑壓縮。

private int find(int p) {
    while (p != parents[p]) {
        parents[p] = parents[parents[p]];
        p = parents[p];
    }
    return p;
}

路徑壓縮的流程如下:

這裡寫圖片描述

在我機器上的效能對比如下:

//千萬級別
int size = 10000000;
int p = 10000000;
UnionFind3 = 5.16725354   //基於size的優化
UnionFind4 = 5.148308358  //基於rank的優化
UnionFind5 = 4.526459366  //基於路徑壓縮的優化

至此,我們從一開始的 Quick Find 到 Quick Union優化,然後從 Quick Union 到基於 size 的優化,然後從基於size優化到基於rank優化,到最後的路徑壓縮優化,整個並查集的實現和優化就介紹完畢。

並查集的時間複雜度

在我們使用 Quick Union 版本的並查集使用樹形結構來組織節點的關係。

那麼效能跟樹的深度有關係,簡稱 O(h),以前介紹二分搜尋樹的時候,時間複雜度也是為 O(h)。

但是並查集並不是一個二叉樹,而是一個多叉樹,所以並查集的查詢和合並時間複雜度並不是O(log n)

在加上rank和路徑壓縮優化後 ,並查集的時間複雜度為 O(log* n)

log* n的數學定義:

這裡寫圖片描述

n lg* n
(−∞, 1] 0
(1, 2] 1
(2, 4] 2
(4, 16] 3
(16, 65536] 4
(65536, 2^65536] 5

O(log n) 的時間複雜度已經很快了,O(log* n) 是比O(log n) 還要快,近似等於O(1),比O(1)慢一點。