# 併查集 Disjoint Set (Union-Find)
我們常用的資料結構無非就幾種操作: 插入、查詢、合併,差別只在於需要維護的對象有誰
併查集又稱為不相交集合、互斥集,用於維護一堆集合,是個非常有效率的工具。在檢查[連通分量 (連通單元/成分/塊)](https://hackmd.io/@ShanC/connected_component),亦或是尋找一張圖的最小生成樹都是一個重要的存在
在此章節,要維護的東西是一個集合,因此需要先說明集合的性質
## 集合 Sets
**這裡只討論併查集用的到的集合概念**
- 一個集合 $S$ 中,會由許多元素(element) $x$ 組成
- 若 $x$ 是 $S$ 的元素,則可以寫作 $x \in S$
- 可用大括號表示集合: $S=\{1,2,3\}, G=\{4,5,6\}$
- 兩個集合可以使用聯集 (Union) 來合併: $S\cup G=\{1,2,3,4,5,6\}$
- 集合沒有順序
- 同一元素就算合併多次也只會寫一次: $\{1,2,3,1\}=\{1,2,3\}$
- 一個集合的元素個數稱為基數 (Cardinal number),寫作 $|S|$
- $\emptyset$ 為空集合的符號,若 $S=\emptyset$,則 $|S|=0$
## 併查集實作
### 簡介
此資料結構共有三種操作 (operation) : 初始化、查詢所在集合、合併
某種程度上,併查集也算一種樹狀資料結構
### 需要維護的東西
此資料結構需要維護一個陣列 $f[i]$ 用於紀錄 $i$ 的父親是誰
我們可以用樹狀結構的根節點來作為一個集合的代表
由於根節點並沒有父親,因此可以把根節點的父親指向自己 $f[root]=root$
### 初始化
在併查集,每一個元素一開始都是**自己的集合**
假設有 $n$ 個元素,則需要將編號 $1$ ~ $n$ 的節點連向自己
```cpp
void init(n){ // 初始化
f.resize(n);
for (int i = 1; i <= n; i++) {
f[i] = i;
}
}
```
<center>
<img src="https://hackmd.io/_uploads/HyJZTtx7Jg.png" style="margin: 0 auto; display: block; width: 250px">
</center>
### 查詢所在集合
由於是樹狀資料結構,可以用遞迴來尋找元素屬於哪一個集合
```cpp
int find(int x){
if (f[x] == x) // 如果當前節點為 f[x] == x
return x; // 則為根節點
return find(f[x]);
}
```
### 合併
合併可以將兩個集合聯集在一起
步驟如下:
1. 丟入兩參數 $x$ 和 $y$
2. 尋找 $x$ 和 $y$ 所在的集合 (找代表元素)
3. 如果 $x$ 和 $y$ 在不同集合,就用其集合的代表元素來合併
```cpp
void merge(int x, int y) { // 合併 x, y 所在集合
x = find(x); // x 找到其根節點
y = find(y); // y 找到其根節點
if(x != y) // 如果 x,y 原本就屬於同一個集合則不需合併
f[y] = x; // 將其中一個根節點連到另一個
}
```
`union` 在 C++ 是關鍵字,在此使用 `merge` 作為代替
<center>
<img src="https://hackmd.io/_uploads/HyahW5x7kg.png" style="margin: 0 auto; display: block; width: 550px">
</center>
## 併查集的限制與優化
### 限制
透過上述這種合併方式,很有可能連出一條鏈(chain),如果從鏈的非根終點查詢所在集合,則有可能會需要走訪所有節點,花費太多時間。因此可以考慮以下兩種優化方式
<center>
<img src="https://hackmd.io/_uploads/HkYus5eQkx.png" style="margin: 0 auto; display: block; width: 300px">
</center>
### 優化1: 啟發式合併 Union by Rank
可以多花一個陣列的空間,維護每個集合的大小關係 $sz[~]$
由於一開始每個集合都是自己,因此初始化大小為 $1$
```cpp
void init(int n){ // 初始化
for (int i = 1; i <= n; i++) {
f[i] = i;
sz[i] = 1;
}
}
```
在每一次合併當中,將小的集合併入大的集合
```cpp
void merge(int x, int y) { // 將 x 和 y 合併
x = find(x), y = find(y);
if (x == y)
return;
if (sz[x] < sz[y]) // 將小的併入大的
swap(x, y);
sz[x] += sz[y];
f[y] = x;
}
```
設有 $n$ 個元素,在最差情況下,只需要搜尋 $log_2(n)$ 左右個元素,即可找到根節點
<center>
<img src="https://hackmd.io/_uploads/HkOPp5gQkl.png" style="margin: 0 auto; display: block; width: 400px">
</center>
### 優化2. 路徑壓縮 Path Compression
將所有節點都指向根節點 (即代表元素),每次查詢只需要花一個步驟就能找到根節點
```cpp
int find(int x){
if (f[x] == x) // 如果當前節點為 f[x] == x
return x; // 則為根節點
f[x] = find(f[x]);
return f[x];
}
```
<center>
<img src="https://hackmd.io/_uploads/By0i0ceX1g.png" style="margin: 0 auto; display: block; width: 350px">
</center>
### 兩種優化都使用的程式碼
```cpp
int f[MAXN], sz[MAXN];
void init(int n){ // 初始化
for (int i = 1; i <= n; i++) {
f[i] = i;
sz[i] = 1;
}
}
int find(int x){
if (f[x] == x) // 如果當前節點為 f[x] == x
return x; // 則為根節點
f[x] = find(f[x]);
return f[x];
}
void merge(int x, int y) { // 將 x 和 y 合併
x = find(x), y = find(y);
if (x == y)
return;
if (sz[x] < sz[y]) // 將小的併入大的
swap(x, y);
sz[x] += sz[y];
f[y] = x;
}
```
## 時間複雜度
### 分析優化的時間複雜度
因併查集是一種樹狀資料結構,設 $h$ 為樹高,$n$ 為元素個數
- 啟發式合併
- 初始化: $O(n)\times 2=O(n)$
- 查詢所在集合: $O(h) = O(log ~n)$
- 合併: $O(h)\times 2 + O(1)=O(h) = O(log ~n)$
- 路徑壓縮
- 初始化: $O(n)$
- 查詢所在集合: $O(1)$
- 合併: $O(1)\times 2 + O(1)=O(1)$
- 兩種優化一起用
- 初始化: $O(n)$
- 查詢所在集合: $O(\alpha(n))\approx O(1)$
- 合併: $O(\alpha(n)) \times 2 + O(1)\approx O(1)$
其中,$\alpha(n)$ 是一個成長非常快的迭代的函數 $A_k(n)$ 的反函數。簡而言之就是 $\alpha(n)$ 成長非常緩慢,就算將宇宙所有的原子數量 $10^{80}$ 代入,$\alpha(10^{80}) \leq 4$,所以幾乎可以算是常數
詳細證明: https://codeforces.com/blog/entry/98275
~~我的原文書花了 $9$ 頁在探討這個問題,而我只看得懂結論 XD~~
## 題目練習
[Zerojudge f677. FJCU_109_Winter_Day3_Lab1 並查集練習](https://zerojudge.tw/ShowProblem?problemid=f677)
[Zerojudge a445. 新手訓練系列- 我的朋友很少](https://zerojudge.tw/ShowProblem?problemid=a445)
[CSES Counting Rooms](https://cses.fi/problemset/task/1192/)
[Zerojudge j243. 111北二2a.資料分組問題](https://zerojudge.tw/ShowProblem?problemid=j243)
[Zerojudge f260. 愛八卦的同學](https://zerojudge.tw/ShowProblem?problemid=f260)
[Zerojudge d831. 畢業旅行](https://zerojudge.tw/ShowProblem?problemid=d831)
[Zerojudge f292. 11987 - Almost Union-Find](https://zerojudge.tw/ShowProblem?problemid=f292)
[Zerojudge g212. E.魔法集合(set)](https://zerojudge.tw/ShowProblem?problemid=g212)
[Zerojudge f310. 10158 - War](https://zerojudge.tw/ShowProblem?problemid=f310)
[Zerojudge c291. APCS 2017-0304-2小群體](https://zerojudge.tw/ShowProblem?problemid=c291)
[Zerojudge g598. APCS 4. 真假子圖](https://zerojudge.tw/ShowProblem?problemid=g598)
[CSES Road Construction](https://cses.fi/problemset/task/1676)
[Codeforces 2184E. Exquisite Array](https://codeforces.com/contest/2184/problem/E)
----
## 參考資料
- Introduction to Algorithms, Fourth Edition
- [維基百科](https://zh.wikipedia.org/wiki/%E5%9F%BA%E6%95%B0_(%E6%95%B0%E5%AD%A6))
- [海大競程講義 Graph Theory & DSU](https://hackmd.io/@LeeShoWhaodian/2024I2CP_DSUandGraph#/2)
- [set 台師大演算法筆記](https://web.ntnu.edu.tw/~algo/Set.html)
- [圖論演算法ch21 — DS for Disjoint Sets - 慈慈 - Medium](https://cihcih.medium.com/%E5%9C%96%E8%AB%96%E6%BC%94%E7%AE%97%E6%B3%95ch21-ds-for-disjoint-sets-de61210d671a)
- [Codeforces Proving the inverse Ackermann complexity of Union-Find](https://codeforces.com/blog/entry/98275)
----
> [ShanC 程式競賽筆記](https://hackmd.io/@ShanC/B1ouGxqcC)
> 作者: ShanC
> 更新: 2024/11/24