並查集(Union Find)的基本實現
阿新 • • 發佈:2018-12-30
概念
並查集是一種樹形的資料結構,用來處理一些不交集的合併及查詢問題。主要有兩個操作:
- find:確定元素屬於哪一個子集。
- union:將兩個子集合併成同一個集合。
所以並查集能夠解決網路中節點的連通性問題。
基本實現
package com.yunche.datastructure; /** * @ClassName: UF * @Description: 並查集 * @author: yunche * @date: 2018/12/30 */ public class UF { /** * 此陣列的索引表示節點的編號,陣列儲存的是對應節點所在的集合編號 */ private int[] id; /** * 節點的個數 */ private int count; /** * 建構函式:構造一個指定大小的並查集 * * @param n 並查集節點的個數 */ public UF(int n) { id = new int[n]; count = n; for (int i = 0; i < n; i++) { id[i] = i; } } /** * 查詢指定節點的所屬的集合編號 * * @param p 節點編號 * @return 集合編號 */ public int find(int p) { if (p >= 0 && p < count) { return id[p]; } return -1; } /** * 將兩個節點所屬的集合並在一起,即將兩個節點連通 * * @param p 節點編號 * @param q 節點編號 */ public void union(int p, int q) { int pId = find(p); int qId = find(q); if (pId < 0 || qId < 0) { return; } if (qId == pId) { return; } for (int i = 0; i < count; i++) { if (id[i] == pId) { id[i] = qId; } } } /** * 判斷兩個節點是否連通 * @param p 節點編號 * @param q 節點編號 * @return */ public boolean isConnected(int p, int q) { return find(p) != -1 && find(p) == find(q); } /** * 測試用例 * @param args */ public static void main(String[] args) { UF uf= new UF(5); uf.union(0, 1); uf.union(0, 2); uf.union(6, 2); System.out.println(uf.isConnected(1, 2)); System.out.println(uf.isConnected(0, 3)); System.out.println(uf.isConnected(6, 2)); } }
對 union 進行 rank 優化
package com.yunche.datastructure; /** * @ClassName: UF2 * @Description: 針對union進行rank優化 * @author: yunche * @date: 2018/12/30 */ public class UF2 { /** * 此陣列的索引為節點的編號,陣列儲存的為對應節點的父節點的編號 */ private int[] parent; /** * 節點的個數 */ private int count; /** * 進一步優化union * 陣列的索引代表集合的根節點 * 陣列儲存的是每個根節點下的對應的樹層數 */ private int[] rank; /** * 建構函式:構造一個指定大小的並查集 * * @param n 並查集節點的個數 */ public UF2(int n) { parent = new int[n]; rank = new int[n]; count = n; for (int i = 0; i < n; i++) { parent[i] = i; rank[i] = 1; } } /** * 查詢指定節點的所屬的集合的根節點編號 * * @param p 節點編號 * @return 根節點編號 */ public int find(int p) { if (p >= 0 && p < count) { while (p != parent[p]) { p = parent[p]; } return p; } return -1; } /** * 將兩個節點所屬的集合並在一起,即將兩個節點連通 * 時間複雜度O(n) * @param p 節點編號 * @param q 節點編號 */ public void union(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot < 0 || qRoot < 0) { return; } if (qRoot == pRoot) { return; } //合併兩個集合 //重要優化避免構造樹的深度太深 if (rank[pRoot] < rank[qRoot]) { parent[pRoot] = qRoot; } else if (rank[pRoot] > rank[qRoot]){ parent[qRoot] = pRoot; } else { parent[pRoot] = qRoot; rank[qRoot] += 1; } } /** * 判斷兩個節點是否連通 * 時間複雜度O(1) * @param p 節點編號 * @param q 節點編號 * @return */ public boolean isConnected(int p, int q) { return find(p) != -1 && find(p) == find(q); } /** * 測試用例 * @param args */ public static void main(String[] args) { testUF2(1000000); } /** * 測試方法 * @param n 並差集的個數 */ public static void testUF2( int n ){ UF2 uf = new UF2(n); long startTime = System.currentTimeMillis(); // 進行n次操作, 每次隨機選擇兩個元素進行合併操作 for( int i = 0 ; i < n ; i ++ ){ int a = (int)(Math.random()*n); int b = (int)(Math.random()*n); uf.union(a,b); } // 再進行n次操作, 每次隨機選擇兩個元素, 查詢他們是否同屬一個集合 for(int i = 0 ; i < n ; i ++ ){ int a = (int)(Math.random()*n); int b = (int)(Math.random()*n); uf.isConnected(a,b); } long endTime = System.currentTimeMillis(); // 列印輸出對這2n個操作的耗時 System.out.println("UF2, " + 2*n + " ops, " + (endTime-startTime) + "ms"); } }
對 find 進行路徑壓縮
1、第一種方式
圖示
程式碼
package com.yunche.datastructure; /** * @ClassName: UF3 * @Description: 對find操作進行路徑壓縮,第一種路徑壓縮,過程如圖所示 * @author: yunche * @date: 2018/12/30 */ public class UF3 { /** * 此陣列的索引為節點的編號,陣列儲存的為對應節點的父節點編號 */ private int[] parent; /** * 節點的個數 */ private int count; /** * 進一步優化union * 陣列的索引代表集合的根節點 * 陣列儲存的是每個根節點下的對應的樹深度 * 在後續的程式碼中, 我們並不會維護rank的語意, 也就是rank的值在路徑壓縮的過程中, 有可能不再是樹的層數值 * 這也是我們的rank不叫height或者depth的原因, 它只是作為比較的一個標準 */ private int[] rank; /** * 建構函式:構造一個指定大小的並查集 * * @param n 並查集節點的個數 */ public UF3(int n) { parent = new int[n]; rank = new int[n]; count = n; for (int i = 0; i < n; i++) { parent[i] = i; rank[i] = 1; } } /** * 查詢指定節點的所屬的集合的根節點編號 * 路徑壓縮 * @param p 節點編號 * @return 根節點編號 */ public int find(int p) { if (p >= 0 && p < count) { while (p != parent[p]) { // 根據圖示,p的父節點應該指向p的父節點的父節點 parent[p] = parent[parent[p]]; //繼續從此時p的父節點開始迴圈 p = parent[p]; } return p; } return -1; } /** * 將兩個節點所屬的集合並在一起,即將兩個節點連通 * 時間複雜度O(n) * @param p 節點編號 * @param q 節點編號 */ public void union(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot < 0 || qRoot < 0) { return; } if (qRoot == pRoot) { return; } //合併兩個集合 //重要優化避免構造樹的深度太深 if (rank[pRoot] < rank[qRoot]) { parent[pRoot] = qRoot; } else if (rank[pRoot] > rank[qRoot]){ parent[qRoot] = pRoot; } else { parent[pRoot] = qRoot; rank[qRoot] += 1; } } /** * 判斷兩個節點是否連通 * 時間複雜度O(1) * @param p 節點編號 * @param q 節點編號 * @return */ public boolean isConnected(int p, int q) { return find(p) != -1 && find(p) == find(q); } /** * 測試用例 * @param args */ public static void main(String[] args) { UF2.testUF2(1000000); testUF3(1000000); }/*Output: UF2, 2000000 ops, 856ms UF3, 2000000 ops, 579ms */ /** * 測試方法 * @param n 並查集的個數 */ public static void testUF3( int n ){ UF3 uf = new UF3(n); long startTime = System.currentTimeMillis(); // 進行n次操作, 每次隨機選擇兩個元素進行合併操作 for( int i = 0 ; i < n ; i ++ ){ int a = (int)(Math.random()*n); int b = (int)(Math.random()*n); uf.union(a,b); } // 再進行n次操作, 每次隨機選擇兩個元素, 查詢他們是否同屬一個集合 for(int i = 0 ; i < n ; i ++ ){ int a = (int)(Math.random()*n); int b = (int)(Math.random()*n); uf.isConnected(a,b); } long endTime = System.currentTimeMillis(); // 列印輸出對這2n個操作的耗時 System.out.println("UF3, " + 2*n + " ops, " + (endTime-startTime) + "ms"); } }
2、第二種方式
在明白了第一種壓縮路徑的方法後,我們可能會疑惑:是否這就是最短的路徑了?我們仔細思考後會發現,這並不是最短的路徑,最短的路徑形成的樹應該只有2層,第一層為根節點,其餘所有節點都在第二層指向根節點,如下圖所示。要實現這種壓縮路徑的方式也簡單,使用遞迴即可。
圖示
程式碼
package com.yunche.datastructure;
/**
* @ClassName: UF4
* @Description: 對find操作進行路徑壓縮,第二種路徑的遞迴壓縮
* @author: yunche
* @date: 2018/12/30
*/
public class UF4 {
/**
* 此陣列的索引為節點的編號,陣列儲存的為對應節點的父節點編號
*/
private int[] parent;
/**
* 節點的個數
*/
private int count;
/**
* 進一步優化union
* 陣列的索引代表集合的根節點
* 陣列儲存的是每個根節點下的對應的樹層數
* 在後續的程式碼中, 我們並不會維護rank的語意, 也就是rank的值在路徑壓縮的過程中, 有可能不再是樹的層數值
* 這也是我們的rank不叫height或者depth的原因, 它只是作為比較的一個標準
*/
private int[] rank;
/**
* 建構函式:構造一個指定大小的並查集
*
* @param n 並查集節點的個數
*/
public UF4(int n) {
parent = new int[n];
rank = new int[n];
count = n;
for (int i = 0; i < n; i++) {
parent[i] = i;
rank[i] = 1;
}
}
/**
* 壓縮路徑遞迴實現 遞迴演算法
* @param p 節點編號
* @return 返回節點當前的根節點編號
*/
public int find(int p) {
if (p >= 0 && p < count) {
// 遞迴邊界
if (p == parent[p]) {
return p;
}
//想象此時有 3 層:分別是 0, 1, 2,最開始p = 2
// 那麼第一次執行到這個位置 parent[2] = findRecursive(1)
//第二次執行到這個位置 parent[1] = findRecursive(0)
//當再一次遞迴此時,到不到這一步,因為觸發遞迴邊界,返回0
//那麼,向上 parent[1] = 0
//再向上,parent[2] = parent[1] = 0
parent[p] = find(parent[p]);
return parent[p];
}
return -1;
}
/**
* 將兩個節點所屬的集合並在一起,即將兩個節點連通
* 時間複雜度O(n)
* @param p 節點編號
* @param q 節點編號
*/
public void union(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if (pRoot < 0 || qRoot < 0) {
return;
}
if (qRoot == pRoot) {
return;
}
//合併兩個集合
//重要優化避免構造樹的深度太深
if (rank[pRoot] < rank[qRoot]) {
parent[pRoot] = qRoot;
} else if (rank[pRoot] > rank[qRoot]){
parent[qRoot] = pRoot;
} else {
parent[pRoot] = qRoot;
rank[qRoot] += 1;
}
}
/**
* 判斷兩個節點是否連通
* 時間複雜度O(1)
* @param p 節點編號
* @param q 節點編號
* @return
*/
public boolean isConnected(int p, int q) {
return find(p) != -1 && find(p) == find(q);
}
/**
* 測試用例
* @param args
*/
public static void main(String[] args) {
UF2.testUF2(1000000);
UF3.testUF3(1000000);
UF4.testUF4(1000000);
}/*Output:
UF2, 2000000 ops, 918ms
UF3, 2000000 ops, 586ms
UF4, 2000000 ops, 631ms
*/
/**
* 測試方法 -
* @param n 並查集的個數
*/
public static void testUF4( int n ){
UF4 uf = new UF4(n);
long startTime = System.currentTimeMillis();
// 進行n次操作, 每次隨機選擇兩個元素進行合併操作
for( int i = 0 ; i < n ; i ++ ){
int a = (int)(Math.random()*n);
int b = (int)(Math.random()*n);
uf.union(a,b);
}
// 再進行n次操作, 每次隨機選擇兩個元素, 查詢他們是否同屬一個集合
for(int i = 0 ; i < n ; i ++ ){
int a = (int)(Math.random()*n);
int b = (int)(Math.random()*n);
uf.isConnected(a,b);
}
long endTime = System.currentTimeMillis();
// 列印輸出對這2n個操作的耗時
System.out.println("UF4, " + 2*n + " ops, " + (endTime-startTime) + "ms");
}
}
3、 兩種壓縮路徑的比較
從理論上說,第二種壓縮路徑演算法應該比第一種快,但上面的測試方法的結果卻是第一種壓縮路徑演算法更快,這是因為第二種壓縮演算法有遞迴上的開銷,結果上也表面這兩種演算法效率是差別不大的,所以採取哪種方法要實際測試下。