Skip to main content

[Graph] 4. Disjoint Set (Union & Find)

什麼是 Disjoint Set? 為什麼需要 Disjoint Set?

我們在處理 Graph 問題時,會需要知道 2 個點是否相連,而我們需要一種有效率的資料結構與演算法來處理這個問題

如以下的範例,我們可以多快地找出以下 2 點之間:

  • 0 跟 3 是否相連?
  • 1 跟 5 是否相連?
  • 7 跟 8 是否相連?
Disjoint Set Example

我們可以利用 Disjoint Set (Union & Find) 這個資料結構來處理這類型問題



Disjoint Set v.s. Union Find

通常我們在做這類型的題目,會聽到 Disjoint Set 跟 Union Find 這兩個名詞,這兩個名詞其實是同一個東西, 但差別在於:

  • Disjoint Set:主要在描述資料結構,表示各群不相交的 vertices 集合
  • Union Find:主要在描述演算法,分別為 Union 與 Find,Union 用於將兩個點連結,Find 用於找查 vertice 的 root node


相關術語

  • Parent node: vertex 直接對應的 parent node
  • Root node:沒有 parent 的 node,或者說自己就是自己的 parent node
    (a node without a parent node; it can be viewed as the parent node of itself.)


Disjoint Set 的運作流程

假設我們現在有 0 ~ 9 的 vertices,並且在這個 Graph 的 edges 如下:

[
[0, 1], [0, 2], [1, 3],
[4, 8], [5, 6], [5, 7]
]


下面我們就用一個 Slides 的圖示來介紹 Disjoint Set 的運作流程

我們透過 edge 連結各點,來將相關連的 vertices 各自歸類到不同的 set, 最後在 Slides 8,我們就可以用最終的 sets 來判斷 2 點是否相連,例如:

  • 0, 3:有相同的 root,所以相連
  • 1, 5:沒有相同的 root,所以不相連
  • 7, 8:沒有相同的 root,所以不相連


資料結構的運作

如果我們把 Disjoint Set 的運作轉成程式的資料解構,我們可以用 1 Array 來表示,其中:

  • 每個 index 代表每個 vertex
  • 每個 index 的 value 代表每個 vertex 的 parent node

並且,如果我們想知道 2 vertices 是否相連,我們可以透過 2 個 vertices 的 root node 是否相同來判斷, 我們可以利用 parent array 來找到每個 vertex 的 root node,並利用 root node 的 parent 就是自己本身的特性: 以上圖的例子來說:

  • ✅ (0,3) 是相連的,因為
    • findRoot(0) === 0 :parentArray[0] = 0 --> 0
    • findRoot(3) === 0 :parentArray[3] = 1 --> parentArray[1] = 0 --> parentArray[0] = 0 --> 0
  • ❌ (1,5) 是不相連的,因為
    • findRoot(1) === 0 :parentArray[1] = 0 --> parentArray[0] = 0 --> 0
    • findRoot(5) === 5 :parentArray[5] = 5
  • ❌ (7,8) 是不相連的,因為
    • findRoot(7) === 5 :parentArray[7] = 5 --> parentArray[5] = 5 --> 5
    • findRoot(8) === 4 :parentArray[8] = 4 --> parentArray[4] = 4 --> 4


重要 functions

在 Disjoint Set 中,我們有 2 個重要的 functions,可以幫我們解決多數的問題:

  • find:找到給定 vertex 的 root node
  • union:將輸入的 2 個 input vertices 連結起來,並設為同一個 root node


兩種實作模式

根據不同的需求,我們可以用不同的實作模式,分別達到最好的 findunion 的效率:

  • Implementation with Quick Find
    • find:O(1)
    • union:O(N)
  • Implementation with Quick Union
    • find:O(N)
    • union:O(N)



Quick Find

如果我們要快速找到指定 vertex 的 root node,我們可以將 array value 改為儲存 Root vertex, 這樣一來,我們就可以在 O(1) 的時間內找到指定 vertex 的 root node


但代價是:我們在 union 的時候就需要 O(N) 的時間來更新所有相關的 vertices

以下列的例子來說,我們有兩組 sets,分別以 0, 2 為 root node,當我們連結 0, 2 時,我們也要同時更改 root node 為 2 的所有 vertices, 將其 root node 改為 2



JavaScript 實作

我們主要需要實作三個 methods:

  • find:找到指定 vertex 的 root node
  • union:將輸入的 2 個 input vertices 連結起來,並設為同一個 root node
  • connected:確認兩點是否相連

