算法筆記:並查集(union-find算法)

問題建模

動態連通性問題

       對於一類問題,問題的輸入是一些對象兩兩之間的“相連關係”,並且這種“相連關係”是一種等價關係,即它具有以下性質:

  • 自反性:任何對象和其自身都是相連的;
  • 對稱性:如果p和q是相連的,那麼q和p也是相連的;
  • 傳遞性:如果p和q是相連的,q和r是相連的,那麼p和r也是相連的。

       那麼我們可以把相連的對象劃分爲一個等價類。問題的目標是根據輸入的所有相連關係,將對象空間劃分爲多個等價類,並能夠判斷任意兩個對象是否屬於同一個等價類。這個問題稱爲動態連通性問題,動態連通性問題有以下現實場景的應用:

  • 網絡:輸入的對象表示一個大型計算機網絡中的計算機,相連關係表示計算機之間的連接,那麼只有屬於同一個等價類的兩臺計算機才能相互通信;或者也可以是社交網絡,即對象是人,相連關係表示人之間的朋友關係,那麼可以將人劃分爲“朋友圈”(即等價類)。
  • 數學集合:抽象地說,可以將相連關係看作“屬於同一個集合”。

將問題抽象完成後,引入一些術語:將對象稱爲觸點,將相連關係稱爲連接,將等價類稱爲連通分量或者簡稱分量。爲了描述方便,直接00N1N-1的整數表示NN個觸點。
connectivity

算法API

       將動態連通性問題的解決抽象爲一份API:

public class UF {
    // 以整數標識(0到N-1)初始化N個觸點
    UF(int N);

    // 在p和q之間添加連接
    void union(int p, int q);

    // p所在的分量(分量也由整數表示),要保證當參數屬於同一個分量時,返回值應該相等。
    int find(int p);

    // 判斷p和q是否在同一個分量中
    boolean connected(int p, int q);

    // 連通分量的數量
    int count();

}

實現時要注意以下要點:

  • 所有的對象是用0到N-1表示的
  • 連通分量也用整數表示,一般來說可以用該分量中任意一個對象來表示。

爲了實現上述功能,我們用一個整數數組id[]來記錄所有的對象所屬的分量。即id[p]表示的是對象p所屬的連通分量。那麼有以下實現

public class UF {
    private int[] id;
    // 記錄分量的個數
    private count;

    public UF(int n) {
        // 初始化時,所有觸點都是一個獨立的分量
        count = n;
        id = new int[n];
        // 所有觸點都構成了一個只含有它自己的分量,因此將id[i]的值初始化爲i
        for(int i = 0; i < n; i++) {
            id[i] = i;
        }
    }

    public int count() { return count; }

    public boolean connected(int p, int q) {
        return find(p) == find(q);
    }
}

其中find()和union()兩個方法有不同的實現。

實現

quick-find算法

       這種方法是要保證當且僅當id[p]等於id[p]時,p和q是屬於同一個分量的。此時實現如下

public int find(int p) {
    return id[p];
}

public void union(int p, int q) {
    int pId = find(p);
    int qId = find(q);

    // 只有在p和q不屬於同一個分量時才需要執行
    if(pId != qId) {
        // 將所有與p同一分量的觸點所屬的分量修改爲q所屬的分量
        for(int i = 0; i < id.length; i++) {
            if(id[i] == pId) {
                id[i] = qId;
            }
        }
        count--;
    }
}

分析:

  • find()操作的時間複雜度爲O(1)O(1),因爲只需要訪問一次id數組
  • union()操作的時間複雜度爲O(N)O(N),因爲每一個合併都需要遍歷整個id數組
  • 如果有M對連接,那麼構建出所有的分量的時間複雜度爲O(MN)O(MN)

quick-union算法

       這種方法不需要保證同一個分量中的觸點的id值相同,id[]值只是同一個分量中另一個觸點(或者也可以是觸點自身),此時每一個分量中必定有一個觸點的id值是指向自身的,這個觸點就可以被認爲是該分量的“根觸點”,用以代表整個分量。

public int find(int p, int q) {
    while(p != id[p]) p = id[p];
    return p;
}

public void union(int p, int q) {
    // 將p和q的根觸點統一
    int pRoot = find(p);
    int qRoot = find(q);

    if(pRoot != qRoot) {
        id[pRoot] = qRoot;
        count --;
    }
}

這種方法其實是用一棵樹來表示一個分量,整個id數組用父連接的形式表現了一片森林。

