具代表性的面試主題

演算法面試:如何實作並查集並維護連通分量?

程式題中等
OfferCC 編輯團隊發佈 更新

題幹

給定 n 個節點,請實作並查集 UnionFind,支援 union(a, b)、connected(a, b) 和 count();union 必須回報是否真的合併了兩個分量。請證明實作正確、分析複雜度,並討論靜態圖、並行呼叫和刪除關係等追問。

題目與適用情境

有 n 個節點,編號為 0 到 n - 1。初始時每個節點各自形成一個連通分量。請實作 UnionFind,支援以下操作:

  • union(a, b):合併 a 和 b 所在的分量;只有原本不同的兩個分量真的合併時才回傳

True。

  • connected(a, b):判斷兩個節點目前是否屬於同一分量。
  • count():回傳目前的連通分量數量。

本題允許 n = 0,但任何操作參數都必須是合法編號,否則拋出 IndexError。基礎題只增加連線, 不刪除邊;呼叫由單一執行緒完成。例如 n = 6 時依序合併 (0, 1)、(1, 2)、(3, 4), 目前有 3 個分量:{0, 1, 2}、{3, 4} 和 {5}。再合併 (2, 4) 後只剩 2 個;此時 union(0, 3) 應回傳 False,分量數不能再次減少。

這是一般軟體工程編碼面試中的資料結構題。核心情境是連線持續加入,同時穿插大量「是否連通」與 「還剩幾組」查詢。若所有邊一次給定、只需計算一次連通分量,DFS 或 BFS 通常更直接;能指出這項 差異也是題目的一部分。

面試官評估重點

第一項訊號是能否選對狀態。並查集不保存完整圖,而是把每個集合表示成一棵父指標樹;根節點代表該 集合,且根的父節點是自己。因此 connected(a, b) 可以簡化為比較兩個根,不必重新走訪所有邊。

第二項訊號是合併是否只連接根。直接寫 parent[a] = b 可能把一棵樹的中間節點改掛到另一節點, 破壞原集合的結構。正確步驟是先求 root_a 與 root_b,確認兩根不同,再把較小樹的根掛到較大樹 的根。size 只對根有定義,用來控制樹高。

第三項訊號是能否解釋路徑壓縮,而非只背模板。本實作採用路徑折半:find 往根移動時,讓目前節點 改指向祖父節點。新父節點仍在同一棵樹中,因此連通關係不變;後續查找路徑會縮短。路徑折半以迴圈 完成,也避開長路徑上的遞迴深度問題。

最後會檢查計數不變量、複雜度口徑和驗證意識。components 初始為 n,只在兩個不同根合併後減一; 重複合併和自己與自己合併都不能改變它。按大小合併與路徑壓縮同時使用時,單次操作在一串操作上的 均攤時間為 O(α(n)),並非每次呼叫都嚴格是 O(1)。

回答前需要釐清的問題

  • 連線只會增加,還是也會刪除? 只增加時使用標準並查集。任意刪除一條邊後,剩餘邊是否仍讓

兩點連通無法從父指標森林恢復,需要離線演算法或更複雜的動態連通結構。

  • 查詢會在線穿插,還是所有邊一次給定? 在線合併與連通查詢適合並查集;靜態圖只做一次分量

統計時,鄰接串列加 DFS/BFS 的 O(n + m) 解法更直觀,也保留實際邊。

  • union 的回傳值是什麼? 本題回傳是否真的合併。這個布林值可以直接用於環偵測,也能保證

分量計數只更新一次。

  • 非法節點如何處理? 本題拋出 IndexError。如果呼叫端保證編號合法,可以刪除檢查以縮短

競賽程式碼;不能讓 Python 的負索引悄悄指向陣列尾端。

  • 需要查詢分量大小或列出成員嗎? 根上的 size 能以近似常數的均攤時間回答大小;列出全部

成員仍至少與輸出量成正比,基礎並查集沒有維護成員清單。

  • 是否有並行呼叫? 基礎實作會在 find 中寫入父陣列,連查詢都不是唯讀,也不具執行緒安全性。

若必須並行,需要定義鎖定範圍或選用專門的並行並查集演算法。

30 秒回答架構

「我用兩個長度為 n 的陣列:parent[x] 指向父節點,size[root] 記錄根所在樹的大小;初始每個 節點的父節點是自己,分量數是 n。find 沿父指標找根,途中讓節點改指向祖父節點做路徑折半。 union 先找兩個根;相同就回傳 False,不同則把較小樹的根掛到較大樹的根,累加大小,把分量數 減一並回傳 True。連接兩個根不會產生環,路徑折半只在原集合內修改父指標,所以連通關係維持 正確。初始化是 O(n),之後每次操作均攤 O(α(n)),空間是 O(n)。」

