並查集-代碼實現

目錄

一、並查集-數組模擬Quick Find-1

1、數組模擬Quick Find

二、改進:Quick Union-2

三、基於size的優化-Quick Union-3

四、基於Rank的優化-Quick Union-4

五、路徑壓縮-Quick Union-5

六、更理想的路徑壓縮-Quick Union-6

七、測試


一、並查集-數組模擬Quick Find-1

並查集解決的的問題——網絡間節點的連接狀態。

使用並查集可以快速的判斷兩個節點之間是否是相連接的。不同於求解兩點之間的路徑,並查集不關心兩點之間是通過怎樣的路徑進行連接,正因爲它沒有求解其他不必要的結果,因此它的效率快。

首先我們定義一個並查集的接口:

public interface UF {
    int getSize();
    // 判斷兩個元素是否是相連的
    boolean isConnected(int p, int q);
    // 將兩個元素合併在一起
    void unionElements(int p, int q);
}

1、數組模擬Quick Find

quick find 的實現邏輯,存儲值爲1-9的數據,對於數據1-9,他們所屬的集合分成0和1;當需要判斷兩個數據是否是連接狀態時,只需要判斷這兩個數據對應的集合是不是一樣的。

比如0和2是相連接的,因爲他們同屬於0這個集合,而1和2是不相連接的,因爲他們對應的集合分別是1和0;按照這種邏輯,當我們需要判斷兩個節點是否是相連接的時候,只需要比較他們所屬的集合是否相同就可以了,這種操作的時間複雜度爲O(1)。

但是,當isConnected操作爲O(1)複雜度的時候,對應的unionElements操作就爲O(n)的複雜度。因爲,當執行unionElements(0,1)的時候,我們需要把所有對應的元素都遍歷一遍,查找到屬於0集合的元素,並全部改寫成1集合,以實現合併操作。

簡單的代碼實現:

public class UnionFind1 implements UF{
    private int[] id;
    public UnionFind1(int size){
        id = new int[size];
        // 初始化的時候,每個元素都屬於不同的集合
        for (int i = 0; i < id.length; i++) {
            id[i] = i;
        }
    }

    @Override
    public int getSize() {
        return id.length;
    }

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

    // 合併元素p和元素q所屬的集合
    @Override
    public void unionElements(int p, int q) {
        int pID = find(p);
        int qID = find(q);
        // 兩個元素如果本來就是屬於相同的集合,就沒有合併的並要
        if (pID == qID) {
            return;
        }
        for (int i = 0; i < id.length; i++) {
            if (id[i] == pID) {
                id[i] = qID;
            }
        }
    }

    // 查找元素p所對應的集合編號
    private int find(int p){
        if(p < 0 || p >= id.length){
            throw new IllegalArgumentException("p is out of bound.");
        }
        return id[p];
    }
}

二、改進:Quick Union-2

使用樹的結構來實現並查集,此處並查集的樹結構是倒過來的,由子節點指向父節點,它的是實現邏輯如下:

我們將每個元素都看成是一個節點,如圖2是3的根節點(根節點2自己指向了自己),他們屬於同一個集合;當節點1和節點3需要合併時,我們只要把節點的1的指針指向節點3的根節點2就可以了。同樣的,節點7和3需要合併,此時把7所在樹的根節點5的指針指向節點3的根節點2就可以了,此時,完成了7所在樹的所有節點的合併操作。

對於數據的初始化操作,一開始,所有元素都是一顆獨立的樹

此處,我們仍然使用數組的形式來進行索引存儲

實現相關並操作後的結構圖示示例:

根據樹結構實現的並查集:

public class UnionFind2 implements UF {
    private int[] parent;
    public UnionFind2(int size){
        parent = new int[size];
        // 初始化的時候,所有的元素都是一個獨立的樹
        for (int i = 0; i < parent.length; i++) {
            parent[i] = i;
        }
    }

    @Override
    public int getSize() {
        return parent.length;
    }

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

    @Override
    public void unionElements(int p, int q) {
        // 查找根節點所屬集合
        int pRoot = find(p);
        int qRoot = find(q);
        if(pRoot == qRoot){
            return;
        }
        // 把根節點的指針指向q進行合併
        parent[pRoot] = qRoot;
    }
    // 查找元素p所對應的集合編號,時間複雜度爲O(h)
    private int find(int p){
        if (p < 0 || p >= parent.length) {
            throw new IllegalArgumentException("p is out of bound.");
        }
        // 判斷是不是根節點(根節點自己等於自己)
        while(p != parent[p]){
            p = parent[p];
        }
        return p;
    }
}

