1. 程式人生 > >並查集(Union Find)的基本實現

並查集(Union Find)的基本實現

概念

並查集是一種樹形的資料結構,用來處理一些不交集的合併及查詢問題。主要有兩個操作:

  • find:確定元素屬於哪一個子集。
  • union:將兩個子集合併成同一個集合。

所以並查集能夠解決網路中節點的連通性問題。

基本實現

package com.yunche.datastructure;

/**
 * @ClassName: UF
 * @Description: 並查集
 * @author: yunche
 * @date: 2018/12/30
 */
public class UF {

    /**
     * 此陣列的索引表示節點的編號,陣列儲存的是對應節點所在的集合編號
     */
    private int[] id;

    /**
     * 節點的個數
     */
    private int count;

    /**
     * 建構函式:構造一個指定大小的並查集
     *
     * @param n 並查集節點的個數
     */
    public UF(int n) {
        id = new int[n];
        count = n;
        for (int i = 0; i < n; i++) {
            id[i] = i;
        }
    }

    /**
     * 查詢指定節點的所屬的集合編號
     *
     * @param p 節點編號
     * @return 集合編號
     */
    public int find(int p) {
        if (p >= 0 && p < count) {
            return id[p];
        }
        return -1;
    }

    /**
     * 將兩個節點所屬的集合並在一起,即將兩個節點連通
     *
     * @param p 節點編號
     * @param q 節點編號
     */
    public void union(int p, int q) {
        int pId = find(p);
        int qId = find(q);
        if (pId < 0 || qId < 0) {
            return;
        }
        if (qId == pId) {
            return;
        }

        for (int i = 0; i < count; i++) {
            if (id[i] == pId) {
                id[i] = qId;
            }
        }
    }

    /**
     * 判斷兩個節點是否連通
     * @param p 節點編號
     * @param q 節點編號
     * @return
     */
    public boolean isConnected(int p, int q) {
        return find(p) != -1 && find(p) == find(q);
    }

    /**
     * 測試用例
     * @param args
     */
    public static void main(String[] args) {
        UF uf= new UF(5);
        uf.union(0, 1);
        uf.union(0, 2);
        uf.union(6, 2);
        System.out.println(uf.isConnected(1, 2));
        System.out.println(uf.isConnected(0, 3));
        System.out.println(uf.isConnected(6, 2));
    }
}

對 union 進行 rank 優化

package com.yunche.datastructure;

/**
 * @ClassName: UF2
 * @Description: 針對union進行rank優化
 * @author: yunche
 * @date: 2018/12/30
 */
public class UF2 {

    /**
     * 此陣列的索引為節點的編號,陣列儲存的為對應節點的父節點的編號
     */
    private int[] parent;

    /**
     * 節點的個數
     */
    private int count;

    /**
     * 進一步優化union
     * 陣列的索引代表集合的根節點
     * 陣列儲存的是每個根節點下的對應的樹層數
     */
    private int[] rank;


    /**
     * 建構函式:構造一個指定大小的並查集
     *
     * @param n 並查集節點的個數
     */
    public UF2(int n) {
        parent = new int[n];
        rank = new int[n];
        count = n;
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            rank[i] = 1;
        }
    }

    /**
     * 查詢指定節點的所屬的集合的根節點編號
     *
     * @param p 節點編號
     * @return 根節點編號
     */
    public int find(int p) {
        if (p >= 0 && p < count) {
            while (p != parent[p]) {
                 p = parent[p];
            }
            return p;
        }
        return -1;
    }

    /**
     * 將兩個節點所屬的集合並在一起,即將兩個節點連通
     * 時間複雜度O(n)
     * @param p 節點編號
     * @param q 節點編號
     */
    public void union(int p, int q) {
        int pRoot = find(p);
        int qRoot = find(q);
        if (pRoot < 0 || qRoot < 0) {
            return;
        }
        if (qRoot == pRoot) {
            return;
        }

        //合併兩個集合
        //重要優化避免構造樹的深度太深
        if (rank[pRoot] < rank[qRoot]) {
            parent[pRoot] = qRoot;
        } else if (rank[pRoot] > rank[qRoot]){
            parent[qRoot] =  pRoot;
        } else {
            parent[pRoot] = qRoot;
            rank[qRoot] += 1;
        }
    }

    /**
     * 判斷兩個節點是否連通
     * 時間複雜度O(1)
     * @param p 節點編號
     * @param q 節點編號
     * @return
     */
    public boolean isConnected(int p, int q) {
        return find(p) != -1 && find(p) == find(q);
    }

    /**
     * 測試用例
     * @param args
     */
    public static void main(String[] args) {
        testUF2(1000000);
    }

    /**
     * 測試方法
     * @param n 並差集的個數
     */
    public static void testUF2( int n ){

        UF2 uf = new UF2(n);

        long startTime = System.currentTimeMillis();

        // 進行n次操作, 每次隨機選擇兩個元素進行合併操作
        for( int i = 0 ; i < n ; i ++ ){
            int a = (int)(Math.random()*n);
            int b = (int)(Math.random()*n);
            uf.union(a,b);
        }
        // 再進行n次操作, 每次隨機選擇兩個元素, 查詢他們是否同屬一個集合
        for(int i = 0 ; i < n ; i ++ ){
            int a = (int)(Math.random()*n);
            int b = (int)(Math.random()*n);
            uf.isConnected(a,b);
        }
        long endTime = System.currentTimeMillis();

        // 列印輸出對這2n個操作的耗時
        System.out.println("UF2, " + 2*n + " ops, " + (endTime-startTime) + "ms");
    }
}

