並查集(Union Find),從字面意思不太好理解這東西是個啥,但從名字大概能夠得知與查詢和集合有關,而實際也確實如此。並查集其實是一種很不同的樹形結構,用於處理一些不相交集合(Disjoint Sets)的合併及查詢問題。java
之因此說並查集是一種「不同」的樹形結構,是由於通常的樹形結構都是父節點指向子節點的,而並查集則是反過來,子節點指向父節點,而且這棵樹會是一棵多叉樹。算法
並查集能夠高效的用來解決鏈接問題(Connectivity Problem),咱們來看下面這樣的一張圖:
數組
能夠看到,該圖中有不少的點,有些點之間有鏈接,而有些點之間則沒有鏈接。那麼此時就有一個問題是:這圖中任意的兩個點是否可能經過一條路徑鏈接起來。對於這個問題,咱們使用並查集就能夠高效的求解,由於並查集能夠很是快地判斷網絡中節點間的鏈接狀態。這裏的網絡指的是廣義的網絡,例如用戶之間造成的社交網絡,有時候也叫作圖。bash
並查集對於一組數據來講,主要支持兩種操做:網絡
union(p, q)
,把兩個不相交的集合合併爲一個集合。isConnected(p, q)
,查詢兩個元素是否在同一個集合中,也就是是否能夠鏈接的。根據這兩個操做,咱們就能夠定義出並查集的接口了,這是由於並查集能夠有多種實現方式,這裏定義接口來作統一抽象:數據結構
package tree.unionfind; /** * 並查集接口 * * @author 01 * @date 2021-01-28 **/ public interface UnionFind { /** * 查詢兩個元素是否在同一個集合中 * * @param p p * @param q q * @return true or false */ boolean isConnected(int p, int q); /** * 合併兩個元素到同一個集合中 * * @param p p * @param q q */ void unionElements(int p, int q); /** * 並查集中的元素數量 * * @return int */ int getSize(); }
若是咱們但願並查集的查詢效率高一些,那麼咱們就能夠側重於查詢操做,實現一個「Quick Find」性質的並查集。咱們可使用數組來表示並查集中的數據,數組中存放每一個元素所在的集合編號,例如 0 和 1。而數組的索引則做爲每一個元素的 id,這樣咱們在查詢的時候,只須要根據數組索引取出相應的兩個元素的集合編號,判斷是否相等就能得知這兩個集合是否存儲在同一集合中,也就知道這兩個元素是否能夠「鏈接」。具體以下圖:
dom
例如,傳入的 p 和 q,分別是 1 和 3。那麼根據數組索引找到的元素編號都爲 1,此時就能夠判斷出這兩個元素屬於同一集合,也就表明這兩個元素之間能夠「鏈接」,反之同理。因爲數組的特性,這個查詢的時間複雜度就是 $O(1)$,咱們就認爲稱這個並查集具備「Quick Find」性質。ide
合併操做也很簡單,經過傳入的 p 和 q,獲得它們的集合編號。而後遍歷數組,找其中一個集合編號,假定找的是 p 的集合編號,找到後將其更新爲 q 的集合編號便可。固然,反過來也是能夠的,這個沒有特殊的規定。因爲要遍歷數組,所以合併操做的時間複雜度就是 $O(n)$。可見,「Quick Find」是犧牲了合併操做的效率。性能
具體的實現代碼以下:測試
package tree.unionfind; /** * 「Quick Find」性質的Union-Find * * @author 01 */ public class UnionFind1 implements UnionFind { /** * 「Quick Find」性質的Union-Find本質就是一個數組 */ private final int[] ids; public UnionFind1(int size) { ids = new int[size]; // 初始化,每個ids[i]指向本身所在的數組索引,由於此時沒有合併的元素 for (int i = 0; i < size; i++) { ids[i] = i; } } @Override public int getSize() { return ids.length; } /** * 查找元素p所對應的集合編號 * O(1)複雜度 */ private int find(int p) { if (p < 0 || p >= ids.length) { throw new IllegalArgumentException("p is out of bound."); } return ids[p]; } /** * 查看元素p和元素q是否所屬一個集合,這裏的p和q就是表示的數組索引 * O(1)複雜度 */ @Override public boolean isConnected(int p, int q) { // 只須要判斷兩個元素所屬的集合編號是否相等便可 return find(p) == find(q); } /** * 合併元素p和元素q所屬的集合 * O(n) 複雜度 */ @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 < ids.length; i++) { if (ids[i] == pId) { ids[i] = qId; } } } }
有「Quick Find」天然就有「Quick Union」,「Quick Find」的查詢和合並操做不是那麼的平衡,時間複雜度相差得比較大,是徹底犧牲了合併操做的性能。所以,這種並查集的實現思路並不經常使用,而「Quick Union」是相對來講更經常使用,以及更標準的實現思路。由於「Quick Union」是基於樹的,雖然這棵樹也可使用數組來表示。
使用「Quick Union」思路實現並查集時,咱們將每個元素,看作是一個節點。但與普通的樹形結構不一樣的是,並查集的樹是子節點指向父節點的,在以前也提到過。以下:
能夠看到,3 這個子節點是指向它的父節點 2 的,而這個父節點是一個根節點則是會本身指向本身。接下來,咱們先看看合併操做。若是咱們要讓元素 3 和元素 1 進行合併,只須要讓元素 1 指向元素 3 的父節點元素 2 便可。以下所示:
還有一種狀況就是,合併另外一棵樹的子節點。例如,節點 5 有兩個子節點 6 和 7,此時但願將節點 7 與以前的節點 2 進行合併。對於這種狀況其實只須要將其父節點 5 與節點 2 進行合併便可。以下所示:
從上圖能夠看出,「Quick Union」的並查集在合併集合時,其實就是在合併兩棵樹,而一棵樹就是在表示一個集合。理解這種表示集合的方式很是重要。屬於同一個根節點的元素,咱們就能夠認爲它們屬於同一個集合。集合的合併就是樹的合併,合併的方式是一棵樹的根節點掛到另外一棵樹的根節點下,成爲對方的子樹。就像是一個集合與另外一個集合合併後,成爲對方的子集。
咱們使用數組來表示樹形結構的並查集時,子節點指向父節點的指針實際就是存儲父節點的數組索引。並且在初始化後,未進行合併操做時,每一個元素都是本身成爲一棵樹的根節點,表明不一樣的集合。也就是說此時會有多棵樹,這種狀況稱之爲森林結構,這也是爲何會存在合併兩棵樹的狀況。以下所示:
對應的數組表示以下:
基於上圖,若是咱們此時合併 4 和 3 這兩個元素,也就是將 4 的指針指向 3,3 成爲 4 的父節點。以下:
那麼只須要更新數組中索引爲 4 的元素的值爲 3 便可,由於子節點只須要存儲父節點的數組索引,此時就完成了合併操做。以下所示:
咱們再看看其他狀況的合併操做:
因爲樹的特性,此時並查集的查詢操做時間複雜度就是 $O(h)$,$h$ 爲樹的高度。由於查詢兩個節點是否屬於同一集合,就等同於查詢這兩個節點是否屬於同一棵樹。那麼,就得找到這兩個節點的根節點,判斷是不是同一個節點,因此時間複雜度取決於樹的高度。同理,合併操做也是同樣的,由於 B 節點須要與 A 節點合併的話,那麼就得找到 A 節點的根節點,並將本身掛載到該根節點下。
接下來,咱們就實現「Quick Union」性質的並查集。代碼以下:
package tree.unionfind; /** * 「Quick Union」性質的Union-Find * * @author 01 */ public class UnionFind2 implements UnionFind { /** * 「Quick Union」性質的Union-Find,使用一個數組構建一棵指向父節點的樹 * parent[i]表示第一個元素所指向的父節點 */ private final int[] parent; public UnionFind2(int size) { parent = new int[size]; // 初始化,此時每個parent[i]指向本身,表示每個元素本身自成一顆樹 for (int i = 0; i < size; i++) { parent[i] = i; } } @Override public int getSize() { return parent.length; } /** * 查找過程, 查找元素p所對應的集合編號 * O(h)複雜度, h爲樹的高度 */ private int find(int p) { if (p < 0 || p >= parent.length) { throw new IllegalArgumentException("p is out of bound."); } // 不斷去查詢本身的父親節點, 直到到達根節點 // 根節點的特色: parent[p] == p while (p != parent[p]) { p = parent[p]; } return p; } /** * 查看元素p和元素q是否所屬一個集合 * O(h)複雜度, h爲樹的高度 */ @Override public boolean isConnected(int p, int q) { return find(p) == find(q); } /** * 合併元素p和元素q所屬的集合 * O(h)複雜度, h爲樹的高度 */ @Override public void unionElements(int p, int q) { // 找到p和q的根節點 int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) { return; } // 將q的根節點掛載到p的根節點下,成爲它的子節點 parent[pRoot] = qRoot; } }
在上一小節中,咱們實現了「Quick Union」性質的並查集,這也是並查集標準的實現方式。但這只是一個基礎的實現,仍有許多優化空間。本小節就演示一下其中一種優化方法:基於size的優化。
在基礎的「Quick Union」實現中,對 q 和 p 進行合併時,咱們只是簡單地把 q 的根節點掛載到 p 的根節點下,沒有去判斷另外一棵樹是什麼形狀的。此時在極端的狀況下,並查集中的這棵樹可能會退化成線性的時間複雜度:
爲了解決這個問題,咱們須要在合併時,考慮當前這棵樹的size,也就是須要判斷一下樹中的節點數量。經過這個節點數量來決定合併方向,將節點數量少的那棵樹合併到節點數量多的那棵樹上。以下所示:
具體的實現代碼以下:
package tree.unionfind; /** * 基於size優化的Union-Find * * @author 01 */ public class UnionFind3 implements UnionFind { /** * parent[i]表示第一個元素所指向的父節點 */ private final int[] parent; /** * sz[i]表示以i爲根的集合中元素個數 */ private final int[] sz; public UnionFind3(int size) { parent = new int[size]; sz = new int[size]; // 初始化, 每個parent[i]指向本身, 表示每個元素本身自成一個集合 for (int i = 0; i < size; i++) { parent[i] = i; sz[i] = 1; } } @Override public int getSize() { return parent.length; } /** * 查找過程, 查找元素p所對應的集合編號 * O(h)複雜度, h爲樹的高度 */ private int find(int p) { if (p < 0 || p >= parent.length) { throw new IllegalArgumentException("p is out of bound."); } // 不斷去查詢本身的父親節點, 直到到達根節點 // 根節點的特色: parent[p] == p while (p != parent[p]) { p = parent[p]; } return p; } /** * 查看元素p和元素q是否所屬一個集合 * O(h)複雜度, h爲樹的高度 */ @Override public boolean isConnected(int p, int q) { return find(p) == find(q); } /** * 合併元素p和元素q所屬的集合 * O(h)複雜度, h爲樹的高度 */ @Override public void unionElements(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) { return; } // 根據兩個元素所在樹的元素個數不一樣判斷合併方向 // 將元素個數少的集合合併到元素個數多的集合上 if (sz[pRoot] < sz[qRoot]) { parent[pRoot] = qRoot; sz[qRoot] += sz[pRoot]; } else { parent[qRoot] = pRoot; sz[pRoot] += sz[qRoot]; } } }
在上一小節中,咱們介紹了基於 size 的優化,這是一種最基礎的並查集優化方式,從基於 size 的優化咱們能夠過渡到基於 rank 的優化。由於基於 size 的優化在某些極端狀況下,仍然存在一些問題,另外基於 rank 的優化也是並查集的標準優化方式。
基於 size 的優化的問題就在於,咱們但願樹的高度儘可能低,可是 size 小不意味着高度就低。而相較而言,rank 能夠更好地衡量高度。由於這裏的 rank 表示的是樹的層級數量,而不是像 size 那樣的節點數量。
咱們來看一個例子:
在這個例子中,咱們要合併 4 和 2 這兩個節點。從圖中能夠看到,2 所在的樹共有 6 個節點,而 4 所在的樹共有 3 個節點。若是使用的是基於 size 的優化,那麼 size 小的要向 size 大的合併,4 所在的根節點 8 就須要掛到 2 所在的根節點 7 下。合併後,以下圖所示:
能夠看到,在這種狀況下,基於 size 的優化就不是最優的,合併後的樹的高度反而變高了。因此更合理的作法應該是層數低的向層數高的合併,也就是 rank 小的向 rank 大的合併。在此例中,就應該是 2 所在的根節點 7 掛到 4 所在的根節點 8 下。以下圖所示:
改進後,基於 rank 優化的並查集代碼以下:
package tree.unionfind; /** * 基於rank優化的Union-Find * * @author 01 */ public class UnionFind4 implements UnionFind { /** * rank[i]表示以i爲根的集合所表示的樹的層數(高度) */ private final int[] rank; /** * parent[i]表示第i個元素所指向的父節點 */ private final int[] parent; public UnionFind4(int size) { rank = new int[size]; parent = new int[size]; // 初始化, 每個parent[i]指向本身, 表示每個元素本身自成一個集合 for (int i = 0; i < size; i++) { parent[i] = i; rank[i] = 1; } } @Override public int getSize() { return parent.length; } /** * 查找過程, 查找元素p所對應的集合編號 * O(h)複雜度, h爲樹的高度 */ private int find(int p) { if (p < 0 || p >= parent.length) { throw new IllegalArgumentException("p is out of bound."); } // 不斷去查詢本身的父親節點, 直到到達根節點 // 根節點的特色: parent[p] == p while (p != parent[p]) { p = parent[p]; } return p; } /** * 查看元素p和元素q是否所屬一個集合 * O(h)複雜度, h爲樹的高度 */ @Override public boolean isConnected(int p, int q) { return find(p) == find(q); } /** * 合併元素p和元素q所屬的集合 * O(h)複雜度, h爲樹的高度 */ @Override public void unionElements(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) { return; } // 根據兩個元素所在樹的rank不一樣判斷合併方向 // 將rank低的集合合併到rank高的集合上 if (rank[pRoot] < rank[qRoot]) { // 被合併的樹高度不會增長,不須要維護rank parent[pRoot] = qRoot; } else if (rank[qRoot] < rank[pRoot]) { parent[qRoot] = pRoot; } else { // 層數相同,向任意一方合併便可 parent[pRoot] = qRoot; // 而後須要維護一下rank的值,由於層數相同,被合併的樹必然層數會+1 rank[qRoot] += 1; } } }
基於 rank 的優化其實在通常的狀況下已經沒什麼問題了,並且也能獲得一個比較好的性能,但在 rank 的基礎上,咱們仍然還能夠再進一步的優化。這種優化方式叫:路徑壓縮。在下圖中,雖然樹的高度不一樣,但這幾個並查集都是等價的:
從上圖中,明顯能夠看出左邊的這棵樹性能最低,由於其樹的高度最高。所以,咱們就知道樹的高度是影響性能的一個主要緣由。然而即使是基於 rank 的優化也沒法避免數據量較大的狀況下致使樹的高度太高的問題,因此咱們就得使用路徑壓縮這種優化方式來解決這個問題。
那麼咱們要如何進行路徑壓縮呢?其實只須要在 find
方法中增長一句代碼便可:parent[p] = parent[parent[p]]
。咱們知道find
方法的主要邏輯是從指定的節點開始,一直循環往上找到它的根節點爲止。
而這句代碼的做用就是每次都將當前節點掛到其父節點的父節點上,這樣就實現了查找過程就是一個壓縮路徑的過程。例如,咱們要查找下圖中,4 這個節點的根節點:
此時將這個節點掛載到其父節點的父節點上,就造成了這個樣子:
而後再繼續這個循環,直到達到根節點,就完成了一次路徑壓縮:
具體的實現代碼以下:
package tree.unionfind; /** * 基於路徑壓縮優化的Union-Find * * @author 01 */ public class UnionFind5 implements UnionFind { /** * rank[i]表示以i爲根的集合所表示的樹的層數 * 在後續的代碼中, 咱們並不會維護rank的語意, 也就是rank的值在路徑壓縮的過程當中, 有可能再也不表示樹的層數值 * 這也是咱們的rank不叫height或者depth的緣由, 它只是做爲比較的一個標準 */ private final int[] rank; /** * parent[i]表示第i個元素所指向的父節點 */ private final int[] parent; public UnionFind5(int size) { rank = new int[size]; parent = new int[size]; // 初始化, 每個parent[i]指向本身, 表示每個元素本身自成一個集合 for (int i = 0; i < size; i++) { parent[i] = i; rank[i] = 1; } } @Override public int getSize() { return parent.length; } /** * 查找過程, 查找元素p所對應的集合編號 * O(h)複雜度, 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; } /** * 查看元素p和元素q是否所屬一個集合 * O(h)複雜度, h爲樹的高度 */ @Override public boolean isConnected(int p, int q) { return find(p) == find(q); } /** * 合併元素p和元素q所屬的集合 * O(h)複雜度, h爲樹的高度 */ @Override public void unionElements(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) { return; } // 根據兩個元素所在樹的rank不一樣判斷合併方向 // 將rank低的集合合併到rank高的集合上 if (rank[pRoot] < rank[qRoot]) { parent[pRoot] = qRoot; } else if (rank[qRoot] < rank[pRoot]) { parent[qRoot] = pRoot; } else { parent[pRoot] = qRoot; // 維護一下rank的值 rank[qRoot] += 1; } } }
看到以上代碼後,可能你會有一個疑問,爲何在壓縮路徑的過程當中不用更新 rank 呢?事實上,這正是咱們將這個變量叫作 rank 而不是叫諸如 depth 或者 height 的緣由。由於這個 rank 只是咱們作的一個標誌當前節點排名的一個數字。當咱們引入了路徑壓縮之後,維護這個深度的真實值會相對困難一些。
而實際上,這個 rank 的做用,只是在 union 的過程當中,比較兩個節點的深度。換句話說,咱們徹底能夠不知道每一個節點具體的深度,只要保證每兩個節點深度的大小關係能夠被 rank 正確表達便可。而這個 rank 確實能夠正確表達兩個節點之間深度的大小關係。
由於根據咱們的路徑壓縮的過程,rank 高的節點雖然被擡了上來(深度下降),可是不可能下降到比原先深度更小的節點還要小。因此,rank 足以勝任比較兩個節點的深度,進而選擇合適的節點進行 union 這個任務。也就是說,此時 rank 更像是一個權重值,而不是表示樹實際的深度。
在本小節所介紹的路徑壓縮算法,只能將一棵樹壓縮到高度爲 3。那麼有沒有辦法將一棵樹壓縮到高度只有 2 呢?如同下圖這樣:
答案是有的,咱們可使用遞歸的方式,將樹的高度壓縮爲 2 。但因爲是使用遞歸實現的,遞歸開銷比較大,因此其性能也不會比以前介紹的壓縮方式性能高,甚至還不如。具體實現代碼以下:
private int find(int p) { if (p < 0 || p >= parent.length) { throw new IllegalArgumentException("p is out of bound."); } // 遞歸實現路徑壓縮,將全部的節點直接壓縮到根節點上,也就是每次壓縮樹的高度都會變成2 if (p != parent[p]) { parent[p] = find(parent[p]); } return parent[p]; }
最後,咱們來編寫一個簡單的測試用例,對這幾種方式實現的並查集進行性能測試,對比一下不一樣實現方式的性能差距。代碼以下:
package tree.unionfind; import java.util.Random; public class UnionFindTests { private static double testUnionFind(UnionFind uf, int m) { int size = uf.getSize(); Random random = new Random(); long startTime = System.nanoTime(); for (int i = 0; i < m; i++) { int a = random.nextInt(size); int b = random.nextInt(size); uf.unionElements(a, b); } 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 = 1000000; int m = 1000000; UnionFind1 uf1 = new UnionFind1(size); System.out.println("UnionFind1 : " + testUnionFind(uf1, m) + " s"); UnionFind2 uf2 = new UnionFind2(size); System.out.println("UnionFind2 : " + testUnionFind(uf2, m) + " s"); UnionFind3 uf3 = new UnionFind3(size); System.out.println("UnionFind3 : " + testUnionFind(uf3, m) + " s"); UnionFind4 uf4 = new UnionFind4(size); System.out.println("UnionFind4 : " + testUnionFind(uf4, m) + " s"); UnionFind5 uf5 = new UnionFind5(size); System.out.println("UnionFind5 : " + testUnionFind(uf5, m) + " s"); } }
輸出結果以下:
UnionFind1 : 436.5402681 s UnionFind2 : 1337.1119902 s UnionFind3 : 0.0927705 s UnionFind4 : 0.0725342 s UnionFind5 : 0.0553162 s