使用樹狀結構的好處是縮短了查詢的時間複雜度,相比使用數組進行實現的時間複雜度O(n),明顯O(log(n))的效率要高很多。對於使用樹來說,算法的遍歷深度只跟樹的層級有關。

三、基於size的優化-Quick Union-3

在我們使用特殊的樹狀結構來實現並查集的時候,我們只是把樹進行了簡單的合併,並沒有考慮到樹的形狀

// 把根節點的指針指向q進行合併
parent[pRoot] = qRoot;

那麼,當有如下並查集數據,要實現Union(4,9),按照我們之前的邏輯是把8指向9(8—>9)這樣來實現,此時8爲根節點的樹的層級爲3,執向9以後,9爲根節點的樹的層級結構爲4,我們發現並查集的層級增加了(意味着遍歷深度又增加了)。更有極端的情況是整棵樹退化成一個鏈表的結構,使得樹的層級就等於節點的個數,算法的複雜度退化成了O(n)級別。

那麼,有沒有辦法減少樹的層級呢?

我們可以嘗試使用size,對比兩個將要合併的節點,比較他們的所在樹的節點個數,我們讓節點個數少的節點合併到節點個數多的那個節點上,如圖,我們不讓8—>9,而是讓9—>8;這樣操作的結果,就是樹的層級沒有改變,仍然是3層。

接下來,我們修改一下代碼,只要在Quick Union-2的基礎上,新增一個維護變量sz,sz[i]表示以i爲根的集合元素個數。

private int[] sz; // sz[i]表示以i爲根的集合元素個數
    public UnionFind3(int size){
        parent = new int[size];
        sz = new int[size];
        // 初始化的時候,所有的元素都是一個獨立的樹
        for (int i = 0; i < size; i++) {
            parent[i] = i;
            // 每一集合一開始都只有一個元素
            sz[i] = 1;
        }
    }

然後更改索引的指向邏輯,把要實現合併的節點少的樹的根節點指向節點多的樹的根節點,其他邏輯不變

// 如果pRoot節點的數量小於qRoot節點的數量,pRoot->qRoot
        if(sz[pRoot] < sz[qRoot]){
            parent[pRoot] = qRoot;
            // 樹的節點數量維護
            sz[qRoot] += sz[pRoot];
        }else{// qRoot->pRoot
            parent[qRoot] = pRoot;
            // 樹的節點數量維護
            sz[pRoot] += sz[qRoot];
        }

整體代碼如下:

public class UnionFind3 implements UF {
    private int[] parent;
    private int[] sz; // sz[i]表示以i爲根的集合元素個數
    public UnionFind3(int size){
        parent = new int[size];
        sz = new int[size];
        // 初始化的時候,所有的元素都是一個獨立的樹
        for (int i = 0; i < size; i++) {
            parent[i] = i;
            // 每一集合一開始都只有一個元素
            sz[i] = 1;
        }
    }

    @Override
    public int getSize() {
        return parent.length;
    }

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

    @Override
    public void unionElements(int p, int q) {
        // 查找根節點所屬集合
        int pRoot = find(p);
        int qRoot = find(q);
        if(pRoot == qRoot){
            return;
        }
        // 如果pRoot節點的數量小於qRoot節點的數量,pRoot->qRoot
        if(sz[pRoot] < sz[qRoot]){
            parent[pRoot] = qRoot;
            // 樹的節點數量維護
            sz[qRoot] += sz[pRoot];
        }else{// qRoot->pRoot
            parent[qRoot] = pRoot;
            // 樹的節點數量維護
            sz[pRoot] += sz[qRoot];
        }
    }
    // 查找元素p所對應的集合編號,時間複雜度爲O(h)
    private int find(int p){
        if (p < 0 || p >= parent.length) {
            throw new IllegalArgumentException("p is out of bound.");
        }
        // 判斷是不是根節點(根節點自己等於自己)
        while(p != parent[p]){
            p = parent[p];
        }
        return p;
    }
}

四、基於Rank的優化-Quick Union-4

基於size的維護其實還存在一個問題,那就是當合並節點“7”所在樹的size > 另一個合併節點“8”所在樹的size;但是節點“8”所在樹的深度卻要比節點“7”所在樹的深度要高,如圖。