分步深入解答

最直接的表示法是替每個節點保存一個分量編號。connected 只需比較兩個編號,但合併兩個分量時 必須掃描整個陣列,把舊編號全部改成新編號,單次 union 會達到 O(n)。另一種樸素方案只用父 指標樹,卻總把一棵根掛到另一棵根而不控制大小;一串不利的合併會形成長鏈,使 find 退化。

建議方案維護三項狀態:

  1. parent[x] 是 x 的父節點,且每棵樹的根滿足 parent[root] == root。
  2. size[root] 是該根對應分量的節點數;非根位置的舊值不再讀取。
  3. components 等於父指標森林中的根數量。

實作如下。_validate 很短且只在此類別使用,因此留在使用位置附近,不另外抽成工具模組。

python
class UnionFind:
    def __init__(self, n: int) -> None:
        if n < 0:
            raise ValueError("n must be non-negative")
        self.parent = list(range(n))
        self.size = [1] * n
        self.components = n

    def _validate(self, x: int) -> None:
        if x < 0 or x >= len(self.parent):
            raise IndexError("node out of range")

    def find(self, x: int) -> int:
        self._validate(x)
        while x != self.parent[x]:
            self.parent[x] = self.parent[self.parent[x]]
            x = self.parent[x]
        return x

    def union(self, a: int, b: int) -> bool:
        root_a = self.find(a)
        root_b = self.find(b)
        if root_a == root_b:
            return False

        if self.size[root_a] < self.size[root_b]:
            root_a, root_b = root_b, root_a

        self.parent[root_b] = root_a
        self.size[root_a] += self.size[root_b]
        self.components -= 1
        return True

    def connected(self, a: int, b: int) -> bool:
        return self.find(a) == self.find(b)

    def count(self) -> int:
        return self.components

正確性可以分三步說明。初始化時每個節點是唯一根,森林包含 n 棵單節點樹,三項不變量都成立。 路徑折半把 x 的父節點改為原父節點的父節點;祖父仍在同一條通往原根的路徑上,所以不會跨集合, 也不會改變 find(x) 的最終根。

合併時只操作兩個根。若根相同,兩節點已經連通,狀態不變。若根不同,把 root_b 指向 root_a 會把兩棵樹連成一棵,而且不可能成環,因為兩根原本屬於不同樹;新根大小等於兩棵樹大小 總和,根數量恰好減一。依此歸納,兩個節點連通若且唯若 find 回傳同一根,count() 始終等於 真實分量數。

按大小合併保證一個節點每次因整棵樹被掛到別處而增加深度時,它所在分量的大小至少加倍;即使不做 路徑壓縮,樹高也至多是 O(log n)。再結合路徑折半,一串 m 次查找和合併在初始化後總計具有 O(m α(n)) 的均攤界。α 是反阿克曼函數,成長極慢;面試中可以說「近似常數均攤時間」,但不應 改寫成嚴格的最壞 O(1)。兩個陣列占 O(n) 空間,迴圈式 find 的額外堆疊空間是 O(1)。

可以用下列操作手動追蹤狀態:

text
n = 6                         count = 6
union(0, 1) -> True           count = 5
union(1, 2) -> True           count = 4
union(3, 4) -> True           count = 3
connected(0, 2) -> True
connected(0, 4) -> False
union(2, 4) -> True           count = 2
union(0, 3) -> False          count = 2

驗證不能只看一個範例。至少涵蓋 n = 0 且不呼叫查詢、n = 1 的自己合併、重複合併、兩個獨立 分量再以一條邊橋接、孤立節點、用相反順序反覆合併,以及 -1 和 n 兩個非法編號。對隨機小圖, 還可以維護一份鄰接串列,每次加邊後用 BFS 計算連通性和分量數,與並查集逐步比對;這是很有效的 差分測試。

若所有 m 條邊一開始就已知,而且只問一次分量數,建立鄰接串列後從每個未走訪節點做 DFS/BFS, 時間和空間都是 O(n + m),程式碼意圖也很清楚。並查集的優勢出現在邊逐條到來、查詢與合併交錯, 或需要在 Kruskal 演算法中快速判斷加入無向邊是否成環。選擇依據是操作模型,不是「看到圖就用 並查集」。

高品質示範回答

「我先確認關係只會新增、查詢會和新增操作交錯,而且 union 要回傳是否真的合併。這樣我會用 並查集;如果只是靜態圖的一次分量統計,DFS 會更直接。

