云端 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 只给一个客户,非虚拟机。先对照工程规模选择配置,再按天验证自己的依赖、缓存与打包流程。