雲端 Mac 上的 Swift 並查集測試:連通性與元件計數

雲端 Mac 上的 Swift 並查集測試:連通性與元件計數

雲端 Mac 上的 Swift 並查集測試:連通性與元件計數

離線聯絡人分組、裝置關聯與圖片分群,經常需要判斷「兩個物件是否屬於同一組」。如果連線只會新增、不會刪除,並查集比反覆走訪所有關係更合適;如果還需要說明連線路徑,或經常撤銷連線,就應保留原始圖形,改用圖形走訪或專門的動態資料結構。將這段純 Swift 邏輯納入雲端 Mac 的建置前檢查,就能先找出分組錯誤,再進入耗時的應用程式建置流程。

先定義輸入與可觀察的結果

本文使用無向連線,節點編號固定為 0..<n。業務邏輯層必須先將穩定的識別碼對應到整數,不能使用陣列中目前的顯示位置,否則清單重新排序就會改變物件身分。對應表與連線資料應來自同一份快照。

驗收只觀察三件事:兩個節點是否連通、剩餘的連通元件數,以及某個節點所屬元件的大小。重複連線與自環都不能減少元件數;只有原本不連通的兩組成功合併時,計數才減一。

根節點只是實作細節,不是業務上的分組編號。同一組連線若以不同順序輸入,代表元可能改變,但連通關係必須保持一致。

無效索引應在匯入層拒絕,並記錄行號或資料識別碼。以下實作用前置條件表達內部約束,不會將靜默忽略錯誤輸入視為容錯機制。

撰寫可獨立執行的實作

將以下內容儲存為 union_find_check.swift。依大小合併會將小樹接到大樹下,路徑壓縮則讓查詢經過的節點直接指向根。大小資訊只在根節點上有效,不能直接讀取任意位置的 sizes。

struct UnionFind {
    private var parent: [Int]
    private var sizes: [Int]
    private(set) var components: Int

    init(_ count: Int) {
        precondition(count >= 0)
        parent = Array(0..<count)
        sizes = Array(repeating: 1, count: count)
        components = count
    }

    mutating func root(_ node: Int) -> Int {
        precondition(parent.indices.contains(node))
        var r = node
        while parent[r] != r {
            r = parent[r]
        }
        var current = node
        while parent[current] != current {
            let next = parent[current]
            parent[current] = r
            current = next
        }
        return r
    }

    @discardableResult
    mutating func union(_ a: Int, _ b: Int) -> Bool {
        var ra = root(a)
        var rb = root(b)
        guard ra != rb else { return false }
        if sizes[ra] < sizes[rb] {
            swap(&ra, &rb)
        }
        parent[rb] = ra
        sizes[ra] += sizes[rb]
        components -= 1
        return true
    }

    mutating func connected(_ a: Int, _ b: Int) -> Bool {
        let ra = root(a)
        let rb = root(b)
        return ra == rb
    }

    mutating func size(of node: Int) -> Int {
        let r = root(node)
        return sizes[r]
    }
}

查詢會改寫路徑,因此方法標記為 mutating。不要將同一個實例直接交給多個並行任務修改;可以將一次分組計算放在單一任務內,完成後輸出不可變的結果。若以 actor 包裝,也應讓一批連線在不跨越暫停點的操作中處理完畢。

用圖形走訪交叉核對每次合併

只寫下幾個預期的根節點編號,很容易讓測試與實作出現同一種錯誤。更可靠的參照是鄰接串列走訪:它不使用父節點指標,也不依賴合併策略。將以下程式碼接在同一個檔案的末尾,逐次加入連線,並核對所有節點配對。

func reachable(_ start: Int, in graph: [[Int]]) -> Set<Int> {
    var seen: Set<Int> = [start]
    var stack = [start]
    while let node = stack.popLast() {
        for next in graph[node] {
            if seen.insert(next).inserted {
                stack.append(next)
            }
        }
    }
    return seen
}

