玩轉資料結構(18)-- 並查集
並查集(Union Find)
一、概述
由孩子節點指向父親節點的樹結構,解決連線問題,如圖來判斷兩個點之間是否是連線的
並查集:可以快速判斷網路中節點間的連線狀態【網路:抽象概念,使用者之間形成的網路】可以高效回答連線問題的資料結構
對於一組資料,主要支援兩個動作:
1.uoion(p,q) --並,傳入資料 p 和 q,在並查集內部將這兩個資料,以及他們所在的集合合併起來
2.isConnected(p,q) ---查詢 ,對於給定的兩個資料 p 和 q 是否屬於同一個集合
二、並查集的基本資料表示
第一版Union-Find本質就是一個數組
對 10 個元素 (0-9)分成 2 個集合,其中 (0-4) 這 5 個元素為 集合0 ;(5-9) 這 5 個元素為 集合1
或者
在 isConnected(p,q) ---查詢 中,只需要檢視 p 和 q 所對應的 Id 值是否相等即可,將檢視 p 和 q 背後的 Id 是誰的過程抽象為函式:find(p) == find(q) ,這種方式為 Quick Find,其時間複雜度為 O(1)
但 Quick Find 中的 uoion(p,q) --並 時間複雜度為 O(n);合併過程需要遍歷一遍所有元素, 將兩個元素的所屬集合編號合併
程式碼實現:
UF.java
public interface UF { //定義介面 int getSize(); //元素數量 boolean isConnected(int p, int q); void unionElements(int p, int q); }
UnionFind1.java
// 我們的第一版Union-Find public class UnionFind1 implements UF { private int[] id; // 我們的第一版Union-Find本質就是一個數組 public UnionFind1(int size) { id = new int[size]; // 初始化, 每一個id[i]指向自己, 沒有合併的元素 for (int i = 0; i < size; i++) id[i] = i; } @Override public int getSize(){ return id.length; } // 查詢元素p所對應的集合編號 // O(1)複雜度 private int find(int p) { if(p < 0 || p >= id.length) throw new IllegalArgumentException("p is out of bound."); return id[p]; } // 檢視元素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 < id.length; i++) if (id[i] == pID) id[i] = qID; } }
第二版Union-Find, 使用一個數組構建一棵指向父節點的樹
將每一個元素,看做是一個節點;其中,節點 3 指向其父節點 2,2 為根節點,其指標指向自己即可;
節點1 和 節點3 合併,則讓 節點1 所對應的指標指向 節點3 所在樹的根節點,即 節點2;
如果讓 節點7 和 節點2 合併 ,即讓 節點7 所在的 根節點5 ,指向 節點2 即可;
如果讓 節點7 和 節點3 合併 ,即讓 節點7 所在的 根節點5 ,指向 節點3 所在樹的 根節點2 即可;
Quick Union:並查集不是一個樹結構,而是 森林結構 ,裡面存在很多樹 ,在初始親情況下有 10 棵樹,每棵樹只有一個節點;
如果執行 union 4,3 操作
再執行 union 3,8
再執行 union 6,5
再執行 union 9,4[讓節點 9 指向 節點4 所在的根節點]
在Quick Union 中,查操作 與 並操作 的時間複雜度均為 O(h) ,h為樹的高度
程式碼實現:
UF.java
public interface UF { //定義介面
int getSize(); //元素數量
boolean isConnected(int p, int q);
void unionElements(int p, int q);
}
UnionFind2.java
// 我們的第二版Union-Find
public class UnionFind2 implements UF {
// 我們的第二版Union-Find, 使用一個數組構建一棵指向父節點的樹
// parent[i]表示第一個元素所指向的父節點
private 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){
int pRoot = find(p);
int qRoot = find(q);
if( pRoot == qRoot )
return;
parent[pRoot] = qRoot;
}
}
三、優化
基於size的優化
並查集的實現由於對真正合並的元素不做形狀上的判斷,這個合併的過程會不斷增加樹的高度,甚至演化為 連結串列;
解決辦法:考慮(size)當前這棵樹整體有多少個節點,例如 union 4,9,正常操作如下圖
這時的高度為 4,但完全可以讓 9 指向 4 所在的根節點 8 ,即 9 指向 8;高度變為 3
程式碼實現:UnionFind3.java
// 我們的第三版Union-Find
public class UnionFind3 implements UF{
private int[] parent; // parent[i]表示第一個元素所指向的父節點
private int[] sz; // sz[i]表示以i為根的集合中元素個數【新增程式碼】
// 建構函式
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{ // sz[qRoot] <= sz[pRoot]
parent[qRoot] = pRoot;
sz[pRoot] += sz[qRoot];
}
}
}
檢測時間複雜度:Main.java
import java.util.Random;
public class Main {
private static double testUF(UF 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) {
// UnionFind1 慢於 UnionFind2
// int size = 100000;
// int m = 10000;
// UnionFind2 慢於 UnionFind1, 但UnionFind3最快
int size = 100000;
int m = 100000;
UnionFind1 uf1 = new UnionFind1(size);
System.out.println("UnionFind1 : " + testUF(uf1, m) + " s");
UnionFind2 uf2 = new UnionFind2(size);
System.out.println("UnionFind2 : " + testUF(uf2, m) + " s");
UnionFind3 uf3 = new UnionFind3(size);
System.out.println("UnionFind3 : " + testUF(uf3, m) + " s");
}
}
輸出:如此優化,效能得到極大提升
基於rank的優化【樹的高度】
執行 union 4,2 ,以 size 優化方式執行,高度變高了
更合理的方式: 在每一個節點上記錄以這個節點為根的對應的樹,其最大深度為多少,在真正合並的時候,應該使用深度比較低的那棵樹向深度比較高的樹進行合併;整體更加合理;稱為 基於rank 的優化,k[i] 表示根節點為 i 的樹的高度
程式碼實現:
UnionFind4.java
// 我們的第四版Union-Find
public class UnionFind4 implements UF {
private int[] rank; // rank[i]表示以i為根的集合所表示的樹的層數
private int[] parent; // parent[i]表示第i個元素所指向的父節點
// 建構函式
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])
parent[pRoot] = qRoot;
else if(rank[qRoot] < rank[pRoot])
parent[qRoot] = pRoot;
else{ // rank[pRoot] == rank[qRoot]
parent[pRoot] = qRoot;
rank[qRoot] += 1; // 此時, 我維護rank的值
}
}
}
測試時間複雜度:Main.java
import java.util.Random;
public class Main {
private static double testUF(UF 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 = 10000000;
int m = 10000000;
// UnionFind1 uf1 = new UnionFind1(size);
// System.out.println("UnionFind1 : " + testUF(uf1, m) + " s");
//
// UnionFind2 uf2 = new UnionFind2(size);
// System.out.println("UnionFind2 : " + testUF(uf2, m) + " s");
UnionFind3 uf3 = new UnionFind3(size);
System.out.println("UnionFind3 : " + testUF(uf3, m) + " s");
UnionFind4 uf4 = new UnionFind4(size);
System.out.println("UnionFind4 : " + testUF(uf4, m) + " s");
}
}
輸出:
路徑壓縮(Path Compression)
由上述幾種優化方式,知
查詢節點:左側的高度最大,其執行速度最慢;下側的高度最小,其執行速度最快;故將 高度大的樹 壓縮成為 高度小的樹 稱為路徑壓縮;
對並查集來說,每一個節點其子樹是沒有限制的,故理想情況下,希望樹的形態如下側那樣(根節點在第一層,其餘節點均在第二層),但很難實現嗎,通常只要追求高度減小即可提高執行速率;
壓縮過程:find 4 【在查詢過程中,壓縮路徑,使高度變小】
1.
、
2.
3.
程式碼實現:
UnionFind5.java
// 我們的第五版Union-Find
public class UnionFind5 implements UF {
// rank[i]表示以i為根的集合所表示的樹的層數
// 在後續的程式碼中, 我們並不會維護rank的語意, 也就是rank的值在路徑壓縮的過程中, 有可能不在是樹的層數值
// 這也是我們的rank不叫height或者depth的原因, 他只是作為比較的一個標準
private int[] rank;
private int[] parent; // parent[i]表示第i個元素所指向的父節點
// 建構函式
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{ // rank[pRoot] == rank[qRoot]
parent[pRoot] = qRoot;
rank[qRoot] += 1; // 此時, 我維護rank的值
}
}
}
測試:Main.java
import java.util.Random;
public class Main {
private static double testUF(UF 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();
double time = (endTime - startTime) / 1000000000.0;
return time;
}
public static void main(String[] args) {
int size = 10000000;
int m = 10000000;
// UnionFind1 uf1 = new UnionFind1(size);
// System.out.println("UnionFind1 : " + testUF(uf1, m) + " s");
//
// UnionFind2 uf2 = new UnionFind2(size);
// System.out.println("UnionFind2 : " + testUF(uf2, m) + " s");
UnionFind3 uf3 = new UnionFind3(size);
System.out.println("UnionFind3 : " + testUF(uf3, m) + " s");
UnionFind4 uf4 = new UnionFind4(size);
System.out.println("UnionFind4 : " + testUF(uf4, m) + " s");
UnionFind5 uf5 = new UnionFind5(size);
System.out.println("UnionFind5 : " + testUF(uf5, m) + " s");
}
}
輸出:、
理想狀況:通過 遞迴 實現
程式碼實現:UnionFind6.java
// 我們的第六版Union-Find
public class UnionFind6 implements UF {
// rank[i]表示以i為根的集合所表示的樹的層數
// 在後續的程式碼中, 我們並不會維護rank的語意, 也就是rank的值在路徑壓縮的過程中, 有可能不在是樹的層數值
// 這也是我們的rank不叫height或者depth的原因, 他只是作為比較的一個標準
private int[] rank;
private int[] parent; // parent[i]表示第i個元素所指向的父節點
// 建構函式
public UnionFind6(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.");
// path compression 2, 遞迴演算法
if(p != parent[p])
parent[p] = find(parent[p]);
return parent[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{ // rank[pRoot] == rank[qRoot]
parent[pRoot] = qRoot;
rank[qRoot] += 1; // 此時, 我維護rank的值
}
}
}
測試:Main.java
import java.util.Random;
public class Main {
private static double testUF(UF 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();
double time = (endTime - startTime) / 1000000000.0;
return time;
}
public static void main(String[] args) {
int size = 10000000;
int m = 10000000;
// UnionFind1 uf1 = new UnionFind1(size);
// System.out.println("UnionFind1 : " + testUF(uf1, m) + " s");
//
// UnionFind2 uf2 = new UnionFind2(size);
// System.out.println("UnionFind2 : " + testUF(uf2, m) + " s");
UnionFind3 uf3 = new UnionFind3(size);
System.out.println("UnionFind3 : " + testUF(uf3, m) + " s");
UnionFind4 uf4 = new UnionFind4(size);
System.out.println("UnionFind4 : " + testUF(uf4, m) + " s");
UnionFind5 uf5 = new UnionFind5(size);
System.out.println("UnionFind5 : " + testUF(uf5, m) + " s");
UnionFind6 uf6 = new UnionFind6(size);
System.out.println("UnionFind6 : " + testUF(uf6, m) + " s");
}
}
輸出:遞迴會產生一定的開銷
補充:在方式 5 中也可以變為理想狀態下的情況【不是通過 遞迴 ,通過 迴圈遍歷 來實現】
時間複雜度:O(h) --- 嚴格意思上:O(log *n)