Union Find (Disjoint Set)
Union Find(也稱作 Disjoint Set,中文常譯為「互斥集合」或「並查集」)是一種專門用來管理 「一群元素被分成好幾堆,互不相交的集合」 的資料結構。
想像一場派對,一開始每個人都自己一夥。每當發現兩個人是朋友,就把他們的圈子合併起來。
Union Find 要解決的核心問題就是:「任意問兩個人,能不能快速知道他們是不是同一個朋友圈的?」
它主要提供兩個操作:
- Find:查詢某個元素屬於「哪一個集合」(通常是回傳該集合的代表元素,稱為 Root 或 Representative)。
- Union:把兩個集合合併成一個。
核心結構:用陣列記錄「誰是我的老大」
Union Find 的底層通常只需要一個陣列 parent,parent[i] 記錄「元素 i 的上一層是誰」。如果 parent[i] === i,代表 i 自己就是這個集合的 Root(老大)。
一開始,每個元素都自己獨立成一個集合,所以 parent[i] = i。
初始狀態(5 個元素,各自獨立):
index: 0 1 2 3 4
parent: 0 1 2 3 4
實作
一個「陽春版」的 Find 和 Union 其實很簡單:
- Python
- JavaScript
- Java
class UnionFind:
def __init__(self, size):
self.parent = list(range(size)) # 一開始每個人都是自己的老大
def find(self, x):
# 如果 x 不是自己的老大,就繼續往上找,直到找到 Root 為止
while self.parent[x] != x:
x = self.parent[x]
return x
def union(self, x, y):
root_x = self.find(x)
root_y = self.find(y)
if root_x == root_y:
return # 本來就在同一國,不用合併
self.parent[root_x] = root_y # 讓其中一個 Root 認另一個 Root 當老大
class UnionFind {
constructor(size) {
this.parent = Array.from({ length: size }, (_, i) => i); // 一開始每個人都是自己的老大
}
find(x) {
// 如果 x 不是自己的老大,就繼續往上找,直到找到 Root 為止
while (this.parent[x] !== x) {
x = this.parent[x];
}
return x;
}
union(x, y) {
const rootX = this.find(x);
const rootY = this.find(y);
if (rootX === rootY) return; // 本來就在同一國,不用合併
this.parent[rootX] = rootY; // 讓其中一個 Root 認另一個 Root 當老大
}
}
import java.util.stream.IntStream;
class UnionFind {
int[] parent;
UnionFind(int size) {
this.parent = IntStream.range(0, size).toArray(); // 一開始每個人都是自己的老大
}
int find(int x) {
// 如果 x 不是自己的老大,就繼續往上找,直到找到 Root 為止
while (this.parent[x] != x) {
x = this.parent[x];
}
return x;
}
void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX == rootY) return; // 本來就在同一國,不用合併
this.parent[rootX] = rootY; // 讓其中一個 Root 認另一個 Root 當老大
}
}
這個陽春版本有個問題:如果一直把新元素接在同一條鏈的尾巴,鏈會越拉越長,find 就會退化成 O(n)。因此實務上一定會搭配兩個優化技巧:
優化一:Path Compression(路徑壓縮)
在 find 的過程中,順便把沿路經過的每個節點,都直接接到 Root 底下,讓下一次查詢瞬間到位。
- Python
- JavaScript
- Java
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x]) # 遞迴往上找,順便把沿路的節點都接到 Root
return self.parent[x]
find(x) {
if (this.parent[x] !== x) {
this.parent[x] = this.find(this.parent[x]); // 遞迴往上找,順便把沿路的節點都接到 Root
}
return this.parent[x];
}
int find(int x) {
if (this.parent[x] != x) {
this.parent[x] = find(this.parent[x]); // 遞迴往上找,順便把沿路的節點都接到 Root
}
return this.parent[x];
}
優化二:Union by Rank / Size(依大小合併)
合併時,永遠讓「規模較小的樹」接到「規模較大的樹」底下,避免樹越長越高。
- Python
- JavaScript
- Java
class UnionFind:
def __init__(self, size):
self.parent = list(range(size))
self.rank = [1] * size # 記錄每個 Root 底下大概的樹高(或元素數量)
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x]) # Path Compression
return self.parent[x]
def union(self, x, y):
root_x = self.find(x)
root_y = self.find(y)
if root_x == root_y:
return False # 已經是同一國,合併失敗(可用來偵測環)
# Union by Rank:矮的樹接到高的樹下面
if self.rank[root_x] < self.rank[root_y]:
self.parent[root_x] = root_y
elif self.rank[root_x] > self.rank[root_y]:
self.parent[root_y] = root_x
else:
self.parent[root_y] = root_x
self.rank[root_x] += 1
return True
class UnionFind {
constructor(size) {
this.parent = Array.from({ length: size }, (_, i) => i);
this.rank = new Array(size).fill(1); // 記錄每個 Root 底下大概的樹高(或元素數量)
}
find(x) {
if (this.parent[x] !== x) {
this.parent[x] = this.find(this.parent[x]); // Path Compression
}
return this.parent[x];
}
union(x, y) {
const rootX = this.find(x);
const rootY = this.find(y);
if (rootX === rootY) return false; // 已經是同一國,合併失敗(可用來偵測環)
// Union by Rank:矮的樹接到高的樹下面
if (this.rank[rootX] < this.rank[rootY]) {
this.parent[rootX] = rootY;
} else if (this.rank[rootX] > this.rank[rootY]) {
this.parent[rootY] = rootX;
} else {
this.parent[rootY] = rootX;
this.rank[rootX]++;
}
return true;
}
}
import java.util.Arrays;
import java.util.stream.IntStream;
class UnionFind {
int[] parent;
int[] rank;
UnionFind(int size) {
this.parent = IntStream.range(0, size).toArray();
this.rank = new int[size];
Arrays.fill(this.rank, 1); // 記錄每個 Root 底下大概的樹高(或元素數量)
}
int find(int x) {
if (this.parent[x] != x) {
this.parent[x] = find(this.parent[x]); // Path Compression
}
return this.parent[x];
}
boolean union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX == rootY) return false; // 已經是同一國,合併失敗(可用來偵測環)
// Union by Rank:矮的樹接到高的樹下面
if (this.rank[rootX] < this.rank[rootY]) {
this.parent[rootX] = rootY;
} else if (this.rank[rootX] > this.rank[rootY]) {
this.parent[rootY] = rootX;
} else {
this.parent[rootY] = rootX;
this.rank[rootX]++;
}
return true;
}
}
逐步拆解:以 6 個元素為例
依序執行 union(0, 1)、union(1, 2)、union(3, 4)、最後查詢 find(2) 是否等於 find(0):
| 操作 | parent 陣列變化 | 說明 |
|---|---|---|
| 初始化 | [0, 1, 2, 3, 4, 5] | 6 個元素各自獨立 |
union(0, 1) | [0, 0, 2, 3, 4, 5] | 兩者 rank 相同,1 的 Root 接到 0 底下,0 的 rank 變成 2 |
union(1, 2) | [0, 0, 0, 3, 4, 5] | find(1) 沿路壓縮後是 0(rank 2),find(2) 是 2(rank 1),rank 較小的 2 接到 0 底下 |
union(3, 4) | [0, 0, 0, 3, 3, 5] | 3、4 合併,邏輯同上,4 接到 3 底下 |
find(0) | - | 0 本身就是 Root,直接回傳 0 |
find(2) | - | 2 → 0(經過 Path Compression 直接指向 Root),Root 也是 0 |
因為 find(0) 和 find(2) 都是 0,可以立刻判斷 0 和 2 在同一個集合裡,完全不需要真的去走訪整個 Graph 找路徑。
複雜度
同時使用 Path Compression 與 Union by Rank 優化後:
| 操作 | 時間複雜度 | 說明 |
|---|---|---|
| Find | 近似 O(1)(嚴謹來說是 O(α(n))) | α 是反阿克曼函數(Inverse Ackermann Function),成長極慢,在正常資料量下幾乎可以當作常數 |
| Union | 近似 O(1)(同上) | Union 內部呼叫了兩次 Find |
不需要死記「反阿克曼函數」這個名詞,只要記得:同時做了 Path Compression 和 Union by Rank 之後,Union Find 的操作幾乎快到可以當成 O(1),這是它最大的賣點。
適用情況
- 判斷兩個節點是否連通:例如社群網路中判斷兩人是否在同一個朋友圈、網路中判斷兩台機器是否能互相連線。
- 判斷無向圖中是否有環(Cycle Detection):依序處理每一條邊,如果要合併的兩個節點「早就已經是同一國」(
union回傳false),代表這條邊會形成環。 - 計算連通分量數量:例如 Leetcode: Number of Islands、社群網路中「總共有幾個互不相連的朋友圈」,也可以用 Graph Traversal 的 BFS/DFS 解決,但資料會動態一直新增連線時,Union Find 通常更方便。
- 最小生成樹(Minimum Spanning Tree):Kruskal's Algorithm 會依序嘗試加入權重最小的邊,並用 Union Find 判斷加入這條邊會不會形成環。
常見誤區
- 忘記做 Path Compression:沒有壓縮路徑的陽春版本,在最壞情況下
find會退化成 O(n),一定要記得在find的過程中把沿路節點都接到 Root。 - 只用來判斷「有向圖」的環:Union Find 適合處理無向圖的連通性與環偵測。有向圖的環偵測,通常需要搭配 DFS(判斷是否走回目前遞迴路徑上的節點),或參考 Topological Sort 排序結果是否完整。
- 誤以為 Union Find 能告訴你「路徑長什麼樣子」:Union Find 只能回答「兩個元素是否連通」,沒辦法像 Graph Traversal 或 Dijkstra's Algorithm 一樣還原出實際走過的路徑。