[譯] Swift 演算法學院 - 並查集

KeithSummer發表於2018-02-13

本篇是來自 Swift 演算法學院的翻譯的系列文章,Swift 演算法學院 致力於使用 Swift 實現各種演算法,對想學習演算法或者複習演算法的同學非常有幫助,講解思路非常清楚,每一篇都有詳細的例子解釋。 更多翻譯的文章還可以檢視這裡

並查集

並查集資料結構是對一組分成多個不相交的子集元素的處理,並查集又稱為不相交集。

到底是神馬意思?舉個例子,並查集是用來處理下面集合合併和查詢:

[ a, b, f, k ]
[ e ]
[ g, d, c ]
[ i, j ]
複製程式碼

這些集合是不相交的,因為它們沒有共同的成員。

並查集支援三種基本操作:

  1. Find(A):找到 A 在那個子集中。比如 find(d) 函式返回 [g, d, c ]
  2. Union(A, B):把某兩個集合 AB 合成一個子集。比如 union(d, j) 需要將 [ g, d, c ][ i, j ] 合併成一個大的集合[ g, d, c, i, j ]
  3. AddSet(A):生成一個只包含 A 新子集。如 addSet(h) 生成一個新集合 [ h ]

該資料結構最常用於查詢無向中的節點。它也能用於提高 Kruskal 演算法的效率,用於查詢圖中最小生成樹。

程式碼實現

並查集有很多實現方法,下面用一種相對簡單高效的方式實現:加權Quick-Union。

PS:可以在 playground 中找到並查集的多種實現方式

public struct UnionFind<T: Hashable> {
  private var index = [T: Int]()
  private var parent = [Int]()
  private var size = [Int]()
}
複製程式碼

這裡並查集資料結構實際是森林,每個子集用表示。

由於目標只是保持對每個樹節父點的聯絡,不需要聯絡子節點,可以通過一個父節點的陣列來表示,parent[ i ] 表示第 i 個父節點。

舉例:如果父節點陣列像這樣

parent [ 1, 1, 1, 0, 2, 0, 6, 6, 6 ]
     i   0  1  2  3  4  5  6  7  8
複製程式碼

樹的結構如下:

      1              6
    /   \           / \
  0       2        7   8
 / \     /
3   5   4
複製程式碼

森林中有兩棵樹,每棵對應一組元素集合。(備註:這裡是因為受制於 ASCII 表達形式所以用二叉樹表示的,實際情況並不侷限於此)。

每個子集使用唯一的數字來標示。子集合樹的根節點作為索引,如 1 是第一棵樹的根節點, 6 是第二棵樹的根節點。

在這個例子中有兩個子集,第一個代表值為 1 ,第二個為 6Find 函式返回的是子集的代表值,而不是它的內部資料。

注意 parent[] 中根節點指向自己,如 parent[1] = 1parent[6] = 6 ,可以通過這個方法可以發現那些是根節點。

新增

從新增開始,看看如何實現一些基本操作:

public mutating func addSetWith(_ element: T) {
  index[element] = parent.count  // 1
  parent.append(parent.count)    // 2
  size.append(1)                 // 3
}
複製程式碼

當新增一個新元素,實際是新增一個包含該元素的集合。

  1. 儲存新元素的索引到 index 字典中。可以幫助快速查詢該元素。
  2. 新增索引到 parent 陣列中併為這個集合新建一個樹。由於這個新集合只包含一個值,而且該值為樹的根節點,所以parent[i] 指向自己。
  3. size[i] 是索引值為 i 根節點處的節點個數。對於新集合因為只含一個元素所以值為 1 。在隨後的合併操作中會用到 size 這個陣列。

查詢

經常需要查詢某個元素是否在集合中, Find 函式就是幹這個事滴!在 並查集 中又稱為 setof():

public mutating func setOf(_ element: T) -> Int? {
  if let indexOfElement = index[element] {
    return setByIndex(indexOfElement)
  } else {
    return nil
  }
}
複製程式碼

先通過 index 字典來查詢某個元素的索引值,再用一個函式來查詢該元素屬於哪個集合:

private mutating func setByIndex(_ index: Int) -> Int {
  if parent[index] == index {  // 1
    return index
  } else {
    parent[index] = setByIndex(parent[index])  // 2
    return parent[index]       // 3
  }
}
複製程式碼

