前言
並查集(Disjoint-set) 的代碼非常簡潔,但是功能卻很強大。
關於並查集,這裏有一篇文章超有愛的並查集~,講得非常好,但是隻使用了並查集兩個主要優化中的"路徑壓縮"優化,並且我覺得很多情況下采用遞歸的寫法要比採用循環的寫法要易懂很多。本文將使用C++實現並查集並使用“按秩合併”和”路徑壓縮“優化並查集。我們先大概瞭解什麼是並查集。
什麼是並查集(Disjoint-set)
對於一個集合,我們還可以對集合S進一步劃分: ,我們希望能夠快速確定S中的兩兩元素是否屬於S的同一子集。
舉個栗子,,如果我們按照一定的規則對集合S進行劃分,假設劃分後爲,,,任意給定兩個元素,我們如何確定它們是否屬於同一子集?某些合併子集後,又如何確定兩兩關係?基於此類問題便出現了並查集這種數據結構。
並查集有兩個基本操作:
- Find: 查找元素所屬子集
- Union:合併兩個子集爲一個新的集合
並查集的基本結構
我們可以使用樹這種數據結構來表示集合,不同的樹就是不同的集合,並查集中包含了多棵樹,表示並查集中不同的子集,樹的集合是森林,所以並查集屬於森林。
若集合,最初每一個元素都是一棵樹。
對於Union
操作,我們只需要將兩棵樹合併,例如合併0、1、2得到,合併3和4得到。
對於Find操作,我們只需要返回該元素所在樹的根節點。所以,如果我們想要比較判斷1和2是否在一個集合,只需要通過Find(1)和Find(2)返回各自的根節點比較是否相等便可。已知樹中的一個節點,找到其根節點的時間複雜度爲,爲節點的深度。
我們可以使用數組來表示樹,數組下標表示樹的一個節點,該下表所對應的值表示樹的父節點。例如表示元素的父節點。對於圖2中的集合,我們可以存儲在下面的數組中(第二行爲數組下標)
0 | 0 | 0 | 3 | 3 | 5 | 6 |
---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 |
對於樹的根節點,我們規定其元素值爲其本身(即父節點爲自己)。
C++實現一
我們使用一個parent數組存儲樹,先實現未經優化的版本。
對於Find操作,代碼非常簡單。
int find(int x){
return parent[x] == x ? x : find(parent[x]);
}
該代碼比較元素x的父節點parent[x]是否等於x自身,如果是便說明找到了根節點(根節點的父節點是自身),直接返回;否則,把x的父節點parent[x]傳入find,直到找到根節點。
下面是union操作
void to_union(int x1, int x2){
int p1 = find(x1);
int p2 = find(x2);
parent[p1] = p2;
}
傳入兩個元素,分別找到根節點,使根節點p1的父節點爲p2,即將p1爲根節點的這棵樹合併到p2爲根節點的樹上。
下面是完整代碼:
#include <vector>
class DisjSet{
private:
std::vector<int> parent;
public:
DisjSet(int max_size) : parent(std::vector<int>(max_size)) {
// 初始化每一個元素的根節點都爲自身
for (int i = 0; i < max_size; ++i)
parent[i] = i;
}
int find(int x) {
return parent[x] == x ? x : find(parent[x]);
}
void to_union(int x1, int x2) {
parent[find(x1)] = find(x2);
}
// 判斷兩個元素是否屬於同一個集合
bool is_same(int e1, int e2) {
return find(e1) == find(e2);
}
};
剛學並查集的時候看到這裏都快哭了,這代碼也簡潔了吧:joy:。
上面的實現,可以看出每一次Find操作的時間複雜度爲O(H)
,H爲樹的高度,由於我們沒有對樹做特殊處理,所以樹的不斷合併可能會使樹嚴重不平衡,最壞情況每個節點都只有一個子節點,如下圖3(第一個點爲根節點)
此時Find操作的時間複雜度爲O(n)
,這顯然不是我們想要的。下面引入兩個優化的方法。
並查集的優化
方法一:
"按秩合併"。實際上就是在合併兩棵樹時,將高度較小的樹合併到高度較大的樹上。這裏我們使用“秩”(rank)代替高度,秩表示高度的上界,通常情況我們令只有一個節點的樹的秩爲0,嚴格來說,rank + 1纔是高度的上界;兩棵秩分別爲r1、r2的樹合併,如果秩不相等,我們將秩小的樹合併到秩大的樹上,這樣就能保證新樹秩不大於原來的任意一棵樹。如果r1與r2相等,兩棵樹任意合併,並令新樹的秩爲r1 + 1。
方法二:
“路徑壓縮”。在執行Find的過程中,將路徑上的所有節點都直接連接到根節點上。
同時使用這兩種方法的平均時間複雜度爲,是的反函數,是阿克曼函數,增長率非常之高,所以在n非常大的時候,依然小於5。下面是採用"按秩合併"與“路徑壓縮”兩中優化算法的實現。
C++實現二(優化版)
#include <vector>
class DisjSet{
private:
std::vector<int> parent;
std::vector<int> rank; // 秩
public:
DisjSet(int max_size) : parent(std::vector<int>(max_size)),
rank(std::vector<int>(max_size, 0))
{
for (int i = 0; i < max_size; ++i)
parent[i] = i;
}
int find(int x) {
return x == parent[x] ? x : (parent[x] = find(parent[x]));
}
void to_union(int x1, int x2) {
int f1 = find(x1);
int f2 = find(x2);
if (rank[f1] > rank[f2])
parent[f2] = f1;
else {
parent[f1] = f2;
if (rank[f1] == rank[f2])
++rank[f2];
}
}
bool is_same(int e1, int e2) {
return find(e1) == find(e2);
}
};
總結
時間複雜度
採用“路徑壓縮”與“按秩合併”優化的並查集每個操作的平均時間複雜度爲,是的反函數,在n非常大的時候,依然小於5。
空間複雜度
,爲元素的數量。
參考資料
作者staneyffer,首發於他的博客,原文鏈接: https://chengfy.com/post/4
以下是我自己修改的上面的代碼,在並查集類中加入了計算元素所在集合大小的代碼。
class DisjSet{
private:
std::vector<int> parent;
std::vector<int> rank; // 秩
std::vector<int> myssize;
public:
DisjSet(int max_size) :
parent(std::vector<int>(max_size)),
rank(std::vector<int>(max_size, 0)),
myssize(std::vector<int>(max_size, 1))
{
for (int i = 0; i < max_size; ++i)
parent[i] = i;
}
int find(int x){
return x == parent[x] ? x : (parent[x] = find(parent[x]));
}
void to_union(int x1, int x2){
int f1 = find(x1);
int f2 = find(x2);
if (rank[f1] > rank[f2]) {
parent[f2] = f1;
myssize[f1] = myssize[f1] + myssize[f2];
}
else{
parent[f1] = f2;
myssize[f2] = myssize[f1] + myssize[f2];
if (rank[f1] == rank[f2])
++rank[f2];
}
}
bool is_same(int e1, int e2){
return find(e1) == find(e2);
}
int ssize(int e) {
return myssize[find(e)];
}
};