本文已收錄到 GitHub · AndroidFamily,有 Android 進階知識體系,歡迎 Star。技術和職場問題,請關注公眾號 [彭旭銳] 私信我提問。
大家好,我是小彭。
今天分享到的是一種相對冷門的資料結構 —— 並查集。雖然冷門,但是它背後體現的演演算法思想卻非常精妙,在處理特定問題上能做到出奇制勝。那麼,並查集是用來解決什麼問題的呢?
學習路線圖:
除了並查集之外,不相交集合(Disjoint Sets)、合併-查詢集合(Merge-find Set)、聯合-查詢資料結構(Union-find Data Structure)、聯合-查詢演演算法(Union-find algorithm),均表示相同的資料結構或思想。
並查集是一種用來高效地判斷 「動態連通性 」 的資料結構: 即給定一個無向圖,要求判斷某兩個元素之間是否存在相連的路徑(連通),這就是連通問題,也叫 「朋友圈」 問題。聽起來有點懵,你先彆著急哈,咱來一點一點地把這個知識體系建立起來。
先舉個例子,給定一系列航班資訊,問是否存在 「北京」 到 「廣州」 的路徑,這就是連通性問題。而如果是問 「北京」 到 「廣州」 的最短路徑,這就是路徑問題。並查集是專注於解決連通性問題的資料結構,而不關心元素之間的路徑與距離,所以最短路徑等問題就超出了並查集的能夠處理的範圍,不是它考慮的問題。
連通問題與路徑問題示意圖
另一個關鍵點是,並查集也非常適合處理動態資料的連通性問題。 因為在完成舊資料的處理後,舊資料的連通關係是記錄在並查集中的。即使後續動態增加新的資料,也不需要重新檢索整個資料集,只需要將新資料提供的資訊補充到並查集中,這帶有空間換時間的思想。
動態連通問題
理解了並查集的應用場景後,下面討論並查集是如何解決連通性的問題。
既然要解決連通性問題,那麼在並查集的邏輯結構裡,就必須用某種方式體現出兩個元素或者一堆元素之間的連線關係。那它是怎麼體現的呢 —— 代表元法。
並查集使用 「代表元法」 來表示元素之間的連線關係:將相互連通的元素組成一個子集,並從中選取一個元素作為代表元。而判斷兩個元素之間是否連通,就是判斷它們的代表元是否相同,代表元相同則說明處於相同子集,代表元不同則說明處於不同的子集。
例如,我們將航班資訊構建為並查集的資料結構後,就有 「重慶」 和 「北京」 兩個子集。此時,問是否存在 「北京」 到 「廣州」 的路徑,就是看 「北京」 和 「廣州」 的代表元是否相同。可見它們的代表元是相同的,因此它們是連通的。
並查集的邏輯結構和物理結構
理解了並查集的邏輯結構後,下面討論如何用程式碼實現並查集。
並查集的物理結構可以是陣列,亦可以是連結串列,只要能夠體現節點之間連線關係即可。
陣列實現相對於連結串列實現更加常見,另外,在陣列的基礎上還衍生出雜湊表的實現,關鍵看元素個數是否固定。例如:
提示: 我們這裡將父節點指向節點本身定義為根節點,也有題解將父節點指向
null
或者-1
的節點定義為根節點。兩種方法都可以,只要能夠區分出普通節點和根節點。但是指向節點本身的寫法更簡潔,不需要擔心Union(x, x)
出現死迴圈。
以下為基於陣列和基於雜湊表的程式碼模板:
基於陣列的並查集
// 陣列實現適合元素個數固定的場景
class UnionFind(n: Int) {
// 建立一個長度為 n 的陣列,每個位置上的值初始化陣列下標,表示初始化時有 n 個子集
private val parent = IntArray(n) { it }
...
}
基於雜湊表的並查集
// 雜湊表實現適合元素個數不固定的場景
class UnionFind() {
// 建立一個空雜湊表,
private val parent = HashMap<Int, Int>()
// 查詢操作
fun find(x: Int): Int {
// 1. parent[x] 為 null 表示首次查詢,先加入雜湊表中並指向自身
if (null == parent[x]) {
parent[x] = x
return x
}
// 下文說明查詢操作細節...
}
}
「並查集,並查集」,顧名思義並查集就是由 「並」 和 「查」 這兩個最基本的操作組成的:
例如,以下是一個基於陣列的並查集實現,其中使用 Find(x)
查詢元素的根節點使用 Union(x, y)
合併兩個元素的根節點:
基於陣列的並查集
class UnionFind(n: Int) {
// 建立一個長度為 n 的陣列,每個位置上的值初始化陣列下標,表示初始化時有 n 個子集
val parent = IntArray(n) { it }
// 查詢操作(遍歷寫法)
fun find(x: Int): Int {
var key = x
while (key != parent[key]) {
key = parent[key]
}
return key
}
// 合併操作
fun union(x: Int, y: Int) {
// 1. 分別找出兩個元素的根節點
val rootX = find(x)
val rootY = find(y)
// 2. 任意選擇其中一個根節點成為另一個根節點的子樹
parent[rootY] = rootX
}
// 判斷連通性
fun isConnected(x: Int, y: Int): Boolean {
// 判斷根節點是否相同
return find(x) == find(y)
}
// 查詢操作(遞迴寫法)
fun find(x: Int): Int {
var key = x
if (key != parent[key]) {
return find(parent[key])
}
return key
}
}
合併與查詢示意圖
並查集的連通分量,表示的是整個並查集中獨立的子集個數,也就是森林中樹的個數。要計算並查集的連通分量,其實就是在合併操作中維護連通分量的計數,在合併子集後將計數減一。
class UnionFind(n: Int) {
private val parent = IntArray(n) { it }
// 連通分量計數,初始值為元素個數 n
var count = n
// 合併操作
fun union(x: Int, y: Int) {
val rootX = find(x)
val rootY = find(y)
if(rootX == rootY){
// 未發生合併,則不需要減一
return
}
// 合併後,連通分量減一
parent[rootY] = rootX
count --
}
...
}
連通分量示意圖
理解以上概念後,就已經具備解決連通問題的必要知識了。我們看一道 LeetCode 上的典型例題: LeetCode · 990.
LeetCode 例題
我們可以把每個變數看作一個節點,而等式表示兩個節點相連,不等式則表示兩個節點不相連。那麼,我們可以分 2 步:
—— 圖片參照自 LeetCode 官方題解
題解範例如下:
題解
// 未優化版本
class Solution {
fun equationsPossible(equations: Array<String>): Boolean {
// 26 個小寫字母的並查集
val unionFind = UnionFind(26)
// 合併所有等式
for (equation in equations.filter { it[1] == '=' }) {
unionFind.union(equation.first(), equation.second())
}
// 檢查不等式是否與連通性衝突
for (equation in equations.filter { it[1] == '!' }) {
if (unionFind.isConnected(equation.first(), equation.second())) {
return false
}
}
return true
}
private fun String.first(): Int {
return this[0].toInt() - 97
}
private fun String.second(): Int {
return this[3].toInt() - 97
}
private class UnionFind() {
// 程式碼略
}
}
前面說到並查集邏輯上是一種基於森林的資料結構。既然與樹有關,我們自然能想到它的複雜度就與樹的高度有關。在極端條件下(按照特殊的合併順序),有可能出現樹的高度恰好等於元素個數 n 的情況,此時,單次 Find
查詢操作的時間複雜度就退化到 $O(n)$。
那有沒有優化的辦法呢?
在介紹具體的優化方法前,我先提出來一個問題:在已經選定集合的代表元后,一個元素的父節點是誰還重要嗎?答案是不重要。
因為無論父節點是誰,最終都是去找根節點的。至於中間是經過哪些節點到達根節點的,這個並不重要。舉個例子,以下 3 個並查集是完全等價的,但明顯第 3 個並查集中樹的高度更低,查詢的時間複雜度更好。
父節點並不重要
理解了這個點之後,再理解並查集的優化策略就容易了。在並查集裡,有 2 種防止連結串列化的優化策略 —— 路徑壓縮 & 按秩合併。
路徑壓縮指在查詢的過程中,逐漸調整父節點的指向,使其指向更高層的節點,使得很多深層的階段逐漸放到更靠近根節點的位置。 根據調整的激程序度又分為 2 種:
路徑壓縮示意圖
路徑壓縮範例程式
// 遍歷寫法
fun find(x: Int): Int {
var key = x
while (key != parent[key]) {
parent[key] = parent[parent[key]]
key = parent[key]
}
return key
}
// 遞迴寫法
fun find(x: Int): Int {
var key = x
if (key != parent[key]) {
parent[key] = find(parent[key])
return parent[key]
}
return key
}
在 第 2.1 節 提到合併操作時,我們採取的合併操作是相對隨意的。我們在合併時會任意選擇其中一個根節點成為另一個根節點的子樹,這就有可能讓一棵較大子樹成為較小子樹的子樹,使得樹的高度增加。
而按秩合併就是要打破這種隨意性,在合併的過程中讓較小的子樹成為較大子樹的子樹,避免合併以後樹的高度增加。 為了表示樹的高度,需要維護使用 rank 陣列,記錄根節點對應的高度。
按秩合併示意圖
按秩合併範例程式
private class UnionFind(n: Int) {
// 父節點
private val parent = IntArray(n) { it }
// 節點的高度
private val rank = IntArray(n) { 1 }
// 連通分量
var count = n
private set
// 查詢(路徑壓縮)
fun find(x: Int): Int {
var key = x
while (key != parent[key]) {
parent[key] = parent[parent[key]]
key = parent[key]
}
return key
}
// 合併(按秩合併)
fun union(key1: Int, key2: Int) {
val root1 = find(key1)
val root2 = find(key2)
if (root1 == root2) {
return
}
if (rank[root1] > rank[root2]) {
// root1 的高度更大,讓 root2 成為子樹,樹的高度不變
parent[root2] = root1
} else if (rank[root2] > rank[root1]) {
// root2 的高度更大,讓 root1 成為子樹,樹的高度不變
parent[root1] = root2
} else {
// 高度相同,誰當子樹都一樣
parent[root1] = root2
// root2 的高度加一
rank[root2]++
// 或
// parent[root2] = root1
// rank[root1] ++
}
count--
}
}
在同時使用路徑壓縮和按秩合併兩種優化策略時,單次合併操作或查詢操作的時間複雜度幾乎是常數,整體的時間複雜度幾乎是線性的。
以對 N 個元素進行 N - 1 次合併和 M 次查詢的操作序列為例,單次操作的時間複雜度是 $O(a(N))$,而整體的時間複雜度是 $O(M·a(N))$。其中 $a(x)$ 是逆阿克曼函數,是一個增長非常非常慢的函數,只有使用那些非常大的 「天文數位」 作為變數 $x$,否則 $a(x)$ 的取值都不會超過 4,基本上可以當作常數。
然而,逆阿克曼函數畢竟不是常數,因此我們不能說並查集的時間複雜度是線性的,但也幾乎是線性的。關於並查集時間複雜度的論證過程,具體可以看參考資料中的兩本演演算法書籍,我是看不懂的。
前面我們講的是一維的連通性問題,那麼在二維世界裡的連通性問題,並查集還依舊好用嗎?我們看 LeetCode 上的另一道典型例題: LeetCode · 200.
LeetCode 例題
這個問題直接上 DFS 廣度搜尋自然是可以的:遍歷二維陣列,每找到 1
後使用 DFS 遍歷將所有相連的 1
消除為 0
,直到整塊相連的島嶼都消除掉,記錄島嶼數 +1。最後,輸出島嶼數。
用並查集的來解的話,關鍵技巧就是建立長度為 M * N 的並查集:遍歷二維陣列,每找到 1
後,將它與右邊和下邊的 1
合併起來,最終輸出並查集中連通分量的個數,就是島嶼樹。
並查集解法
class Solution {
fun numIslands(grid: Array<CharArray>): Int {
// 位置
fun position(row: Int, column: Int) = row * grid[0].size + column
// 並查集
val unionFind = UnionFind(grid)
// 偏移量陣列(向右和向下)
val directions = arrayOf(intArrayOf(0, 1), intArrayOf(1, 0))
// 邊界檢查
fun checkBound(row: Int, column: Int): Boolean {
return (row in grid.indices) and (column in grid[0].indices)
}
for (row in grid.indices) {
for (column in grid[0].indices) {
if ('1' == grid[row][column]) {
// 消費(避免後續的遍歷中重複搜尋)
grid[row][column] = '0'
for (direction in directions) {
val newRow = row + direction[0]
val newColumn = column + direction[1]
if (checkBound(newRow, newColumn) && '1' == grid[newRow][newColumn]) {
unionFind.union(position(newRow, newColumn), position(row, column))
}
}
}
}
}
return unionFind.count
}
private class UnionFind(grid: Array<CharArray>) {
// 父節點
private val parent = IntArray(grid.size * grid[0].size) { it }
// 節點高度
private val rank = IntArray(grid.size * grid[0].size) { 1 }
// 連通分量(取格子 1 的總數)
var count = grid.let {
var countOf1 = 0
for (row in grid.indices) {
for (column in grid[0].indices) {
if ('1' == grid[row][column]) countOf1++
}
}
countOf1
}
private set
// 合併(按秩合併)
fun union(key1: Int, key2: Int) {
val root1 = find(key1)
val root2 = find(key2)
if (root1 == root2) {
// 未發生合併,則不需要減一
return
}
if (rank[root1] > rank[root2]) {
parent[root2] = root1
} else if (rank[root2] > rank[root1]) {
parent[root1] = root2
} else {
parent[root1] = root2
rank[root2]++
}
// 合併後,連通分量減一
count--
}
// 查詢(使用路徑壓縮)
fun find(x: Int): Int {
var key = x
while (key != parent[key]) {
parent[key] = parent[parent[key]]
key = parent[key]
}
return key
}
}
}
到這裡,並查集的內容就講完了。文章開頭也提到了,並查集並不算面試中的高頻題目,但是它的設計思想確實非常妙。不知道你有沒有這種經歷,在看到一種非常美妙的解題 / 設計思想後,會不自覺地拍案叫絕,直呼內行,並查集就是這種。
更多同型別題目:
並查集 | 題解 |
---|---|
990. 等式方程的可滿足性 | 【題解】 |
200. 島嶼數量 | 【題解】 |
547. 省份數量 | 【題解】 |
684. 冗餘連線 | 【題解】 |
685. 冗餘連線 II | |
1319. 連通網路的操作次數 | 【題解】 |
399. 除法求值 | |
952. 按公因數計算最大元件大小 | |
130. 被圍繞的區域 | |
128. 最長連續序列 | |
721. 賬戶合併 | |
765. 情侶牽手 |