1. 程式人生 > 程式設計 >31-並查集(Union Find)

31-並查集(Union Find)

並查集(Union Find)

需求分析

假設現在有這樣一個需求,如下圖的每一個點代表一個村莊,每一條線就代表一條路,所以有些村莊之間有連線的路,有些村莊沒有連線的路,但是有間接連線的路

根據上面的條件,能設計出一個資料結構,能快速執行下面2個操作

  1. 查詢兩個村莊之間是否有連線的路
  2. 連線兩個村莊

為了完成上面的需求,能不能使用前面介紹的資料結構呢,例如:陣列,連結串列,平衡二叉樹,集合?其實是可以的,只是效率上高與低的問題。

例如使用動態陣列完成上面這種操作,可以通過下面的方式完成。

  1. 將每個圖用一個陣列來對應,所以在這裡,需要三個陣列
    • 判斷兩個村莊之間是否有連線的路 判斷兩個元素是否在同一個陣列中即可,如果兩個元素在同一個元素中,就代表它們之間,有連線的路,否則就沒有
    • 連線兩個村莊 將兩個村莊的元素,整合到一個陣列即可

其他幾種資料結構操作也類似。但是使用這些資料結構存在一個問題,它們的查詢,連線時間複雜度都是O(n),所以用這些資料結構來完成上面的需求,不合適。但是在本節內容中介紹的並查集能夠辦到查詢,連線的均攤時間複雜度都是O(α(n)),α(n) < 5,所以並查集非常適合解決這類“連線”相關的問題

並查集

並查集和叫做不相交集合(Disjoint Set)

並查集有2個核心操作

  1. 查詢(Find):查詢元素所在的集合(這裡的集合並不是特指Set這種資料結構,是指廣義上的資料集合)
  2. 合併(Union):將兩個元素所在的集合合併為一個集合

並查集有2種常見的實現思路

  1. Quick Find
    • 查詢(Find)的時間複雜度為:O(1)
    • 合併(Union)的時間複雜度為:O(n)
  2. Quick Union
    • 查詢(Find)的時間複雜度為:O(logn),可以優化至O(α(n)),α(n) < 5
    • 合併(Union)的時間複雜度為:O(logn),可以優化至O(α(n)),α(n) < 5

所以,通過Quick Find來實現並查集的話,查詢的效率會比Quick Union高,但是合併效率會比Quick Union低,那在開發中用哪一個呢?在開發中,一般使用Quick Union這種思路

並查集如何儲存資料

現假設並查集處理的資料都是整型,那麼可以用整型陣列來儲存資料,其中陣列的索引代表對應的元素編號,然後陣列中儲存的資料為該元素對應所在的集合,所以如果用下圖來表示對應村莊對應的集合,其中索引代表村莊的標號,編號對應的值代表村莊所在的集合

可以知道,村莊0,1,3是相互有路相連,村莊2/5分別獨立,沒有路與其他相連,村莊5,6,7是相互有路相連的。那麼如果使用形象的圖來表示的話,村莊0,3,可以用下圖來進行表示

解釋:

  1. 村莊索引0的父節點為村莊索引1
  2. 村莊索引3的父節點為村莊索引1
  3. 村莊索引1·的父節點為村莊索引1

也可以認為,索引對應的元素,代表父節點的索引,所以每一個節點,都有一根線,指向其父節點,所以從這個圖,可以看出,0,3的父節點都1,所以屬於同一個集合。以此類推2表示單獨的一個集合

根據上面的定義,索引4的村莊的父節點索引為5,所以其實索引4與5之間是有聯絡的,並且5,7,之間本來也存在聯絡,所以最終可以用下圖來進行表示,所以可以認為4,5,7也是屬於同一個集合的。

所以,如果並查集是整數的話,就可以通過陣列來表示每個元素之間的關係。所以並查集是可以用陣列來實現的樹形結構(二叉堆,優先順序佇列也是可以用陣列實現的樹形結構)

介面定義

所以,根據前面的介紹,並查集需要定義以下介面

/**
 * 查詢v所屬的集合(根節點)
 */
int find(int v);
/**
 * 合併v1,v2所屬的集合
 */