func verify(_ edges: [(Int, Int)], count: Int) {
    var uf = UnionFind(count)
    var graph = Array(repeating: [Int](), count: count)

    for (a, b) in edges {
        let expectedMerge = !reachable(a, in: graph).contains(b)
        precondition(uf.union(a, b) == expectedMerge)
        graph[a].append(b)
        graph[b].append(a)

        var unseen = Set(0..<count)
        var expectedComponents = 0
        for node in 0..<count {
            let group = reachable(node, in: graph)
            if unseen.contains(node) {
                expectedComponents += 1
                unseen.subtract(group)
            }
            precondition(uf.size(of: node) == group.count)
            for other in 0..<count {
                precondition(
                    uf.connected(node, other) == group.contains(other)
                )
            }
        }
        precondition(uf.components == expectedComponents)
    }
}

let edges = [(0, 1), (2, 3), (1, 2), (0, 3), (4, 4)]
verify(edges, count: 6)
verify(Array(edges.reversed()), count: 6)
precondition(UnionFind(0).components == 0)

這裡同時涵蓋了跨組合併、重複連線、孤立節點與自環。反向執行並不是要求中間狀態相同,而是要求每一步都符合當下的圖形。最終分組也不應受到輸入順序影響。

小規模資料適合在每一步進行完整核對;大規模資料則先在小圖形上窮舉邊的子集,再以固定種子進行抽樣。發生失敗時,應儲存完整的邊序列,不能只保留隨機種子,因為產生器或產生規則也可能改變。

納入建置前檢查,並區分正確性與效能

先確認節點已安裝所需的工具鏈,再於指令碼所在目錄執行:

set -eu
xcrun swift --version
xcrun swift union_find_check.swift

指令碼正常結束即代表通過,前置條件失敗則會回傳非零狀態。將指令碼與測試資料提交至儲存庫,讓流水線直接使用結束狀態,不必解析本地化輸出。不要使用會移除前置條件檢查的 -Ounchecked 來編譯這個驗收程式。

如何單獨驗收路徑壓縮

連通性正確,不代表路徑壓縮已經生效。可以在測試專用建置中加入唯讀的深度檢查:先建立多層樹,查詢其中一個深層節點,再確認它已直接指向根。不要為了測試,就在業務介面開放可寫入的父節點陣列。

效能檢查應另外使用較大的輸入,分別記錄首次查詢與重複查詢。不要將完整圖形走訪的參照運算納入計時,也不要拿某次雲端執行的毫秒數作為所有機器的硬性門檻;比較父節點指標的存取次數,是更穩定的檢查方式。

將失敗原因定位到明確的邊界

元件數異常,通常是因為重複合併時仍將計數減一;元件大小偏大,應先檢查自環與重複邊;合併後部分節點仍不連通,則要檢查是否誤改了輸入節點的父節點指標,而不是根節點的父節點指標。針對每種故障保留一個最小邊序列,比留下大量業務資料更容易進行迴歸測試。

輸入層應另外測試負數、等於節點總數的編號,以及未登錄的識別碼,確認它們在進入資料結構前就被拒絕。空集合不允許查詢節點,但應正常回報零個元件。

最後要明確界定刪除操作的能力邊界:路徑壓縮會丟失原始連線的形狀,不能只修改一個父節點指標就實現「解除關係」。連線數量不大時,從剩餘的邊重建最簡單;需要回滾時,應採用具備變更日誌的獨立設計,並重新評估路徑壓縮策略。驗收通過的標準是:有效輸入能得到正確分組,無效輸入會明確失敗,超出能力範圍的操作不會被偽裝成成功。

常見問題

測試時可以固定根節點編號嗎?

不宜。根節點會隨合併順序與策略改變;應驗證兩點是否連通、元件數量及元件大小。

刪除連線後能直接調整 parent 陣列嗎?

不能。parent 陣列不保存原始連線。靜態資料可重新建構;需要頻繁復原時,應另行設計可回滾的資料結構。

用你的建置工作測試獨享節點

整台實體 Mac mini 僅供一位客戶使用,並非虛擬機器。先依專案規模挑選規格,再按天測試自己的相依套件、快取與打包流程。