class UnionFind {
root = [];

// 一開始先預設每個 vertex 的 root node 為自己
constructor(size) {
for (let i = 0; i < size; i++) {
this.root[i] = i;
}
}

// 直接回傳 root array 的值
find(x) {
return this.root[x];
}

// 1. 先找到 x, y 的 root node
// 2. 將 y 的 root node 設為 x 的 root node
// 3. 將 root node 為 y 的其他 vertices 的 root node 也一起更新成 x 的 root node
union(x, y) {
const rootX = this.find(x);
const rootY = this.find(y);

if (rootX !== rootY) {
this.root[y] = rootX;

for (let i = 0; i < this.root.length; i++) {
if (this.root[i] === rootY) {
this.root[i] = rootX;
}
}
}
}

// 確認兩點是否相連,透過 2 vertices 的 root node 來判斷
connected(x, y) {
return this.find(x) === this.find(y);
}
}

Complexity

對於 Quick Find,find 可以達到 O(1) 的效率,但 union 需要檢查所有 vertices 的 root node 是否需要更改,需要 O(N) 的時間

Union-find ConstructorFindUnionConnected
Time ComplexityO(N)O(1)O(N)O(1)
Space ComplexityO(N)


Quick Union

如果我們要快速連結 2 vertices,我們保持 parent array 的值為 parent node,會是最有效的方式

如下圖中,當我們連結 0, 4 時,我們只需要將 4 的 parent node 設為 0,而不需要更新其他 vertices 的 parent node,在 union 中減少非常多的操作

JavaScript 實作

我們需要實作三個 methods 跟 Quick Find 相同,主要是 highlight 的部分實作上有差別:

  • find:找到指定 vertex 的 root node
  • union:將輸入的 2 個 input vertices 連結起來,並設為同一個 root node
  • connected:確認兩點是否相連

class UnionFind {
root = [];

constructor(size) {
for (let i = 0; i < size; i++) {
this.root[i] = i;
}
}

// 一直去找尋 parent node,直到 parent node 就是自己本身
find(x) {
while (this.root[x] !== x) {
x = this.root[x];
}

return x;
}

// 1. 先找到 x, y 的 root node
// 2. 將 y 的 root node 設為 x 的 root node
union(x, y) {
const rootX = this.find(x);
const rootY = this.find(y);

if (rootX !== rootY) {
this.root[y] = rootX;
}
}

// 確認兩點是否相連,透過 2 vertices 的 root node 來判斷
connected(x, y) {
return this.find(x) === this.find(y);
}
}

Complexity

對於 Quick Union,find 需要一直去找尋 parent node,直到 parent node 就是自己本身,需要 O(N) 的時間,但這是 worst case 的狀況,如下圖, 而 union 因為用到了 find method,所以時間複雜度也是 O(N)

Union-find ConstructorFindUnionConnected
Time ComplexityO(N)O(N)O(N)O(N)
Space ComplexityO(N)


Quick Union vs Quick Find

若要從 0 組到 n 的 union:

FindUnion
Quick FindO(1)N * O(N) === O(N^2)
Quick UnionO(N) (In worst case)N * O(N) === O(N^2) (In worst case)


平均下來

Quick Union 的效率會比 Quick Find 來得好



Quick Union Optimizations

由於 Quick Union 在不管在 findunion 的 worst case 的狀況下,會需要 O(N) 的時間,我們希望可以再進一步優化: 我們有以下兩種優化方法:

  • Union by Rank:針對 union 的時間複雜度做優化
  • Path Compression:針對 find 的時間複雜度做優化

我們就來一一介紹



Union by Rank

如果我們在 union 時沒有特別處理,在最壞的情況下,我們的 tree 會變成一個 linked list,這樣一來,find 的時間複雜度就會變成 O(N)

但我們可以利用一些技巧,在 union 做一些判斷,讓連接後的 graph 的廣度及深度更平衡, 這樣在進行 find 時,效率會更好,進而間接優化 union 的時間複雜度

方法很簡單,如下:

  1. 我們先得出出兩個 set 的深度
  2. 以深度較深的 set 的 root node 為基準,將深度較淺的 set 連接到深度較深的 set 上

這樣我們就可以避免最壞的 O(N) 的狀況



JavaScript 實作

  1. 宣告一個 rank array,用來儲存每個 vertex 的深度
  2. 先預設每個 vertex 的深度為 1
  3. union 時,比較兩個 set 的深度,
    1. 將深度較淺的 set 連接到深度較深的 set 上
    2. 如果深度相同,則將其中一個 set 連接到另一個 set 上,並將被接上的 vertex rank 加 1