對 find 進行路徑壓縮

1、第一種方式

圖示

程式碼

package com.yunche.datastructure;

/**
 * @ClassName: UF3
 * @Description: 對find操作進行路徑壓縮,第一種路徑壓縮,過程如圖所示
 * @author: yunche
 * @date: 2018/12/30
 */
public class UF3 {

    /**
     * 此陣列的索引為節點的編號,陣列儲存的為對應節點的父節點編號
     */
    private int[] parent;

    /**
     * 節點的個數
     */
    private int count;

    /**
     * 進一步優化union
     * 陣列的索引代表集合的根節點
     * 陣列儲存的是每個根節點下的對應的樹深度
     * 在後續的程式碼中, 我們並不會維護rank的語意, 也就是rank的值在路徑壓縮的過程中, 有可能不再是樹的層數值
     * 這也是我們的rank不叫height或者depth的原因, 它只是作為比較的一個標準
     */
    private int[] rank;


    /**
     * 建構函式:構造一個指定大小的並查集
     *
     * @param n 並查集節點的個數
     */
    public UF3(int n) {
        parent = new int[n];
        rank = new int[n];
        count = n;
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            rank[i] = 1;
        }
    }

    /**
     * 查詢指定節點的所屬的集合的根節點編號
     * 路徑壓縮
     * @param p 節點編號
     * @return 根節點編號
     */
    public int find(int p) {
        if (p >= 0 && p < count) {
            while (p != parent[p]) {
                // 根據圖示,p的父節點應該指向p的父節點的父節點
                parent[p] = parent[parent[p]];
                //繼續從此時p的父節點開始迴圈
                p = parent[p];
            }
            return p;
        }
        return -1;
    }

    /**
     * 將兩個節點所屬的集合並在一起,即將兩個節點連通
     * 時間複雜度O(n)
     * @param p 節點編號
     * @param q 節點編號
     */
    public void union(int p, int q) {
        int pRoot = find(p);
        int qRoot = find(q);
        if (pRoot < 0 || qRoot < 0) {
            return;
        }
        if (qRoot == pRoot) {
            return;
        }

        //合併兩個集合
        //重要優化避免構造樹的深度太深
        if (rank[pRoot] < rank[qRoot]) {
            parent[pRoot] = qRoot;
        } else if (rank[pRoot] > rank[qRoot]){
            parent[qRoot] =  pRoot;
        } else {
            parent[pRoot] = qRoot;
            rank[qRoot] += 1;
        }
    }

    /**
     * 判斷兩個節點是否連通
     * 時間複雜度O(1)
     * @param p 節點編號
     * @param q 節點編號
     * @return
     */
    public boolean isConnected(int p, int q) {
        return find(p) != -1 && find(p) == find(q);
    }

    /**
     * 測試用例
     * @param args
     */
    public static void main(String[] args) {
        UF2.testUF2(1000000);
        testUF3(1000000);
    }/*Output:
    UF2, 2000000 ops, 856ms
    UF3, 2000000 ops, 579ms
     */

    /**
     * 測試方法
     * @param n 並查集的個數
     */
    public static void testUF3( int n ){

        UF3 uf = new UF3(n);

        long startTime = System.currentTimeMillis();

        // 進行n次操作, 每次隨機選擇兩個元素進行合併操作
        for( int i = 0 ; i < n ; i ++ ){
            int a = (int)(Math.random()*n);
            int b = (int)(Math.random()*n);
            uf.union(a,b);
        }
        // 再進行n次操作, 每次隨機選擇兩個元素, 查詢他們是否同屬一個集合
        for(int i = 0 ; i < n ; i ++ ){
            int a = (int)(Math.random()*n);
            int b = (int)(Math.random()*n);
            uf.isConnected(a,b);
        }
        long endTime = System.currentTimeMillis();

        // 列印輸出對這2n個操作的耗時
        System.out.println("UF3, " + 2*n + " ops, " + (endTime-startTime) + "ms");
    }
}

2、第二種方式