按照我們原有的邏輯,實現的結果是這樣的,樹的層級由最高爲3,變成了最高爲4,結合的深度又增加了

按照之前我們對算法的分析,應該把深度低的樹指向深度高的樹,這樣的操作結果纔是最合理的,因爲不會增加樹的層級,如下效果:

接下來,我們在之前的代碼上繼續進行優化,在Quick Union-3的基礎上我們不再維護size了,轉而維護樹的深度rank;

private int[] parent;
    private int[] rank; // rank[i]表示以i爲根的集合所表示的樹的層數
    public UnionFind4(int size){
        parent = new int[size];
        rank = new int[size];
        // 初始化的時候,所有的元素都是一個獨立的樹
        for (int i = 0; i < size; i++) {
            parent[i] = i;
            // 每個集合一開始的層級都是1
            rank[i] = 1;
        }
    }

我們根據深度來實現合併的邏輯

// 將rank低的集合合併到rank高的集合上
        if(rank[pRoot] < rank[qRoot]){
            parent[pRoot] = qRoot;
        }else if(rank[pRoot] > rank[qRoot]){// qRoot->pRoot
            parent[qRoot] = pRoot;
        }else{// parent[pRoot] == parent[qRoot]
            parent[qRoot] = pRoot;
            rank[pRoot] += 1;
        }

整體的代碼實現如下:

public class UnionFind4 implements UF {
    private int[] parent;
    private int[] rank; // rank[i]表示以i爲根的集合所表示的樹的層數
    public UnionFind4(int size){
        parent = new int[size];
        rank = new int[size];
        // 初始化的時候,所有的元素都是一個獨立的樹
        for (int i = 0; i < size; i++) {
            parent[i] = i;
            // 每個集合一開始的層級都是1
            rank[i] = 1;
        }
    }

    @Override
    public int getSize() {
        return parent.length;
    }

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

    @Override
    public void unionElements(int p, int q) {
        // 查找根節點所屬集合
        int pRoot = find(p);
        int qRoot = find(q);
        if(pRoot == qRoot){
            return;
        }
        // 將rank低的集合合併到rank高的集合上
        if(rank[pRoot] < rank[qRoot]){
            parent[pRoot] = qRoot;
        }else if(rank[pRoot] > rank[qRoot]){// qRoot->pRoot
            parent[qRoot] = pRoot;
        }else{// parent[pRoot] == parent[qRoot]
            parent[qRoot] = pRoot;
            rank[pRoot] += 1;
        }
    }
    // 查找元素p所對應的集合編號,時間複雜度爲O(h)
    private int find(int p){
        if (p < 0 || p >= parent.length) {
            throw new IllegalArgumentException("p is out of bound.");
        }
        // 判斷是不是根節點(根節點自己等於自己)
        while(p != parent[p]){
            p = parent[p];
        }
        return p;
    }
}

五、路徑壓縮-Quick Union-5

所謂的路徑壓縮,是針對樹的深度來說的,一般情況下,一棵樹它的層級越低,它的查詢效率越快;如果是退化成鏈表的情況,查詢元素,需要從頭到尾遍歷整個集合,所以它的效率是最差的。因此,我們需要想辦法壓縮整棵樹的高度,來提高數據結構的運算效率。

如果,我們在每一次查詢的時候,發現節點的父親節點還有一個有父親節點,那麼就讓這個節點指向父親節點的父親節點,即執行一次 parent[p] = parent[parent[p]];便可以對樹結構進行有效的路勁壓縮。

在代碼中的實現也比較簡單,我們只要在Quick Union-4的基礎上,改變一下查詢的方法就可以了,如下,在查詢方法中添加一行代碼: parent[p] = parent[parent[p]];

// 查找元素p所對應的集合編號,時間複雜度爲O(h)
    private int find(int p){
        if (p < 0 || p >= parent.length) {
            throw new IllegalArgumentException("p is out of bound.");
        }
        // 判斷是不是根節點(根節點自己等於自己)
        while(p != parent[p]){
            parent[p] = parent[parent[p]];
            p = parent[p];
        }
        return p;
    }

整體的實現代碼如下:

public class UnionFind5 implements UF {
    private int[] parent;
    private int[] rank; // rank[i]表示以i爲根的集合所表示的樹的層數
    public UnionFind5(int size){
        parent = new int[size];
        rank = new int[size];
        // 初始化的時候,所有的元素都是一個獨立的樹
        for (int i = 0; i < size; i++) {
            parent[i] = i;
            // 每個集合一開始的層級都是1
            rank[i] = 1;
        }
    }