class UnionFind {
root = [];
rank = [];

constructor(size) {
for (let i = 0; i < size; i++) {
root[i] = i;
rank[i] = 1;
}
}

union(x, y) {
const rootX = this.find(x);
const rootY = this.find(y);

if (rootX !== rootY) {
// 看哪邊較大,就設哪邊為 root node
// 如果兩邊一樣大,那我們就隨邊設一邊為 root node,並且將 rank +1,表示會再多 1 的高度
if (rank[rootX] > rank[rootY]) {
root[rootY] = rootX;
} else if (rank[rootX] < rank[rootY]) {
root[rootX] = rootY;
} else {
root[rootY] = rootX;
rank[rootX] += 1;
}
}
}

}


Complexity

  • Time Complexity
    • 對於 union-find,我們需要建立 2 個 size N 的 array
    • 對於 find,在最糟的情況下,我們重複連結深度相同的 graph,graph height 最多是 log (N) + 1,所以 find 的時間複雜度是 O(log N)
    • 對於 unionconnected,我們需要先 find 2 個 vertices 的 root node,所以時間複雜度是 O(log N)

  • Space Complexity
    • 對於 union-find,我們需要建立 2 個 size N 的 array


Union-find ConstructorFindUnionConnected
Time ComplexityO(N)O(log N)O(log N)O(log N)
Space ComplexityO(N)


Path Compression

除了在 union 優化,我們也可以針對 find method 做優化,我們可以讓 find 的時間複雜度機乎變成跟 Quick Find 一樣,逼近於 O(1)

我們在第一次執行 find 時,我們可以利用 Recursion 的技巧,來取得 root node,並將每個 vertex 的 parent node 設為 root node, 這樣一來,在第二次以後執行 find 時,時間複雜度就會變成 O(1)




JavaScript 實作

  1. 先取得 x 的 parent node
  2. 再利用 Recursion 的技巧,再往下找 parent 的 parent,直到找到 root node
  3. 並將 x 的 parent node 設為 root node
class UnionFind {
...
find(x) {
if (this.root[x] === x) {
return x;
}

const parent = this.root[x];
const rootX = this.find(parent);
this.root[x] = rootX;

return rootX;
}
...
}


Complexity

  • Time Complexity
    • 對於 find,在最好的情況下,也就是只有一層的平衡術,時間複雜度為 O(1),在最壞的情況下,也就是編成一個 linked list,時間複雜度為 O(N), 這樣平均下來,時間複雜度為 O(log N) (詳細可以參考 Top-Down Analysis of Path Compression

Union-find ConstructorFindUnionConnected
Time ComplexityO(N)O(log N)O(log N)O(log N)
Space ComplexityO(N)



優化完的 Quick Union

JavaScript 實作

class UnionFind {
root = [];
rank = [];

constructor(n) {
for (let i = 0; i < n; i++) {
this.root[i] = i;
this.rank[i] = 1;
}
}

find(x) {
if (this.root[x] === x) {
return x;
}

const parent = this.root[x];
const rootX = this.find(parent);
this.root[x] = rootX;

return rootX;
}

union(x, y) {
const rootX = this.find(x);
const rootY = this.find(y);

if (rootX !== rootY) {
if (this.rank[rootX] > this.rank[rootY]) {
this.root[rootY] = rootX;
} else if (this.rank[rootX] < this.rank[rootY]) {
this.root[rootX] = rootY;
} else {
this.root[rootY] = rootX;
this.rank[rootX] += 1;
}
}
}

connected(x, y) {
return this.find(x) === this.find(y);
}
}


Complexity

再經過 Union by Rank 和 Path Compression 的優化後,find 的時間複雜度變成 O(α(N)), 其中 α(N) 是 Ackermann function 的反函數,增長速度會非常慢,其值會非常接近於 O(1), unionconnected 的時間複雜度也會因為 find 的時間複雜度變成 O(α(N))


Union-find ConstructorFindUnionConnected
Time ComplexityO(N)O(α(N))O(α(N))O(α(N))
Space ComplexityO(N)


結論

  1. Disjoint Set 是用來判斷兩點是否相連
  2. 有兩種方法可以實作 Disjoint Set:Quick Find 和 Quick Union,Quick Union 的效率比較好
  3. Quick Union 可以再進一步優化,利用 Union by Rank 優化 union 的時間複雜度,並利用 Path Compression 優化 find 的時間複雜度
  4. Quick Union 經過優化後,find 的時間複雜度變成 O(α(N)),幾乎逼近於 O(1),unionconnected 的時間複雜度也會連帶變成 O(α(N))



參考資料