1. 程式人生 > >並查集(union-find)演算法詳解

並查集(union-find)演算法詳解

之前很多連通性問題,其實都是可以通過並查集演算法去實現的,比如城鎮的修路問題:
首先在地圖上給你若干個城鎮,這些城鎮都可以看作點,然後告訴你哪些對城鎮之間是有道路直接相連的。最後要解決的是整幅圖的連通性問題。比如隨意給你兩個點,讓你判斷它們是否連通,或者問你整幅圖一共有幾個連通分支,也就是被分成了幾個互相獨立的塊。像暢通工程這題,問還需要修幾條路,實質就是求有幾個連通分支。如果是1個連通分支,說明整幅圖上的點都連起來了,不用再修路了;如果是2個連通分支,則只要再修1條路,從兩個分支中各選一個點,把它們連起來,那麼所有的點都是連起來的了;如果是3個連通分支,則只要再修兩條路…
讀完《演算法》第4版關於並查集的介紹後,覺得對這個演算法有了比較完整的瞭解,這裡簡單記錄一下。

動態連通性


這幅圖是比較經典的動態連通性圖,這個圖會不斷的接受整數對的輸入,比如(p,q),它代表p和q是相連的,這種相連具有自反性,對稱性和傳遞性。他的動態體現在這個圖隨著輸入的不斷增加,連通性會發生變化。演算法的目標就是,當輸入(p,q)時,就使p和q連線起來。這裡分為兩種情況:
1.p和q本來就是連通的,那麼放棄這個整數對
2.p和q不連通,則在p和q之間加一條路徑
要實現上述目的,我們可以定義一個API,其中,連通分量表示該圖的連通子圖

UnionFind(int n) 建構函式,初始化N個觸點
void union(int p, int q)  在p和q之間增加一條連線,若已連通,則不做更改
int find(int p) 返回p觸點所在的連通分量的標識
boolean connected(int p, int q) 判斷p和q是否存在於同一個連通分量中
int count() 連通分量的數量

程式碼的基本結構如下:

public class UnionFind {
    private int[] id;    //連通分量,用分量中的某個觸點作為索引
    private int count;    //分量的數量
    private int[] size;     //每個分量的節點數量

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

    public int
count(){ return count; } public boolean connected(int p, int q){ return quFind(p) == quFind(q); } public int find(int p);//下面實現 public void union(int p, int q);//下面實現 public static void main(String[] args){ int[] left = {9, 3, 5, 7, 2, 5, 0, 4, 3}; int[] right = {0, 4, 8, 2, 1, 7, 3, 2, 5}; UnionFind unionFind = new UnionFind(100, true); for(int i = 0; i < left.length; i++){ if(unionFind.connected(left[i], right[i])) continue; unionFind.jqUnion(left[i], right[i]); System.out.println("ID:"); for(int j = 0; j < 10; j++) { System.out.print(unionFind.id[j] + " "); } System.out.println("size:"); for(int j = 0; j < 10; j++) { System.out.print(unionFind.size[j] + " "); } System.out.println(); } }

其中,find方法和union方法是這個方法的核心,所以並查集又叫做union-find演算法。這兩個方法的實現有三種方法,其中各有優劣。

quick-find 演算法

顧名思義,這個方法是以最快find為目的的,這個演算法可以在o(1)的複雜度判斷兩個觸點是否相連,但是卻只能以o(n)複雜度進行新增。這個演算法的思想是保證在一個分量中所有的觸點都有同樣的標識,為了實現這一點,剛開始的時候,各個觸點各自形成自己的分量,當有輸入的時候,就將觸點合併,union方法通過將p的識別符號變為q(或者將q的識別符號變成p)來實現合併,但是光改p一個點是不夠的,還得把p所在分量的所有點都改成q的識別符號,這就意味著需要遍歷整個陣列,來找到p所在分量的所有點,因此複雜度為o(n),合併過程如下圖,5和9合併,將5所在的分量1全部改為了9所在的分量8。

這裡寫圖片描述

但是union的高昂代價給find方法帶來了便利,因為find只需要訪問陣列p索引下的值,就可以知道它屬於哪個分量了,判斷兩個點是否在同一個分量將會十分容易,複雜度僅o(1)。假設有m個點,他的union複雜度就為o(mn),平方級的複雜度讓他很難勝任大規模資料的union。程式碼如下:

