1. 程式人生 > 其它 >UnionFind 並查集

UnionFind 並查集

UnionFind(並查集)

簡介

UnionFind 主要用於解決圖論中的動態聯通性的問題(對於輸入的一系列元素集合,判斷其中的元素是否是相連通的)。

以下圖為例:

集合[1, 2, 3, 4] 和 [5, 6]中的每個元素之間都是相聯通的,及 1 和 2、3、4都是連通的,而 1 和 5 則不是連通的。

UnionFind 主要的 API:

void union(int p, int q); 		// 將 p 和 q 連線起來
boolean connect(int p, int q);	// 判斷 p 和 q 是否是相互連通的

這裡的 “連通” 有以下性質:

  • 自反性:p 和 p 自身是連通的
  • 對稱性:如果 p 和 q 是連通的,那麼 q 和 p 也是連通的
  • 傳遞性:如果 p 和 q 是連通,並且 q 和 r 是連通的,那麼 p 和 r 也是聯通的

實現

使用陣列來儲存連線的節點是一個可選的方案,使用陣列 parent 來表示每個索引元素的直接連線節點,parent 的每個元素代表當前索引位置對應的元素的直接連線元素。

以上文的示例為例,初始時每個元素的連線情況如下圖所示:

因為每個元素此時是沒有與其他元素連線的,此時連線的元素就是它本身

此時的 parent 元素組成如下所示:

將 2 連線到 1、4 連線到 3、6 連線到5,連線起來,此時的連線情況如下所示:

此時 parent

中的情況如下所示:

再將 3-4 連線到 1-2,此時的連線情況如下所示:

此時的 parent 中各個元素的對應的元素值如下所示:

這樣就實現了剛開始舉出的情況,現在,通過 parent就可以檢測兩個元素是否是“連”通的了。

具體的實現:

public class UnionFind {
    private final int[] parent; // 記錄每個元素的連線資訊
    private int count; // 用於記錄當前的集合數量

    UnionFind(final int n) {
        parent = new int[n];
        count = n;
        
        // 初始化每個節點的父節點,使得每個節點連線到自身
        for (int i = 0; i < n; ++i) {
            parent[i] = i;
        }
    }

    /*
    	用於判斷兩個元素之間是否是連通的,
    	只要判斷兩個元素對應的根節點是否是一致的就可以判斷兩個節點是否是連通的
    */
    public boolean connect(int p, int q) {
        return find(p) == find(q);
    }

    public void union(int p, int q) {
        int rootP = find(p);
        int rootQ = find(q);
		
        // 如果兩個節點存在共同的根節點,那麼它們就是連通的,不需要進行進一步的操作
        if (rootP == rootQ) return;

        parent[rootP] = rootQ; // 將 p 的根節點連線到 q 的根節點,這樣 p 和 q 就是連通的
        count--;
    }

    public int count() {return count;}

    // 找到當前元素 p 的連線的根節點
    private int find(int p) {
        while (p != parent[p]) // 由於根節點不會存在連線到其它節點的情況,因此它連線的節點就是它本身
            p = parent[p];
        return p;
    }
}

實現優化

在上文提到的實現中,每次將兩個不相連的節點進行連線操作,在最壞的情況下會導致這個集合成為一條鏈,從而每次的查詢操作都需要在 \(O(n)\) 的時間複雜度內完成。以上文的初始集合為例,考慮以下的連線順序:1->2、1->3、1->4、1->5、1->6,最後得到的結果如下圖所示:

考慮這麼一種情況,對於兩個集合來講,是將大集合連線到小集合得到的集合高度會小一些,還是將小集合連線到大集合得到的集合高度會小一些?以上文的例子為例,將 1-4 的集合連線到 5-6 的集合,得到的連線情況如下所示:

而如果將 5-6 的集合連線到 1- 4 的集合中,得到的連線情況如下所示:

很明顯,將 5-6 的集合插入到 1-4 中的高度更小,這是因為較大的集合有更多的位置容納新連線的節點。因此,在連線時人為地將小集合連線到大集合可以有效地降低 UnionFind 每個操作的時間複雜度:

public class UnionFind {
    private final int[] parent;
    private final int[] sz; // 用於統計當前的元素包含的元素個數

    private int count;

    UnionFind(final int n) {
        parent = new int[n];
        sz = new int[n];
        count = n;

        for (int i = 0; i < n; ++i) {
            parent[i] = i;
            sz[i] = 1; // 每個節點在初始狀態的時候集合的元素個數都是 1
        }
    }

    public void union(int p, int q) {
        int rootP = find(p);
        int rootQ = find(q);

        if (rootP == rootQ) return;
        
        // 將小集合連線到大集合,同時更新對應的集合元素個數
        if (sz[rootP] > sz[rootQ]) {
            parent[rootQ] = rootP;
            sz[rootP] += sz[rootQ];
        } else {
            parent[rootP] = rootQ;
            sz[rootQ] += sz[rootP];
        }
        
        count--;
    }
    // 省略部分程式碼
}

經過這麼一頓操作之後,現在每個節點到根節點的高度為 \(logN\) ,相比較之前在最壞的情況下會生成一條鏈而形成 \(N\) 的高度,這種情況就要好很多了。

進一步的優化

實際上,上文給出的優化已經很好了,但是依舊存在可以優化的地方:將每個節點直接連線到根節點而不是父節點,可以進一步降低查詢操作的時間複雜度。這中優化的方式也被稱為 “路徑壓縮”,因為省去了向上查詢根節點的操作。

// 其餘程式碼同上

private int find(int p) {
    while (p != parent[p]) {
        // 更新當前的元素的父節點為父節點的父節點,因為根節點的父節點為它本身,因此最終會向上壓縮一節高度
        parent[p] = parent[parent[p]]; 
        p = parent[p];
    }
    return p;
}

經過進一步的優化之後,理論上 UnionFind 的每個操作都為 \(O(1)\) 的時間複雜度,但是實際上使用“路徑壓縮”的方式進行優化並不會帶來很大的效能提升。

實際使用

比較經典的一個實際使用是有關滲透閾值的估計,具體內容可以檢視 https://coursera.cs.princeton.edu/algs4/assignments/percolation/specification.php