void union(int v1,int v2);
/**
 * 檢查v1,v2是否屬於同一個集合
 */
boolean isSame(int v1,int v2);
複製程式碼
初始化

前面介紹了並查集的兩種實現方式,不過,不管是使用哪種方式實現,都需要對資料進行初始化,初始化時,每個元素各自屬於一個單元素集合。例如開始的時候,有如下的5個節點,其中索引0的元素儲存元素0,索引1的元素儲存元素1,索引2的元素儲存元素2,索引3的元素儲存元素3,索引4的元素儲存元素4

然後用圖來表示如下,其中,每一個元素屬於一個單元素集合,即自己成為一個集合,這樣代表所有的元素都不在同一個集合內。

所以初始化的實現程式碼為

public UnionFind(int capacity) {
    if (capacity < 0) {
        throw new IllegalArgumentException("capacity must be >= 1");
    }

    parents = new int[capacity];
    for (int i = 0; i < parents.length; i++) {
        parents[i] = i;
    }
}
複製程式碼
Quick Find - Union

在使用Quick Find實現上圖的元素時,首先要進行的是初始化,前面已經介紹過了,所以在這裡就不再贅述。初始化完成後,如果現在需要對1,0執行Union操作的話,有兩種做法,即將0的箭頭,指向1,或者將1的箭頭指向0,這樣就代表兩個元素屬於同一個集合中。現規定,如果執行union(v1,v2)的話,統一將v1的箭頭指向v2。所以,如果現在執行union(1,0)操作的話,只需要將索引為1指向的元素,值改為0即可。即下圖所示

對應的關係圖如下

同樣的,如果要執行union(1,2)操作的話,按照上面的操作,就是將索引為1指向的元素,改為2即可

但是修改後有一個問題,在以前,0,1是屬於同一個集合的,現在1,2要變為同一個集合,所以還需要將索引0指向的元素也修改為2

最終的關係圖如下

執行union(4,0)操作的話,根據上面的流程,最終得到的結果為

對應的關係圖如下

執行union(3,2),得到的結果為

執行完成後得到的關係圖如下

所以,發現細節了嗎?使用Quick Find來實現Union的話,得到的結果很平,每一個節點,向上找一次,就能找到自己的根節點。那基於這種條件,繼續在研究如何實現Find操作

所以合併的實現程式碼為

public void union(int v1,int v2) {
    int p1 = find(v1);
    int p2 = find(v2);
    if (p1 == p2) return;
    for (int i = 0; i < parents.length; i++) {
        if (parents[i] == p1) {
            parents[i] = p2;
        }
    }
}
複製程式碼
Quick Find - Find

如果使用的是上面這種Union方式,可以發現,陣列中儲存的資料,就是每個索引元素對應的根節點,所以如果有下圖的元素

對應的關係圖為

所以根據每個索引元素對應的根節點的結論可以知道,如果執行下面的操作

  • find(0)得到的結果為2
  • find(1)得到的結果為2
  • find(3)得到的結果為3

所以Quick Find的Find操作,時間複雜度為O(1)

查詢的實現為

public int find(int v) {
    rangeCheck(v);
    return parents[v];
}
複製程式碼

所以,到這裡,通過Quick Find的方式,就實現了並查集,不過,從合併的實現可以發現,在合併時,需要對所有元素進行一次遍歷,所以合併的時間複雜度為O(n)。接下來,再來研究並查集的另外一種實現Quick Union

Quick Union- Union

前面說到,Quick Union的Find與Union時間複雜度都是O(logn),對比Quick Find中的Union時間複雜度O(n)來講,效能提升很多,接下來就看以下,Quick Union是如何實現的。

首先最開始的步驟與Quick Find是一樣的,都需要初始化,每一個元素屬於一個單元素集合。即下列的5個元素

組成單元素集合後

初始化完成後,就開始利用Quick Union的思路執行Union操作。

執行union(1,0),現依然規定,左邊的元素,跟隨右邊的元素,即這裡合併後的話,是讓左邊元素的根節點,等於右邊元素的根節點。即現在合併的1,0,其中1的根節點為1,0的根節點為0,由於左邊元素的根節點等於右邊元素的根節點,所以合併完成後,索引1的元素,根節點變為0,這一步,看起來和Quick Find沒什麼區別。