狀態包含 parent、根節點上的 size 和目前根數量 components。初始每個節點指向自己。 find 以迴圈往根移動,每走一步把目前節點改指向祖父,既縮短路徑,也避開遞迴深度問題。union 取得兩根後,如果相同就回傳 False,不改計數;否則把較小樹的根掛到較大樹根上,累加大小並把 計數減一。

這個實作始終是一片森林。路徑折半只把節點連向同一棵樹中的祖先;合併只連接兩棵不同樹的根,所以 不會形成父指標環。每次成功合併恰好把兩棵樹變成一棵,因此分量數也正確。

建立兩個陣列需要 O(n)。按大小合併配合路徑折半後,查找、連通判斷和合併的時間都是 O(α(n)) 均攤,空間是 O(n)。我會重點測試自己合併與重複合併不減少計數、橋接兩個大分量、 孤立節點、空結構和非法負索引;隨機小規模下再用 BFS 作為參照進行差分驗證。」

常見錯誤

  • 直接設定 parent[a] = b → a 可能不是根,原樹的一部分被錯誤拆開或形成難以控制的長鏈

→ 先分別 find,只連接兩個根。

  • 每次都讓第二棵樹掛到第一棵樹 → 不利順序會製造長路徑 → **按根上的 size 或 rank

合併。**

  • find 只回傳 parent[x] → 父節點未必是根,間接連通會被誤判 → 沿父指標走到自指根。
  • 成功或失敗都執行 components -= 1 → 重複合併和自己合併會讓計數愈來愈小 → **只在兩根

不同時更新計數。**

  • 交換根後仍更新舊變數的大小 → size 與實際樹結構分離 → **先決定最終父根,再統一連接和

累加。**

  • 宣稱每次操作最壞 O(1) → 複雜度保證針對操作序列且包含反阿克曼函數 → **回報

O(α(n)) 均攤。**

  • 忽略 Python 負索引 → find(-1) 會存取最後一個節點而非報錯 → 公開實作明確檢查上下界。
  • 把並查集用於任意刪邊 → 父指標沒有保存替代路徑,無法判斷刪除後是否斷開 → **改用離線刪除

處理、回滾並查集或動態連通結構。**

  • 把 connected 當成唯讀方法 → 路徑折半會寫入 parent,並行呼叫可能競爭 → **先定義同步

契約,再選擇全域鎖、分區方案或並行演算法。**

追問與應對

追問 1:如何用並查集偵測無向圖中的環?

逐條處理邊 (u, v)。若 union(u, v) 回傳 False,兩端在加邊前已經連通,新邊會封閉一個環; 若回傳 True,它只是連接兩個原本分開的分量。這個規則只直接適用於無向圖;有向圖的環偵測應使用 三色 DFS、拓撲排序等方法。

追問 2:如果要支援撤銷最近一次合併呢?

使用回滾並查集:保留按大小合併,並把每次真實修改前的父節點、根大小和分量數記錄到堆疊,撤銷時 還原。通常不做路徑壓縮,因為一次 find 會修改路徑上的多個位置,使回滾紀錄膨脹且邊界變複雜。 只按大小合併仍能把樹高限制在 O(log n),適合配合時間線分治處理離線動態連通。

追問 3:如果關係可以任意刪除呢?

標準並查集不能在線回答任意刪邊。若整段操作預先已知,可以把每條邊的有效時間區間放入時間線線段樹, 走訪時配合回滾並查集;只在結尾刪除的邊也可以倒序轉成加邊。真正在線且高頻的插入、刪除和查詢需要 更複雜的全動態連通資料結構。面試中應先確認操作是否離線,因為這會決定問題等級。

追問 4:如何回傳某個分量的大小或全部成員?

大小已經保存在根上,可以實作 size_of(x) = size[find(x)],均攤時間不變。列出成員不能只靠根大小; 掃描全部節點並比較根需要 O(n α(n)),額外維護成員集合又會增加合併成本和記憶體。若需求只是偶爾 匯出,掃描通常更簡單;若每次合併後都要列舉成員,應重新評估資料結構。

追問 5:多個執行緒並行呼叫時如何處理?

最小正確改法是用一把互斥鎖包住 find、union 和 connected,因為路徑折半會寫父陣列;這樣 容易證明,但會序列化全部操作。只有效能量測證明它是瓶頸時,才值得採用以原子比較交換、確定性掛接 順序或分區為基礎的並行並查集。並行版本必須重新證明父指標無環、大小更新與分量計數的原子性,不能 只把陣列換成原子型別。

公開來源

同類題目

相關面試工具

用 Screenshot 處理演算法題

截圖題目後,依序看約束、解法、程式碼、邊界條件和複雜度。

查看工具