quick-union
分析:

  • 最好情況下,find()操作的時間複雜度是O(1)O(1),最壞情況下,find()操作的時間複雜度是O(N)O(N)
  • union()操作的時間複雜度應該與find()操作是一致的。

加權quick-union算法

       爲了防止最壞情況的出現,需要對quick-union算法進行優化,在quick-union算法中,union()操作是隨意的將一棵樹連接到另一棵樹,改進的方法就是記錄每一棵樹的大小並總是將較小的樹連接到較大的樹上。這種方法稱爲加權quick-union算法。這項改動需要添加一個數組和一些代碼來記錄樹中的節點樹。實現如下:

public class WeightedQuickUnionUF {
    private int[] id;
    private int[] size;
    private int count;

    public WeightedQuickUnionUF(int n) {
        count = n;
        id = new int[n];
        for(int i = 0; i < n; i++) id[i] = i;
        size = new int[n];
        for(int i = 0; i < n; i++) size[i] = 1;
    }

    public int count() { return count; }

    public boolean connected(int p, int q) {
        return find(p) == find(q);
    }

    public int find(int p) {
        while(p != id[p]) p = id[p];
        return p;
    }

    public void union(int p, int q) {
        int i = find(p);
        int j = find(q);
        if(i == j) return;
        if(size[i] < size[j]) {
            id[i] = j;
            size[j] += size[i];
        } else {
            id[j] = i;
            size[i] += size[j];
        }
        count--;
    }
 }

分析:

  • 對於NN個觸點,該算法構造的森林中任意的節點的深度最多爲logN\log{N}
  • find()操作的時間複雜度爲O(logN)O(\log{N}),union()操作的時間複雜度爲O(logN)O(\log{N})
  • 對於NN個觸點,MM對連接,那麼構建出所有的分量的時間複雜度爲O(MlogN)O(M\log{N})
  • 路徑壓縮:如果需要進一步優化,可以在檢查節點的同時將它直接連接到根節點。該過程稱爲路徑壓縮

例題

       (LeetCode. 547)班上有 N 名學生。其中有些人是朋友,有些則不是。他們的友誼具有是傳遞性。如果已知 A 是 B 的朋友,B 是 C 的朋友,那麼我們可以認爲 A 也是 C 的朋友。所謂的朋友圈,是指所有朋友的集合。
       給定一個 N * N 的矩陣 M,表示班級中學生之間的朋友關係。如果M[i][j] = 1,表示已知第 i 個和 j 個學生互爲朋友關係,否則爲不知道。你必須輸出所有學生中的已知的朋友圈總數。

示例1:

輸入:
[[1,1,0],
[1,1,0],
[0,0,1]]
輸出: 2
說明:已知學生0和學生1互爲朋友,他們在一個朋友圈。
第2個學生自己在一個朋友圈。所以返回2。

示例2:

輸入:
[[1,1,0],
[1,1,1],
[0,1,1]]
輸出: 1
說明:已知學生0和學生1互爲朋友,學生1和學生2互爲朋友,所以學生0和學生2也是朋友,所以他們三個在一個朋友圈,返回1。

注意:

  1. N 在[1,200]的範圍內。
  2. 對於所有學生,有M[i][i] = 1。
  3. 如果有M[i][j] = 1,則有M[j][i] = 1。

題解:

class Solution {
    public int findCircleNum(int[][] M) {
        int n = M.length;
        UF uf = new UF(n);
        for (int i = 0; i < n; i++) {
            for (int j = i+1; j < n; j++) {
                if(M[i][j] == 1) {
                    uf.union(i, j);
                }
            }
        }
        return uf.count();
    }
    class UF {
        int[] id;
        int[] size;
        int count;
        public UF(int n) {
            id = new int[n];
            for (int i = 0; i < n; i++) id[i] = i;
            size = new int[n];
            for (int i = 0; i < n; i++) size[i] = 1;
            count = n;
        }
        public int count() { return count;}

        public int find(int p) {
            while (p != id[p]) p = id[p];
            return p;
        }

        public void union(int p, int q) {
            int pRoot = find(p);
            int qRoot = find(q);
            if(pRoot == qRoot) return;
            if(size[pRoot] < size[qRoot]) {
                id[pRoot] = qRoot;
                size[qRoot] += size[pRoot];
            } else {
                id[qRoot] = pRoot;
                size[pRoot] += size[qRoot];
            }
            count--;
        }
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章