並查集(union-find)
什麼是並查集?
並查集也是一種樹結構, 它用於處理一些不交集的合併及查詢問題。 以往的樹結構都是父親指向兒子, 而並查集是兒子指向父親。
並查集支援下面兩種操作:
- 查詢(find): 確定某個元素屬於哪個子集。它可以被用來確定兩個元素是否屬於同一個子集。
- 合併(Union): 將兩個子集合併成一個子集。
也就是說,不支援集合的分離、刪除。
先來說說查詢究竟有什麼用?
舉個例子: 幾個家族進行宴會, 但是家族普遍長壽, 所以人數眾多。由於長時間的分離以及年齡的增長, 這些人逐漸忘掉自己的親人。只記得自己的爸爸是誰了。而最長者(稱為"祖先")的父親已經去世, 他只知道自己是祖先, 為了確定自己是哪個家族, 他們想出一個辦法, 只要問自己的爸爸是不是祖先, 一層一層的往上問, 直到問到祖先。如果要判斷兩個人是否在同一個家族, 只要看兩個人的祖先是不是同一個人就可以。
通過查詢, 我們能清楚的知道兩個人是否存在關係。 那麼合併呢?
合併:
宴會上, 一個家族的祖先突然對另外一個家族說: 我們兩個家族交情這麼好, 不如合成一家好了。另一個家族也欣然同意。
並查集實現
設計並查集介面
上面我們提到, 並查集只支援"查詢"和"合併"兩種操作, 所以我們介面中只設計該方法。
public interface UF {
// 並查集元素個數
int getSize();
boolean find(int p, int q);
void union(int p, int q);
}
複製程式碼
並查集實現版本V1, Quick Find
現在我們要實現上面介面的實現, 一個是將兩個元素合併在一起變成在一個集合中的元素(union), 另外一個就是檢查兩個元素是否是相連的(find)。
既然要判斷是否所屬同一個集合中或者合併元素進而同屬於一個集合中, 所以我們可以在並查集內部資料做一個編號, 進而辨別。[如圖1-1]
在這裡0-9表示10個不同的資料, 當然這是一種抽象的表示, 具體可以想象這0-9這10個編號是10個人, 10部車或者10本書, 這都是更具具體業務來決定的。但是, 在並查集的內部我們只儲存0-9這10個編號。它表示具體的10個元素。對於每一個元素並查集儲存的是一個它所屬於的集合ID。什麼意思呢?
可以看到圖[1-1]中, 元素[0, 2, 4, 6, 8]這幾條資料所屬的集合ID是0, 元素[1, 3, 5, 7, 9]所屬的集合ID是1。
不同的ID值就是不同的集合所對應的編號。簡單來說就是對這10條資料分成了2個集合。其中 [0, 2, 4, 6, 8]這5個元素在一個集合中, [1, 3, 5, 7, 9]這5個元素在另外一個集合中。
圖[1-1]
從圖[1-1]也能看出來, 其實就是利用資料來儲存對應的id編號, 這種方式在查詢中效率很高O(1), 但是在進行union的話就需要O(n)了。
比如說: 我現在要合併元素1和4, 可以看到元素1對應的集合id是1但是元素4對應的集合id是0, 在這種情況下, 將1和4這兩個元素合併後, 1所屬的集合和4所屬的集合每一個元素相當於也連線了起來, 簡單來說0和1的集合編號, 我們取其中一個進行覆蓋讓其都能關聯在一起。
所以, 經過union之後, 就會變成圖[1-2]這個樣子
圖[1-2]
public class UnionFindV1 implements UF {
private int[] id; // 集合編號
public UnionFindV1(int size) {
id = new int[size];
/***
* 在初始化的時候, 我們的元素都是獨立的, 還沒有某兩個元素互相合並
* 合併操作等我們構建好並查集之後進行union即可
*
* 現在我們初始化, 每個元素的編號都不一樣
* 第0個元素對應的集合編號是0
* 第1個元素對應的集合編號是1
* ...依次類推
*
*/
for (int i = 0 ; i < id.length; i ++)
id[i] = i;
}
@Override
public int getSize() {
return id.length;
}
@Override
public boolean find(int p, int q) {
/***
* 首先查詢p和q兩個元素所屬同一個集合編號
* O(1)查詢
*/
return find(p) == find(q);
}
// 查詢元素p所對應的集合編號
private int find(int p) {
if (p < 0 || p >= id.length)
throw new IllegalArgumentException("Error/");
return id[p];
}
@Override
public void union(int p, int q) {
/***
* 合併元素p和元素q所屬的集合。
* 需要迴圈所有元素進行替換O(n)
*/
int pID = find(p);
int qID = find(q);
if (pID != qID) { // 這裡只判斷它們屬於不同的集合中, 才進行合併。
for (int i = 0; i < id.length; i++)
if (id[i] == pID)
id[i] = qID;
}
}
}
複製程式碼
並查集實現版本V2, Quick Union
上個版本中, 我們實現了並查集的一種現實思路。我們實際使用陣列進行模擬得到的結果叫做Quick Find, 也就是查詢這個操作是非常快的。不過在標準的情況下, 並查集的實現思路是一種叫做Quick Union這樣的實現思路。
Quick Union實現思路是如何的呢?
具體就是將每一個元素, 看成是一個節點。而節點之間相連線形成一個樹結構。不過這裡的樹結構和我們之前學習的樹結構是不同的, 我們在並查集中實現的樹機構是孩子指向父親的。
什麼意思呢? 如下圖[1-3]:
圖[1-3]
首先我們看"圖例1", 我們有節點3和節點2, 如果要連線在一起, 指向方式是3指向2, 而2則是根節點, 由於根節點也有一個指標, 根節點指標只需要指向自己就可以。
這種情況下, 比如說對於"節點1"所代表的的元素要和節點3所代表的元素進行合併, 合併操作是怎麼實現的呢? 實際上就是讓1這個節點的指標指向3所在的這顆樹的根節點, 也就是讓節點1指向根節點2, 檢視"圖例2"。
當然了, 有可能在我們的並查集中存在一棵樹, 如"圖例3"中的[5, 6, 7], 其中6和7都是5的孩子, 現在如果我想讓7這個節點和2這個節點進行合併, 實際上就是讓7所在的根節點即5這個節點去指向2這個節點就可以了。當然了, 如果我想讓7這個節點和3這個節點合併得到的結果也是一樣的。實際上我們要做的是找到7這個節點根節點5指向3這個節點的根節點2。依然是根節點5指向根節點2。
這樣的資料結構樣子, 才是實際實現一個並查集的思路。
在這種思路下, 我們具體的儲存就發生了變化, 但其實還是非常簡單的, 我們觀察"圖例3"中, 每一個節點其實只有一個指標, 也就是會指向另外一個元素, 關於指標的儲存, 我們依然可以使用陣列來實現。
對於這個陣列, 我們可以把它稱為parent, parent(i)表示的就是第i個節點指向那個節點, 所以之前雖然一直在說指標, 但實際儲存的時候依然使用一個int型的陣列就可以了。
這樣一來, 在初始化的時候, 我們每一個節點都沒有和其它的節點進行合併。所以每一個節點都指向了自己。
以10個元素為例子, 具體觀察圖[1-4], 每一個節點都是一個根節點。都指向自己。嚴格來說, 當前我們的並查集不是一棵樹結構, 而是一個森林。"所謂的森林就是裡面有很多的樹。", 在初始的情況下, 現在我們的森林中就有10顆樹。每顆樹都只有一個節點而已。
具體我們重點來說說第2步, 第5步, 第9步。
第2步: union(4, 3)這個操作, 怎麼做呢? 其實就是將4這個節點指向3這個節點就可以了。在我們的parent陣列中反應出來就是parent(4)=3, 這就代表4這個節點它指向了3這個節點。
第5步: union(9, 4), 這個過程就是讓9這個節點指向4這個節點所在的根節點。這裡就有一個查詢過程了, 我們就要看一下4這個節點指向了3這個節點, 3這個節點指向了8, 而8指向了自己說明8是根節點。我們就找到了4這個節點所在的根節點是8, 我們要做的就是讓9這個節點指向8這個根節點就可以了。"在這裡可以看出, 我們為什麼不讓9指向4這個節點呢? 因為這樣指完以後就形成一個連結串列了, 那麼我們的樹整體的優勢就體現不出來了。現在我們讓9指向8, 如果我們要查詢9這個節點對應的根節點是誰, 只需要進行一步查詢。"
第9步: union(6, 2), 相應的我們要找到6這個節點的根節點是0, 我們在找到2這個節點的根節點是1, 相應的我們讓6這個節點的根節點0指向2這個節點的根節點1就可以了。
通過這個模擬的過程, 我們的union的時間複雜度是一個O(h)級別的。其中這個h是樹的深度大小。這個深度的大小在通常的情況下都比我們的元素個數n要小, 所以我們的union過程相對來說會快些。相對的代價就是查詢則是樹的深度大小。
圖[1-4]
public class UnionFindV2 implements UF {
private int[] parent;
public UnionFindV2(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 find(int p, int q) {
return find(p) == find(q);
}
/***
* 查詢過程, 查詢元素p所對應的集合編號
* O(h)複雜度, 其中h為樹的高度。
* @param p
* @return
*/
private int find(int p) {
if (p < 0 || p >= parent.length)
throw new IllegalArgumentException("Error");
while (p != parent[p]) { // 當p和parent[p]相等也就是指向自己, 即根節點
p = parent[p];
}
return p;
}
@Override
public void union(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
// 讓p的根節點指向q的根節點
if (pRoot != qRoot)
parent[pRoot] = qRoot;
}
}
複製程式碼
並查集實現版本V3, 基於size優化
在進行優化之前, 我們先來對之前兩個版本union-find進行一個簡單測試。
public class UnionFindTest {
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.union(a, b);
}
// m次查詢效能測試
for (int i = 0; i < m; i ++) {
int a = random.nextInt(size);
int b = random.nextInt(size);
uf.find(a, b);
}
long endTime = System.nanoTime();
return (endTime - startTime) / 1000000000.0;
}
public static void main(String[] args) {
int size = 10000;
int m = 10000;
UnionFindV1 unionFindV1 = new UnionFindV1(size);
System.out.println("UnionFindV1: " + testUF(unionFindV1, m) + " s");
UnionFindV2 unionFindV2 = new UnionFindV2(size);
System.out.println("UnionFindV2: " + testUF(unionFindV2, m) + " s");
}
}
複製程式碼
當size=10000, m=10000時候, 我的輸出如下:
UnionFindV1: 0.11981561 s
UnionFindV2: 0.068716989 s
差距並不是很大。由於UnionFindV1合併操作是O(n)級別的, 這個n就是size的值。為了顯示它們之間的差距更加明顯。嘗試修改size的值。
當size = 100000, m=10000時候, 我的輸出如下:
UnionFindV1: 0.502680044 s
UnionFindV2: 0.005111976 s
這次的執行, 二者之間的差距就顯示的非常大了。
但是V2真的就比V1好嗎?
當size=100000, m=100000, 我的輸出如下:
UnionFindV1: 7.837964178 s
UnionFindV2: 15.23050521 s
可以看到UnionFindV2比UnionFindV1還要慢了。
由於UnionFindV1整體就是使用一個陣列, 我們的合併就是對這個一個連續空間進行迴圈操作JVM有比較好的優化所以執行速度會比較快, 相應的UnionFindV2查詢的過程是一個不斷索引的過程, 它不是一個順序的訪問一片連續空間過程。要在不同地址之間進行跳轉因此速度會慢一些。第二個原因就是UnionFindV2的find過程是O(h)比我們UnionFindV1要高。
複製程式碼
好的, 當我們發現V2的版本小於V1的時候, 我們去觀察一下union過程中, 更多的元素被組織在一個集合中, 所以我們得到的樹是非常大的, 相應的深度也會非常的高。這就使得在後續進行m次find操作它的時間效能消耗也會非常的高。
我們在進行union操作的時候, 就直接將p元素的根節點指向q元素的根節點。 我們沒有充分考慮p和q這兩個元素, 所在的樹的特點是如何的。
那麼在優化UnionFindV2之前, 我們先來看看下面這張圖。
通過上圖, 我們發現, 現在的並查集在實現union過程中並沒有去判斷兩個元素的樹結構, 很多時候這個合併過程會不斷增加樹的高度。甚至在某些極端的情況下我們得到的是一個連結串列形狀。具體如何解決呢? 一個簡單解決方案就是考慮"size", 當前這棵樹有多少個節點。簡單來說就是"讓節點個數小的樹它的根節點去指向節點個數多的那棵樹的根節點。"這樣處理之後, 高概率它的深度會比較低。
public class UnionFindV3 implements UF {
private int[] parent;
private int[] sz; // 記錄樹的節點個數, sz[i]表示以i為根節點的集合中元素個數
public UnionFindV3(int size) {
parent = new int[size];
sz = new int[size];
// 初始化, 相互之間沒有共同集合, 大家都指向自己
for (int i = 0 ; i < parent.length; i++) {
parent[i] = i;
sz[i] = 1; // 初始化, 每棵樹的高度都為1
}
}
@Override
public int getSize() {
return parent.length;
}
@Override
public boolean find(int p, int q) {
return find(p) == find(q);
}
/***
* 查詢過程, 查詢元素p所對應的集合編號
* O(h)複雜度, 其中h為樹的高度。
* @param p
* @return
*/
private int find(int p) {
if (p < 0 || p >= parent.length)
throw new IllegalArgumentException("Error");
while (p != parent[p]) { // 當p和parent[p]相等也就是指向自己, 即根節點
p = parent[p];
}
return p;
}
@Override
public void union(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
// 讓p的根節點指向q的根節點
if (pRoot != qRoot) {
// 判斷兩個樹的元素個數, 讓元素個數小的根節點指向元素個數多的根節點。
// 根據兩個元素所在樹的元素個數不同判斷合併方向
// 將元素個數少的集合合併到元素個數多的集合上
if (sz[pRoot] < sz[qRoot]) {
parent[pRoot] = qRoot;
sz[qRoot] += sz[pRoot]; // 維護sz陣列的值, 我們讓qRoot值加上pRoot的值, 因為它們已經合併了。
} else {
parent[qRoot] = pRoot;
sz[pRoot] += sz[qRoot];
}
}
}
}
複製程式碼
現在, 我們把UnionFindV3加入進行測試, 我這邊輸出如下時間:
UnionFindV1: 7.297955247 s
UnionFindV2: 14.029683212 s
UnionFindV3: 0.030103227 s
可以發現, 我們UnionFindV3加入size的優化後, 效率就提升很多了。
加入size後, 我們並查集保證樹的深度是非常淺的。
複製程式碼
並查集實現版本V4, 基於rank優化
我們先來看一下下面這張圖。如果我們要進行union(4,2), 4這個節點的根節點是8, 2這個節點的根節點是7。
如果我們是基於size進行優化的話, 4所在的樹節點總數是3, 而2所在的樹的節點總數是6個。根據上面特性節點小的樹指向節點大的樹, 合併後也就是8指向7。但是這裡需要注意一點就是, 這顆樹的深度是"4"。在合併之前兩棵樹的深度分別是2和3, 經過這樣的合併之後, 這棵樹的深度增加了。
但是在我們這個例子中, 節點數少的這顆樹也就是以8為根節點的這棵樹, 它的高度反而比根節點為7的樹更高。所以, 一個更加合理的合併方案是讓7這個節點指向8這個節點。這樣合併後得到的新樹深度依然是3。對於我們union這個過程來說一個更好的合併方式是在每一個節點上記錄一下以這個節點為根的深度。所以, 我們在合併的時候應該使用"深度比較低的樹指向深度比較高的樹"
那麼這種優化方案, 被稱為基於rank的優化。
rank[i]表示根節點為i的樹的高度。
public class UnionFindV4 implements UF {
private int[] parent;
private int[] rank; // rank[i]表示以i為根的集合所展示的樹的深度
public UnionFindV4(int size) {
parent = new int[size];
rank = new int[size];
// 初始化, 相互之間沒有共同集合, 大家都指向自己
for (int i = 0 ; i < parent.length; i++) {
parent[i] = i;
rank[i] = 1; // 初始化, 每棵樹的高度都為1
}
}
@Override
public int getSize() {
return parent.length;
}
@Override
public boolean find(int p, int q) {
return find(p) == find(q);
}
/***
* 查詢過程, 查詢元素p所對應的集合編號
* O(h)複雜度, 其中h為樹的高度。
* @param p
* @return
*/
private int find(int p) {
if (p < 0 || p >= parent.length)
throw new IllegalArgumentException("Error");
while (p != parent[p]) { // 當p和parent[p]相等也就是指向自己, 即根節點
p = parent[p];
}
return p;
}
@Override
public void union(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if (pRoot != qRoot) {
/***
* 根據兩個元素所在樹的rank不同判斷合併方向
* 將rank低的樹合併到rank高的樹上
*
* 注意:
* 當一棵樹比另外一顆樹的深度更大的話, 就不需要維護樹的深度,
* 比如A的深度是4, B的深度是3, B掛載到A上面, B的深度最多和A的子樹深度一樣大
*/
if (rank[pRoot] < rank[qRoot]) {
parent[pRoot] = qRoot;
} else if (rank[qRoot] < rank[pRoot]) {
parent[qRoot] = pRoot;
} else {
parent[qRoot] = pRoot;
rank[pRoot] += 1; // 如果兩棵樹的深度相等, 在進行指向之後, 肯定會多一個節點出來的。
}
}
}
}
複製程式碼
那麼對比基於size的優化時間執行如下
size = 10000000, m = 10000000
UnionFindV3: 7.95442212 s
UnionFindV4: 6.952561368 s
複製程式碼
並查集優化之路徑壓縮
路徑壓縮解決了一個什麼問題? 我們先來看看下面這張圖。
這三幅圖均表示這5個節點是相互連線的, 但是經過上面的學習我們瞭解到樹的深度不同效率也是不同的。
很顯然, 最左邊的樹高度達到了5, 如果執行find(4)這個操作, 它的時間效率會相對較慢。 而最右邊的樹高度只有2, 所以相應的在這棵樹中find()任意一個節點它的時間效能就比較高。在我們之前學習過的並查集Union操作中, 讓節點低的樹指向節點高的樹, 這個過程難免會增加樹的高度。路徑壓縮解決的問題就是讓一顆高的樹壓縮成為一個比較矮的樹。
這裡注意一下, 並查集中子樹的個數是沒有限制的。所以最理想的情況下都形成下圖最右邊的形狀。只有2層, 根節點在第1層, 其它節點在下面一層。
路徑壓縮發生過程
路徑壓縮發生在什麼時候呢? 什麼時候進行壓縮?
這裡我們主要在進行find的時候壓縮。
也就是我們在查詢一個節點對應的根節點是誰這個過程中, 這個過程我們要不斷向上直到找到根節點, 在這個查詢過程中, 順便讓這顆樹的深度降低。即路徑壓縮過程。
那麼路徑壓縮這個過程是怎麼實現的呢?
檢視下圖
在我們查詢的過程中, 我們需要執行一段話
parent[p] = parent[parent[p]]
複製程式碼
可能看著比較繞, 其實很簡單。其實就是將p這個節點的父節點從新指向p的父節點的父節點。 比如說節點4來舉例, 它的父節點是3通過parent[p]得到, 然後在進行parent[3]得到父節點2, 所以4這個節點重新指向節點2。得到新的父節點2後, 就從2這個節點開始繼續往上查詢。可以看到我們樹的深度就降低了。
public class UnionFindV5 implements UF {
private int[] parent;
private int[] rank; // rank[i]表示以i為根的集合所展示的樹的深度
public UnionFindV5(int size) {
parent = new int[size];
rank = new int[size];
// 初始化, 相互之間沒有共同集合, 大家都指向自己
for (int i = 0 ; i < parent.length; i++) {
parent[i] = i;
rank[i] = 1; // 初始化, 每棵樹的高度都為1
}
}
@Override
public int getSize() {
return parent.length;
}
@Override
public boolean find(int p, int q) {
return find(p) == find(q);
}
/***
* 查詢過程, 查詢元素p所對應的集合編號
* O(h)複雜度, 其中h為樹的高度。
* @param p
* @return
*/
private int find(int p) {
if (p < 0 || p >= parent.length)
throw new IllegalArgumentException("Error");
while (p != parent[p]) { // 當p和parent[p]相等也就是指向自己, 即根節點
/***
* 路徑壓縮...
* 注意:
* 在rank的時候, 當我們樹發生了改變就會從新維護rank值, 而路徑壓縮卻沒有維護rank值? 這是必須的嗎?
* 在路徑壓縮時候可以不用維護rank, 這也就是為什麼稱這個陣列為rank而不是深度或者depath或者height的原因
* 在新增路徑壓縮後, 就不代表這個樹的高度, 只表示一個排名
*
* 當然在新增路徑壓縮rank還是原來的邏輯, 只不過可能會出現相同深度的樹但是rank值不同。
* 所以rank只是在合併的時候進行的一個參考值。
*/
parent[p] = parent[parent[p]];
p = parent[p];
}
return p;
}
@Override
public void union(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if (pRoot != qRoot) {
/***
* 根據兩個元素所在樹的rank不同判斷合併方向
* 將rank低的樹合併到rank高的樹上
*
* 注意:
* 當一棵樹比另外一顆樹的深度更大的話, 就不需要維護樹的深度,
* 比如A的深度是4, B的深度是3, B掛載到A上面, B的深度最多和A的子樹深度一樣大
*/
if (rank[pRoot] < rank[qRoot]) {
parent[pRoot] = qRoot;
} else if (rank[qRoot] < rank[pRoot]) {
parent[qRoot] = pRoot;
} else {
parent[qRoot] = pRoot;
rank[pRoot] += 1; // 如果兩棵樹的深度相等, 在進行指向之後, 肯定會多一個節點出來的。
}
}
}
}
複製程式碼