既然和樹打交道了就用遞迴的方法來解決吧。

回顧一下,每個集合用一棵樹來表示,根節點的索引值為集合的代表值。查詢該元素所屬樹的根節點,並返回它的索引值。

  1. 第一步先檢查輸入的 index 值是否是根節點。(根節點的 parent 指回自己),如果是,結束查詢。
  2. 否則,遞迴呼叫當前節點的父節點方法。 接下來的是非常重要的一步:重寫當前節點的父節點為根節點,實際就是將節點重新連線到根節點上。當下次在呼叫這個方法的時候速度就快了,因為到根節點的路徑非常短了。如果沒有這個優化這個方法的複雜度為 O(n),但經過路徑壓縮後(在合併環節)複雜度接近 O(1)
  3. 返回根節點作為結果。

這裡有一個圖形化的解釋,讓我們來看看:

[譯] Swift 演算法學院 - 並查集

呼叫 setOf(4) 試試看,為了查到根節點,需要先訪問 2 節點,然後再訪問 7 節點。(索引值用紅色數字標示)。

呼叫 setOf(4) 後,樹結構如下:

AfterFind

現在若再呼叫 setOf(4) ,不需要再經過節點 2 才能到根節點。因此在使用並查集資料結構的時候會自優化,是不是很酷!

這裡有個便捷的方法可以判斷兩個元素是不是在一個集合中:

public mutating func inSameSet(_ firstElement: T, and secondElement: T) -> Bool {
  if let firstSet = setOf(firstElement), let secondSet = setOf(secondElement) {
    return firstSet == secondSet
  } else {
    return false
  }
}
複製程式碼

因為呼叫了 sefOf() 方法也會優化樹結構。

合併(按秩的方式)

最後的操作是 合併 就是將兩個集合合併成一個大的集合。

    public mutating func unionSetsContaining(_ firstElement: T, and secondElement: T) {
        if let firstSet = setOf(firstElement), let secondSet = setOf(secondElement) { // 1
            if firstSet != secondSet {                // 2
                if size[firstSet] < size[secondSet] { // 3
                    parent[firstSet] = secondSet      // 4
                    size[secondSet] += size[firstSet] // 5
                } else {
                    parent[secondSet] = firstSet
                    size[firstSet] += size[secondSet]
                }
            }
        }
    }
複製程式碼

計算過程如下:

  1. 給出兩個集合,這兩個集合都有一個根節點的索引值存在 parent 陣列中。
  2. 判斷是不是相同的集合,合併兩個相同的集合沒有任何意義。
  3. 以秩大小作為權重進行優化,如果想讓樹的深度儘可能的保持最小,需要把小的樹新增到大的樹上。通過比較兩個樹的陣列個數決定那個樹更小。
  4. 下面將小一些的樹新增到大一些樹的根節點。
  5. 因為增加一堆新的樹節點,需要更新大樹的元素個數值。

為了更好介紹這個演算法,舉個例子說明一下,有兩個集合,每個集合的樹的資料結構如下:

BeforeUnion

現在呼叫方法 unionSetsContaining(4, and: 3) 將秩小一些的樹新增到大一些的樹上:

AfterUnion

因為在開始的時候呼叫了 setOf() ,所以大一些的樹仍然會走優化流程 — 節點 3 直接掛到根節點上。

合併的優化的複雜度也為 O(1)

路徑壓縮

private mutating func setByIndex(_ index: Int) -> Int {
    if index != parent[index] {
        // Updating parent index while looking up the index of parent.
        parent[index] = setByIndex(parent[index])
    }
    return parent[index]
}
複製程式碼

路徑壓縮能夠使樹不斷變平坦,因此查詢複雜度 幾乎 接近 O(1)

複雜度總結

處理 N 個元素
資料結構 Union Find
Quick Find N 1
Quick Union Tree height Tree height
Weighted Quick Union lgN lgN
Weighted Quick Union + Path Compression very close, but not O(1) very close, but not O(1)
N個元素中做M次並集
演算法 最壞的情況
Quick Find M N
Quick Union M N
Weighted Quick Union N + M lgN
Weighted Quick Union + Path Compression (M + N) lgN

更多

可以繼續檢視原始碼中的其他演算法的工作原理。還可以看Union-Find at Wikipedia

作者Artur Antonov, 稽核 Yi Ding . 譯者KeithMorning

相關文章