前言
並查集是一種非常有用且高效的數據結構,千萬不要被這個極具專業性的名字嚇到了,它的算法思想和代碼實現都非常簡單,不需要花太大力氣就可以輕鬆掌握。下面就通過畫圖等方式爲大家介紹一下這種神奇的數據結構。
一、 圖解並查集
並查集有兩個英文名:1、Disjoint Set,2、Union Find。它的作用就是把一個數據集分成若干個子集,每個子集內部數據可以互聯互通,而子集之間則不具有連通性。並查集的底層結構類似於堆(不熟悉堆的同學趕緊去複習一下堆排序,面試頻率很高哦),也是用數組描述一種樹結構,但不同的是,堆是一棵獨立的二叉樹,並查集的樹是多叉樹,而且可能不止一棵樹(也就是森林)。在並查集中,我們把相互獨立的數據集稱爲連通分量,連通分量在邏輯上可以形象地描述爲一棵樹,每棵樹都有一個根節點,其他的元素都會直接或間接指向根節點。
比如下圖這個並查集,我們維護一個parent數組,每個元素初始化爲對應的數組下標,代表自己是獨立的一棵樹,且是樹根。以第一棵樹爲例,在後續數據處理過程中,我們把與所有與"2"同屬一個連通分量的元素都連到"2"上,並把數組對應下標的元素賦值爲2,其中"5"先連接到了"1"上,"1"又連接到了"2"上。最後,數組每個元素都代表其指向的父節點。
並查集底層結構
知道了並查集的底層結構,那我們該如何去構建這個結構並進行查找操作呢?並查集有兩個最重要的方法:union 和 find,這裏先給出UnionFind 最完善的 API 框架僞代碼:
public class UnionFind {
public UnionFind(int N) {} // 構造方法
public int find(int x) {} //查找某個元素的根節點
public void union(int x, int y) {} // 爲x和y建立聯繫
public boolean connected(int x, int y) {} //判斷x和y是否相連(在同一棵樹也就是連通分量中)
public int count() {} // 返回連通分量的個數,也就是多少棵樹
}
1. find 方法實現
find方法的目的是尋找某個元素所在樹的根節點,代碼如下:
public int find(int x) {
while (x != parent[x]) {
x = parent[x];
}
return x;
}
解釋一下這短短几行代碼做了什麼,前面的介紹我們已經知道,根節點元素的數組值就是自身的下標,也就是parent[x] = x
; 那麼當數組元素值不等於其下標時,說明它不是根節點,就一直循環找下去,直到找到根節點。 如下圖是尋找5的根節點的過程:
尋找根節點的過程
2. union 方法實現
union 方法顧名思義就是把兩個元素聯繫起來,具體的做法先找到各自的根節點,再把其中一個元素的根節點連接到另一個元素的根節點上,代碼如下:
public void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
// 根節點相同則無需操作
if (rootX == rootY) {
return;
}
parent[rootX] = rootY;
}
下圖是對元素 4 和 5 進行 union 的操作示意圖:
union 操作
3. 並查集優化:路徑壓縮和按秩合併
從上圖中我們可以看出一個問題,如果 union 操作直接將兩棵樹合併,那麼多次 union 之後,樹的高度可能會很高,那麼尋找一個節點的根節點的路徑就會很長,導致查找效率降低,那該如何對其進行優化呢?主要有兩種優化方式,那就是路徑壓縮和按秩合併,路徑壓縮是在 find 方法裏進行,而按秩合併則是在 union 方法中進行,二者選其一即可,前者代碼更簡潔。
3.1 路徑壓縮
路徑壓縮又有兩種方式:隔代壓縮和徹底壓縮
- 「隔代壓縮」性能比較高,雖然壓縮不完全,不過多次執行「隔代壓縮」也能達到比較好的效果,只需要在原 find 方法中加上一句
parent[x] = parent[parent[x]];
這句代碼的意思是把路徑上的每個節點的父節點指向其祖父節點。 - 「徹底壓縮」需要藉助系統棧,使用遞歸的寫法 。或者使用迭代寫法,先找到當前結點的根結點,然後把沿途上所有的節點都指向根節點,需要遍歷兩次。徹底壓縮需要消耗更多的性能,但是壓縮的更徹底,可以提高查詢效率。
隔代壓縮與徹底壓縮
3.2 按秩合併
這個“秩”一般是指樹的高度,也有地方解釋爲樹節點個數,我們這裏取前者。具體實現就是新增一個ranks數組記錄以各個元素爲根節點的樹的高度,在做合併操作時,把高度較小的樹的根節點連接到高度較大的樹的根節點上。如下圖,在未優化前,合併後的樹高度增加爲4,而按秩合併後的樹高仍然爲3。這裏要注意的是,如果兩棵樹高度相同,那麼兩者均可作爲根節點,則合併後的新樹高度需要加一。
> 按秩合併
代碼如下:
// 如果代碼片段看不懂,可以結合後面的完整版代碼去理解
public void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
// 根節點相同則不需要操作
if (rootX == rootY) {
return;
}
// 比較樹高,高度小的樹合併到另一顆樹上,相等的話兩者均可作爲根節點,並把高度加一
if (ranks[rootX] > ranks[rootY]) {
parent[rootY] = rootX;
} else if (ranks[rootX] < ranks[rootY]) {
parent[rootX] = rootY;
} else {
parent[rootY] = rootX;
ranks[rootX]++;
}
count--;
}
4. 完整代碼
4.1 路徑壓縮版本
class UnionFind {
private int[] parent;
// count用來記錄連通分量的個數
private int count;
public UnionFind(int n) {
// count初始化爲n,也就是最開始有n個連通分量(n棵樹)
count = n;
// parent數組各個元素初始化爲其自身下標,代表自己是一棵樹
parent = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
/**查找根節點*/
public int find(int x) {
while (x != parent[x]) {
// 路徑壓縮(隔代壓縮)
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
/**合併操作*/
public void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
// 根節點相同則不需要操作
if (rootX == rootY) {
return;
}
parent[rootX] = rootY;
// 合併之後連通分量(樹)個數減一
count--;
}
/**判斷x和y所在的連通分量是否相連*/
public boolean isConnected(int x, int y) {
return find(x) == find(y);
}
/**返回連通分量個數*/
public int count() {
return count;
}
}
4.2 按秩合併版本
class UnionFind {
private int[] parent;
//新加一個ranks數組,記錄樹的高度
private int[] ranks;
// count記錄連通分量的個數
private int count;
public UnionFind(int n) {
count = n;
parent = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
}
// 高度都初始化爲1
ranks = new int[n];
for (int i = 0; i < n; i++) {
ranks[i] = 1;
}
}
/**按秩合併版本的 find 方法不需要做優化*/
public int find(int x) {
while (x != parent[x]) {
x = parent[x];
}
return x;
}
/**按秩合併*/
public void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX == rootY) {
return;
}
if (ranks[rootX] > ranks[rootY]) {
parent[rootY] = rootX;
} else if (ranks[rootX] < ranks[rootY]) {
parent[rootX] = rootY;
} else {
parent[rootY] = rootX;
ranks[rootX]++;
}
count--;
}
public boolean isConnected(int x, int y) {
return find(x) == find(y);
}
public int count() {
return count;
}
}
二、並查集的應用
瞭解完並查集,最好可以在實際場景中實踐一下,如果沒有合適的生產環境的話,那就來趁熱打鐵做幾道Leetcode算法題來檢驗一下學習成果吧。這裏舉三個比較典型且比較簡單的題目,每個題目都代表了一個並查集的應用場景。
1、Leetcode原題1319 聯通網絡的操作次數
比如一個在一整個計算機網絡中,有的計算機可以和其他一部分計算機通過線纜連接了起來,但是整個集羣並不是互聯互通的,那麼我們就需要計算操作多少次可以把整個集羣連接起來。
如果確定了一道題需要用並查集來求解,那我們上來二話就說先把 UnionFind 類寫出來,然後再去處理題目邏輯,這樣會簡單很多。
class Solution {
public int makeConnected(int n, int[][] connections) {
UnionFind uf = new UnionFind(n);
// 數組長度就是現有線纜數量
int cables = connections.length;
// 如果線纜數量小於計算機數量減一,那麼肯定不可能把所有計算機都連接起來
if (cables < n - 1) return -1;
// 遍歷數組,union 所有計算機
for (int[] con : connections) {
uf.union(con[0], con[1]);
}
//need是連通塊的數量,也就是計算機集羣的數量,把他們連起來所需線纜數量等於need-1
int need = uf.count();
return need - 1;
}
}
class UnionFind {...} // 這裏是 UnionFind 的完整代碼,選擇以上任一版本均可
2、 Leetcode原題547 朋友圈
典型的就是朋友圈,如果一個朋友圈裏的人和圈外的人沒有任何關係,那麼如何去統計一羣人中的朋友圈數量;
class Solution {
public int findCircleNum(int[][] M) {
int n = M.length;
UnionFind uf = new UnionFind(n);
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (M[i][j] == 1) {
uf.union(i, j);
}
}
}
// 直接返回連通分量個數即爲朋友圈個數
return uf.count();
}
}
class UnionFind {...} // 這裏是 UnionFind 的完整代碼,選擇之前任一版本均可
3、Leetcode原題990 等式方程的可滿足性
在編程中,有時候我們需要聲明一些等價的變量名(也就是多個引用指向一個對象),在一系列聲明之後,系統需要判斷這些變量名是否等價。在下面這道題中,可以把方程等號兩端的變量看做變量名。
class Solution {
public boolean equationsPossible(String[] equations) {
UnionFind uf = new UnionFind(26);
// 先遍歷一遍將"=="連接的變量進行 union 操作
for (String s : equations) {
char[] cs = s.toCharArray();
if (cs[1] == '=') {
uf.union(cs[0] - 'a', cs[3] - 'a');
}
}
// 再遍歷一遍,如果"!="連接的變量在同一連通分量,則出現矛盾,直接返回false
for (String s : equations) {
char[] cs = s.toCharArray();
if (cs[1] == '!' && uf.isConnected(cs[0] - 'a', cs[3] - 'a')) {
return false;
}
}
return true;
}
}
class UnionFind {...} // 這裏是 UnionFind 的完整代碼,選擇之前任一版本均可
可以看出,在提供了完善的 UnionFind API 後,題目的處理變得異常簡單。 UnionFind 的使用是有固定套路的,那就是先把整個數據集的數據 union 一遍,然後再根據實際需求去判斷兩個數據是否連通或統計連通分量的個數。小夥伴們如果想嘗試更多並查集相關算法題,可以在 Leetcode 查看並查集的標籤分類。