合併後的關係圖如下

接下來,如果繼續做union(1,2)操作的話,就有區別了。根據前面的結論,所以需要將索引1的根節點改為索引2的根節點。由於現在1的根節點為0,2的根節點為2,所以只需要讓兩個根節點來處理就好了,即讓0與2進行連線就好了,最終就是索引為0的節點,父節點變為2。

合併後的關係圖如下

繼續執行union(4,1)操作,所以就是將4的根節點指向1的根節點,最終需要處理的節點就是4,2,達到的效果就是4指向2

合併後的關係圖如下

再執行union(0,3),也就是說需要將0,3進行合併,仍然是找到0的根節點與3的根節點,然後將0根節點的父節點指向3的父節點即可

合併後的關係圖如下

所以,最終看到的效果就是上圖這樣,這種操作對比之前Quick Find做的union操作就不一樣了,Quick Find樹的高度,最多就是2,但是採用Quick Union這種思路的話, 永遠都是找到根節點進行操作,情況就不一樣了,Quick Find是將左邊集合中所有元素根節點,都改為右邊元素的根節點,Quick只需要對左邊元素的根節點進行操作即可

所以實現程式碼如下

public void union(int v1,int v2) {
    int p1 = find(v1);
    int p2 = find(v2);
    if (p1 == p2) return;
    parents[p1] = p2;
}
複製程式碼

接下來在研究Find操作是如何實現的

Quick Union - Find

首先,Find操作的目的是要返回當前集合的根節點,所以例如下圖中的集合

對應的關係圖如下

如果要查詢1的根節點,就是從節點1開始,一直往上找,直到找到的節點,發現根節點是自己時,就說明已經找到根節點了,將該根節點進行返回即可。

所以

  • find(0)得到的結果為2
  • find(1)得到的結果為2
  • find(3)得到的結果為3

Find操作的時間複雜度我O(logn),由於Find的時間複雜度為O(logn),所以Union操作的時間複雜度也為O(logn)

Find的實現程式碼如下

public int find(int v) {
    rangeCheck(v);
    while (v != parents[v]) {
        v = parents[v];
    }
    return v;
}
複製程式碼

這樣,Quick Union也實現了union與find操作。由於Quick Union與Quick Find之間時間複雜度的區別,所以建議使用效能更高的Quick Union。

Quick Union優化

在Union的過程中,可能會出現樹不平衡的情況,甚至可能會退化成為連結串列,例如下圖

如果現在要執行union(1,3),按照以前的操作,是將1的根節點,指向3的根節點,所以最終的結果如下

所以,如果一直按照這種方式進行合併的話,最終就真的會退化成為連結串列,一旦退化成為連結串列,最終find操作的時間複雜度就變為O(n),所以需要對前面的方案進行優化。

優化方案

  1. 基於size的優化:將元素少的樹,嫁接到元素多的樹
  2. 基於rank的優化:矮的樹,嫁接到高的樹
基於size的優化

例如有下圖的兩個集合,現在要將其合併

由於,現在是將元素少的樹,嫁接到元素多的樹上, 所以最終合併後的結果為

基於這種方式的實現,需要知道每個集合中有多少個元素,所以,要儲存以下每個集合的size,由於在初始化時,全是單元素集合,所以在初始化時,也需要將size進行初始化,所以初始化的程式碼如下

public UnionFind_QuickUnion_Size(int capacity) {
    super(capacity);
    sizes = new int[capacity];
    for (int i = 0; i < sizes.length; i++) {
        sizes[i] = 1;
    }
}
複製程式碼

最終優化的部分,一定是合併集合的部分,所以只需要對union函式部分的程式碼進行優化就可以了

所以對size優化後的程式碼為

public void union(int v1,int v2) {
    int p1 = find(v1);
    int p2 = find(v2);
    if (p1 == p2) return;
    //size少的,嫁接到size多的上
    if (sizes[p1] > sizes[p2]) {
        parents[p2] = p1;//將p2嫁接到p1上
        sizes[p1] += sizes[p2];//更新size
    } else {
        parents[p1] = p2;
        sizes[p2] += sizes[p1];
    }
}
複製程式碼