在明白了第一種壓縮路徑的方法後,我們可能會疑惑:是否這就是最短的路徑了?我們仔細思考後會發現,這並不是最短的路徑,最短的路徑形成的樹應該只有2層,第一層為根節點,其餘所有節點都在第二層指向根節點,如下圖所示。要實現這種壓縮路徑的方式也簡單,使用遞迴即可。

圖示

img

程式碼

package com.yunche.datastructure;

/**
 * @ClassName: UF4
 * @Description: 對find操作進行路徑壓縮,第二種路徑的遞迴壓縮
 * @author: yunche
 * @date: 2018/12/30
 */
public class UF4 {

    /**
     * 此陣列的索引為節點的編號,陣列儲存的為對應節點的父節點編號
     */
    private int[] parent;

    /**
     * 節點的個數
     */
    private int count;

    /**
     * 進一步優化union
     * 陣列的索引代表集合的根節點
     * 陣列儲存的是每個根節點下的對應的樹層數
     * 在後續的程式碼中, 我們並不會維護rank的語意, 也就是rank的值在路徑壓縮的過程中, 有可能不再是樹的層數值
     * 這也是我們的rank不叫height或者depth的原因, 它只是作為比較的一個標準
     */
    private int[] rank;


    /**
     * 建構函式:構造一個指定大小的並查集
     *
     * @param n 並查集節點的個數
     */
    public UF4(int n) {
        parent = new int[n];
        rank = new int[n];
        count = n;
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            rank[i] = 1;
        }
    }


    /**
     * 壓縮路徑遞迴實現 遞迴演算法
     * @param p 節點編號
     * @return 返回節點當前的根節點編號
     */
    public int find(int p) {
        if (p >= 0 && p < count) {
            // 遞迴邊界
            if (p == parent[p]) {
                return p;
            }
            //想象此時有 3 層:分別是 0, 1, 2,最開始p = 2
            // 那麼第一次執行到這個位置 parent[2] = findRecursive(1)
            //第二次執行到這個位置 parent[1] = findRecursive(0)
            //當再一次遞迴此時,到不到這一步,因為觸發遞迴邊界,返回0
            //那麼,向上 parent[1] = 0
            //再向上,parent[2] = parent[1] = 0
            parent[p] = find(parent[p]);
            return parent[p];
        }
        return -1;
    }

    /**
     * 將兩個節點所屬的集合並在一起,即將兩個節點連通
     * 時間複雜度O(n)
     * @param p 節點編號
     * @param q 節點編號
     */
    public void union(int p, int q) {
        int pRoot = find(p);
        int qRoot = find(q);
        if (pRoot < 0 || qRoot < 0) {
            return;
        }
        if (qRoot == pRoot) {
            return;
        }

        //合併兩個集合
        //重要優化避免構造樹的深度太深
        if (rank[pRoot] < rank[qRoot]) {
            parent[pRoot] = qRoot;
        } else if (rank[pRoot] > rank[qRoot]){
            parent[qRoot] =  pRoot;
        } else {
            parent[pRoot] = qRoot;
            rank[qRoot] += 1;
        }
    }

    /**
     * 判斷兩個節點是否連通
     * 時間複雜度O(1)
     * @param p 節點編號
     * @param q 節點編號
     * @return
     */
    public boolean isConnected(int p, int q) {
        return find(p) != -1 && find(p) == find(q);
    }

    /**
     * 測試用例
     * @param args
     */
    public static void main(String[] args) {
        UF2.testUF2(1000000);
        UF3.testUF3(1000000);
        UF4.testUF4(1000000);
    }/*Output:
   UF2, 2000000 ops, 918ms
   UF3, 2000000 ops, 586ms
   UF4, 2000000 ops, 631ms
     */

    /**
     * 測試方法 -
     * @param n 並查集的個數
     */
    public static void testUF4( int n ){

        UF4 uf = new UF4(n);

        long startTime = System.currentTimeMillis();

        // 進行n次操作, 每次隨機選擇兩個元素進行合併操作
        for( int i = 0 ; i < n ; i ++ ){
            int a = (int)(Math.random()*n);
            int b = (int)(Math.random()*n);
            uf.union(a,b);
        }
        // 再進行n次操作, 每次隨機選擇兩個元素, 查詢他們是否同屬一個集合
        for(int i = 0 ; i < n ; i ++ ){
            int a = (int)(Math.random()*n);
            int b = (int)(Math.random()*n);
            uf.isConnected(a,b);
        }
        long endTime = System.currentTimeMillis();

        // 列印輸出對這2n個操作的耗時
        System.out.println("UF4, " + 2*n + " ops, " + (endTime-startTime) + "ms");
    }


}

3、 兩種壓縮路徑的比較

從理論上說,第二種壓縮路徑演算法應該比第一種快,但上面的測試方法的結果卻是第一種壓縮路徑演算法更快,這是因為第二種壓縮演算法有遞迴上的開銷,結果上也表面這兩種演算法效率是差別不大的,所以採取哪種方法要實際測試下。