    @Override
    public int getSize() {
        return parent.length;
    }

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

    @Override
    public void unionElements(int p, int q) {
        // 查找根節點所屬集合
        int pRoot = find(p);
        int qRoot = find(q);
        if(pRoot == qRoot){
            return;
        }
        // 將rank低的集合合併到rank高的集合上
        if(rank[pRoot] < rank[qRoot]){
            parent[pRoot] = qRoot;
        }else if(rank[pRoot] > rank[qRoot]){// qRoot->pRoot
            parent[qRoot] = pRoot;
        }else{// parent[pRoot] == parent[qRoot]
            parent[qRoot] = pRoot;
            rank[pRoot] += 1;
        }
    }
    // 查找元素p所對應的集合編號,時間複雜度爲O(h)
    private int find(int p){
        if (p < 0 || p >= parent.length) {
            throw new IllegalArgumentException("p is out of bound.");
        }
        // 判斷是不是根節點(根節點自己等於自己)
        while(p != parent[p]){
            parent[p] = parent[parent[p]];
            p = parent[p];
        }
        return p;
    }
}

注意:rank在進行路徑壓縮後並不是真正代表的樹的深度,有時候,一個層級的節點,它們的rank值並不相同,所以路徑壓縮後的rank更相當於表示一個排序。同時,我們也不一定要刻意去維護一棵樹的精確深度,在並查集中,這樣是沒有意義的,反而會增加數據結構的性能消耗。

六、更理想的路徑壓縮-Quick Union-6

前邊我們也提到了,並查集樹的深度越低,它的效率越快,那麼我們可不可以查找一個節點時,直接把它的索引指向根節點,進行如下圖這種扁平式的優化呢?

答案是可以的,我們需要藉助遞歸的實現,在查詢的邏輯中,使用遞歸實現的代碼如下

// 查找元素p所對應的集合編號,時間複雜度爲O(h)
    private int find(int p){
        if (p < 0 || p >= parent.length) {
            throw new IllegalArgumentException("p is out of bound.");
        }
        // 判斷是不是根節點
        if(p != parent[p]){
            parent[p] = find(parent[p]);
        }// 返回根節點
        return parent[p];
    }

這段代碼的邏輯是,我們不停的去查找根節點的索引,直到查找到爲止。通過這段代碼邏輯,我們可以直接把當前查找節點的索引指向到樹的根節點上。

注:因爲遞歸是需要消耗性能的,Quick Union-6 的效率要比Quick Union-5(非遞歸實現)要差一點,在此這種實現僅作參考。

七、測試

對於上述實現的並查集,可以通過以下簡單的測試程序進行測試

public class Main {

    private static double testUF(UF uf, int m) {
        // 一共維護了多少數據
        int size = uf.getSize();
        Random random = new Random();
        long startTime = System.nanoTime();
        // 首先進行m次的合併操作
        for (int i = 0; i < m; i++) {
            int a = random.nextInt(size);
            int b = random.nextInt(size);
            uf.unionElements(a, b);
        }
        // 然後再進行m次的查詢操作
        for (int i = 0; i < m; i++) {
            int a = random.nextInt(size);
            int b = random.nextInt(size);
            uf.isConnected(a, b);
        }
        long endTime = System.nanoTime();
        return (endTime - startTime) / 1000000000.0;
    }

    public static void main(String[] args) {
        int size = 10000000;
        int m = 10000000;
//        UnionFind1 uf1 = new UnionFind1(size);
//        System.out.println("UnionFind1:" + testUF(uf1, m) + "s");
//
//        UnionFind2 uf2 = new UnionFind2(size);
//        System.out.println("UnionFind2:" + testUF(uf2, m) + "s");

        UnionFind3 uf3 = new UnionFind3(size);
        System.out.println("UnionFind3:" + testUF(uf3, m) + "s");

        UnionFind4 uf4 = new UnionFind4(size);
        System.out.println("UnionFind4:" + testUF(uf4, m) + "s");

        UnionFind5 uf5 = new UnionFind5(size);
        System.out.println("UnionFind5:" + testUF(uf5, m) + "s");

        UnionFind6 uf6 = new UnionFind6(size);
        System.out.println("UnionFind6:" + testUF(uf6, m) + "s");
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章