    /*
    quick-find
     */
    public int qfFind(int p){
        return id[p];
    }

    public void qfUnion(int p, int q){
        int pId = qfFind(p);
        int qId = qfFind(q);
        if(qId == pId) return;
        for(int i = 0; i < id.length; i++){
            if(id[i] == pId)
                id[i] = qId;
        }
        count--;
    }

quick-union

quick-union採用了一種完全不同的思路,就是樹。樹結構有一個特點,就是移動一棵樹不需要把整棵樹的結點都移過去,只需要移動根節點就行了。在上面的分析中我們發現,quick-find演算法的union複雜度之所以高,就是因為每次移動一個分量,需要把所有該分量的識別符號都改變。改成樹結構之後,我把一個分量的所有點用一顆樹串聯起來,當兩個分量合併的時候,只需要把一棵樹的根節點嫁接到另一顆樹的根節點上即可。此時的id陣列結構如下圖

這裡寫圖片描述

每個陣列元素不再記錄自己分量的標識,而是記錄自己的父結點,根節點的父結點就是自己。所以,find函式的實現就是向上不斷尋找找到任意結點的根節點,也就是id[index]=index的那個點,若兩個點是連通的,他們必定有同一個根節點。union函式就是把p所在樹的根節點連到q所在樹的根節點上(反之也可以),實現樹的合併。程式碼如下

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

    public void quUnion(int p, int q){
        int pId = quFind(p);
        int qId = quFind(q);
        if(qId == pId) return;
        id[pId] = qId;
        count--;
    }

quick-union方法看似讓合併變的更加快速,其實不然,因為在合併之前,有兩次find操作,而find操作的複雜度,是樹的高度h,極端情況下,h=n,如下圖。意味著union操作也會達到o(n)複雜度,隨著樹高度的增加,這個演算法的複雜度會越來越高,面對大規模資料的時候仍顯得吃力。
這裡寫圖片描述

加權quick-union

這是幾乎最優的解法了,前面分析了,quick-union演算法不適合大規模資料的主要原因是因為樹高的限制,那麼只需要控制好樹的高度,quick-union演算法的複雜度就有很好的表現。控制的方法也很簡單,就是總是讓較小的樹嫁接在較大樹的根節點下。實現的方式也很簡單,額外設一個size陣列,記錄每個分量的大小,也就是結點數。合併的時候,總是將size較小的樹合併到size較大的樹上去。可以證明,樹的高度不會超過logN(2為底,高度從0開始),最壞的複雜度也不過是o(mlogn),應付大規模的資料,已經足夠了。

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

    public void jqUnion(int p, int q){
        int pId = jqFind(p);
        int qId = jqFind(q);
        if(connected(p, q)) return;
        if(size[pId] < size[qId]){
            id[pId] = qId;
            size[qId] += size[pId];
        }else {
            id[qId] = pId;
            size[pId] += size[qId];
        }
        count--;
    }

和普通的quick-union比起來,樹的高度是明顯的
這裡寫圖片描述

但是這個演算法其實還有優化空間,理想情況下,樹是可以做到高度為1的,也就是隻有兩層,這個時候,find和union的複雜度都將是o(1),但這是不可能的。採用壓縮路徑的加權quick-union可以逼近這個複雜度,他的思想就是在find的時候,把經過的結點直接接到根節點上,這隻需要增加一個while迴圈即可,就不再列舉程式碼了。幾種演算法的對比如下
這裡寫圖片描述
對大規模資料進行處理,使用平方階的演算法是不合適的,比如簡單直觀的Quick-Find演算法,通過發現問題的更多特點,找到合適的資料結構,然後有針對性的進行改進,得到了Quick-Union演算法及其多種改進演算法,最終使得演算法的複雜度降低到了近乎線性複雜度。