然後現在對前面的幾種實現,利用相同數量的元素進行對比,最終得到的結果如下

可以看到,基於size的優化,速度非常快,效果非常明顯。

但是,基於size的優化,也可能存在樹不平衡的問題。

例如需要將下圖中的兩個集合進行合併

如果是使用基於size的優化,最終合併的結果為

所以為瞭解決這種問題,將使用基於rank的優化

基於rank的優化

基於rank的優化,是不比較集合中的數量,而是比較集合中樹的高度、基於樹高進行優化的話,現在對下圖中的兩個集合進行合併,則是將樹矮的樹,合併到樹高的樹上

所以,最終是將根節點為4的樹,嫁接上根節點為3的樹上,最終合併完成後如下

很明顯,這種優化,從樹的平衡來講,樹會更加平衡,是由於基於size的優化的。

基於rank的優化,同樣需要在初始化時,初始化每個單元素集合的高度進行初始化,所以初始化時的實現如下

private int[] ranks;
public UnionFind_QuickUnion_Rank(int capacity) {
    super(capacity);
    ranks = new int[capacity];
    for (int i = 0; i < ranks.length; i++) {
        ranks[i] = 1;
    }
}
複製程式碼

與基於size的優化相同,優化的部分也是在合併時,所以只需要對合並部分的程式碼進行優化即可。所以優化後的程式碼如下

public void union(int v1,int v2) {
    int p1 = find(v1);
    int p2 = find(v2);
    if (p1 == p2) return;
    //rank值小的,嫁接到rank值大的樹上
    if (ranks[p1] > ranks[p2]) {
        parents[p2] = p1; //將矮的樹,嫁接到高的樹上
        //由於是矮的樹嫁接到高的樹上,所以不需要更新樹高
    } else if (ranks[p1] < ranks[p2]){
        parents[p1] = p2;
    } else {
        //樹高相等,進行合併,所以樹高會增加1
        parents[p1] = p2;
        ranks[p2] += 1;
    }
}
複製程式碼

最終,將優化後的兩種方案,用更大的測試資料進行測試後,得到的比較結果如下

可以發現,基於rank的優化,表現同樣非常的優秀。不過需要注意,這並不意味著基於rank的優化效能低於基於size的優化,因為這兩種優化,出發點不一樣。基於rank的優化,主要是為瞭解決基於size優化中,出現不平衡的情況,建議使用基於rank的優化。

雖然兩種優化,效果都非常好,不過仍然還有進一步的優化空間。接下來繼續研究關於優化的問題

路徑壓縮(Path Compression)

什麼叫路徑壓縮呢?比如說現在有如下的兩個集合

現在基於Quick Union並且基於rank的優化,進行union(1,5)合併的話,得到的結果如下

合併後,可以發現,雖然有了基於rank的優化,樹會相對平衡一點,但是,隨著union的次數增加,樹的高度依然會越來越高,最終導致find操作會變得越來越慢。所以,基於這樣的問題的存在,可以進行路徑壓縮優化。

路徑壓縮

  • 在find時使路徑上的所有節點都指向根節點,從而降低樹的高度

這句話的意思就是說,執行完find操作以後,這條路徑上的所有節點,都直接指向根節點,所以例如執行find(1)操作,執行完成後的結果如下圖

可以發現,執行完find操作後,原來路徑上的節點2,3,4都直接指向了根節點,通過這樣的優化,樹的高度僅進一步降低,所以如果繼續執行find(0),find(7)操作,最終在find後,路徑上的所有節點,都直接指向根節點,所以最終的結果如下

通過這樣的優化後,以後在進行find操作時,就會變得非常快。而且由於union也要使用到find,所以最終union效率也會提升。所以,最終要達到的效果就是樹越矮越好。接下來就研究,如何實現。

前面說到,路徑壓縮是對find進行優化,所以這次需要對find方法重新實現,最終find的實現如下

public int find(int v) {
    rangeCheck(v);
    if (v != parents[v]) {
        //修改v的父節點,通過遞迴呼叫,最終找到根節點,將v的父節點指向根節點
        //順便將整條路徑節點的父節點都修改為根節點
        parents[v] = find(parents[v]);
    }
    return parents[v];
}
複製程式碼

通過優化後,與size,rank優化進行對比,最終得到的結果如下

可以看到,最終的效果依然非常明顯。但是依然有以下注意點

  1. 路徑壓縮使路徑上的所有節點都指向根節點,所以實現成本稍高,例如有遞迴呼叫,會開闢更多的棧空間

所以,基於這種問題,還有另外2中更優的做法,不僅能降低樹高,實現成本也會比路徑壓縮更低,分別為

  1. 路徑分裂(Path Spliting)
  2. 路徑減半(Path Halving)

路徑分裂,路徑減半的效率差不多,但都比路徑壓縮要好

路徑分裂(Path Spliting)

路徑分裂:使路徑上的每個節點都指向其祖父節點(parent的parent)

例如,下列的集合

在執行find(1)操作時,會將1指向3,3指向5,5指向7,2指向4,4指向6,6指向7,所以最終執行完畢後的結果如下圖

所以,可以看到,使用了路徑分裂這種優化方案的話, 樹的高度的確是降低了,不像路徑壓縮一樣,直接將所有子節點平鋪開。所以拆分的成本會比路徑壓縮低一些。所以基於這種思路,最終實現的程式碼如下

public int find(int v) {
    rangeCheck(v);
    while (v != parents[v]) {
        //將當前節點的父節點儲存下來
        int p = parents[v];
        //然後讓當前節點指向祖父節點
        parents[v] = parents[parents[v]];
        //更新節點,重複執行操作
        v = p;
    }
    return v;
}
複製程式碼

通過與前面幾種優化方案進行對比,最終得到的比較結果如下

可以發現,路徑分裂方案確實進一步優化了效能。說明這種路徑分裂方案是可行的,那接下來再研究路徑減半這種優化方案。

路徑減半(Path Halving)

路徑減半:使路徑上的每隔一個節點就指向其祖父節點(parent的parent)

對比前面的路徑分裂,路徑分裂是每一個節點,都指向祖父節點,路徑減半是每隔一個節點就指向祖父節點。那究竟是什麼意思呢?例如有下圖的集合

現在執行find(1)操作,根據上面的定義,即執行完find(1)後,1指向祖父節點,3指向祖父節點,5指向祖父節點,即3指向5,5指向7,其餘元素保持不變,所以最終的結果如下圖

其實可以發現,這種方案對比前面的路徑分裂,效果類似。因為最紅優化後的樹高,是一樣的。現在已經知道了優化辦法,根據這種優化辦法,最終得到的優化程式碼如下

public int find(int v) {
    rangeCheck(v);
    while (v != parents[v]) {
        //然後讓當前節點指向祖父節點
        parents[v] = parents[parents[v]];
        //parents[v]為祖父節點,祖父節點重複執行操作
        v = parents[v];
    }
    return v;
}
複製程式碼

最終通過下圖幾種優化方案的對不,可以發現,路徑減半和路徑分裂的效能很接近

理論上來講,路徑減半與路徑分裂兩種演演算法,都非常優秀。並且總體來講,都要由於其他優化方案。

總結

摘自《維基百科》:en.wikipedia.org/wiki/Disjoi…

大概意思是

  1. 使用路徑壓縮,分裂或者減半 + 基於rank或者size的優化
    • 可以確保每個操作的均攤時間複雜度為O(α(n)),α(n) < 5

個人建議的搭配

  1. Quick Union
  2. 基於rank的優化
  3. Path Halving或者Path Spliting

自定義型別

之前的使用都是基於整型資料,如果其他自定義型別也想使用並查集,如何實現呢?可以參考以下方案

  1. 通過一些方法,將自定義型別轉換為整型後,使用並查集(比如生成雜湊值)
  2. 使用連結串列+對映(Map)

為什麼可以使用連結串列實現呢?因為並查集本質上就是樹形結構,只不過是通過陣列來表達這種樹形結構。然後樹形結構中的每一條分支,都是一條連結串列。

demo下載地址

完!