qinghuan
← 回到知识探索

计算机趣闻 / 可下载小项目

连起来很快,删一条边为什么不能把并查集倒着操作?

两张不同的图能留下完全相同的分组状态,却在删除同一条边后给出相反结果。用这个反例看清并查集保存了什么。

展开本文目录
  1. 下载完整练习包
  2. 它保存的是同组关系,不是完整道路地图
  3. 同一份分组状态,可能来自两张不同的图
  4. 为什么把某个父指针改回自己,仍然不对?
  5. 保留真实边,删除后重新建立分组
  6. 重建很直接,但需要承认它的代价
  7. 先问关系会怎样变化,再决定用什么结构
  8. 记住这三点
  9. 别把例子用过头
  10. 自己试一小步
  11. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 1 个解释
并查集
把已经连成一组的点归到同一代表,方便快速问“它们同组吗”。
打开完整术语小抄 ↗

把两座村庄之间修上公路后,你只想快速知道哪些村庄已经能互相到达。并查集很适合把原来分开的两组连成一组。但如果某段公路关闭,能否把上次合并简单反过来?

问题往往不是少写了一个 remove 方法,而是目前保存的分组状态没有记住所有道路。本文用一条线和一个三角形做反例,再给出保存边、删除后重建的完整小程序。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • union_find.py:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B union_find.py

解压到独立练习文件夹,在含 union_find.py 的目录打开终端运行。原创无向简单图并查集,保存边后重新构建,并与BFS核对;没有实现一般在线全动态连通性、回滚并查集或带重边的删除计数。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

它保存的是同组关系,不是完整道路地图

并查集为每个顶点保存一个父指针,沿父指针找到组的代表。union 把两组连接,find 找到代表;两个点代表相同,就说明已经被归入同一连通组。代表是哪一个编号通常是实现选择,不是具有特殊业务身份的村长。

本文按集合大小把较小组挂到较大组,并在查找时缩短父指针链。这样的父指针是数据结构内部关系,不要求每一条都对应原图中的真实边。参考作者代码使用按秩合并,本文是独立编写的按大小版本,不能把两份实现的细节混在一起。

同一份分组状态,可能来自两张不同的图

第一张图是一条线:0—1—2。第二张图在这条线外又补了一条 0—2,成为三角形。两张图里三个点都连通;按本例顺序合并后,父指针数组也都为 [0,0,0]。加入已经同组的边,不需要再改变分组。

现在从两张图都删掉边 0—1。线形图中的 0 被隔开,0 与 2 不再连通;三角形里还有 0—2,所以仍然连通。删的是同一条边,删除前的数组也一样,正确答案却不同。这说明仅凭那份数组不可能分辨两种情况:额外道路信息此前已经没有被保存进去。

为什么把某个父指针改回自己,仍然不对?

父指针经过按大小合并和路径压缩后,表达的是快速查组的结构。图里删除 0—1,不等价于把 parent[1] 改成 1;这样既可能错误切开仍有绕路的顶点,也可能把本该断开的其他顶点继续留在原组。

因此要区分任意删除一条历史边,与按严格逆序回退已经记录的合并操作。后者可以设计回滚结构,但需要保存足够的历史并控制路径压缩等操作;它不是给当前普通并查集补一行赋值就会得到的能力。本文没有实现回滚。

保留真实边,删除后重新建立分组

保存为 union_find.py,运行 python -B union_find.py。程序分别创建线形图和三角形,删除相同边后重建并查集,再显示两个不同的连通性结果。旧的 line_uf 仍说连通,是因为删除边集合并没有自动通知它更新。

为了避免只验证一个手工例子,程序枚举四个顶点的全部 64 张无向简单图,对每张图的 16 个有序点对,把并查集查询与独立 BFS 搜索比较。再检查空图、自环和非法顶点等情况,合计 1029 项核对。BFS 使用实际边集合行走,因此能作为这个小范围的独立对照。

展开收起代码 · 小实验python · 102 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Keep graph edges and compare rebuilt union-find with BFS after removals."""
import itertools
import json
from collections import deque


class UnionFind:
    def __init__(self, n):
        if type(n) is not int or n < 0:
            raise ValueError("n must be a nonnegative integer")
        self.parent = list(range(n))
        self.size = [1] * n

    def find(self, vertex):
        if type(vertex) is not int or not 0 <= vertex < len(self.parent):
            raise ValueError("invalid vertex")
        while vertex != self.parent[vertex]:
            self.parent[vertex] = self.parent[self.parent[vertex]]
            vertex = self.parent[vertex]
        return vertex

    def union(self, a, b):
        a, b = self.find(a), self.find(b)
        if a == b:
            return False
        if self.size[a] < self.size[b]:
            a, b = b, a
        self.parent[b] = a
        self.size[a] += self.size[b]
        return True

    def connected(self, a, b):
        return self.find(a) == self.find(b)


def rebuild(n, edges):
    uf = UnionFind(n)
    for a, b in sorted(edges):
        uf.union(a, b)
    return uf


def reachable(n, edges, source, target):
    adjacency = [[] for _ in range(n)]
    for a, b in edges:
        adjacency[a].append(b)
        adjacency[b].append(a)
    queue, seen = deque([source]), {source}
    while queue:
        vertex = queue.popleft()
        if vertex == target:
            return True
        for neighbor in adjacency[vertex]:
            if neighbor not in seen:
                seen.add(neighbor)
                queue.append(neighbor)
    return False


def main():
    # Both input graphs produce the same partition, but deleting (0, 1) differs.
    line = {(0, 1), (1, 2)}
    triangle = line | {(0, 2)}
    line_uf, triangle_uf = rebuild(3, line), rebuild(3, triangle)
    assert line_uf.parent == triangle_uf.parent
    before = list(line_uf.parent)
    line.remove((0, 1))
    triangle.remove((0, 1))
    stale = line_uf.connected(0, 2)
    after_line = rebuild(3, line).connected(0, 2)
    after_triangle = rebuild(3, triangle).connected(0, 2)
    assert (stale, after_line, after_triangle) == (True, False, True)
    checks = 2
    # Independent oracle: all 64 undirected simple graphs on 4 vertices.
    possible_edges = list(itertools.combinations(range(4), 2))
    for mask in range(1 << len(possible_edges)):
        edges = {edge for index, edge in enumerate(possible_edges) if mask & (1 << index)}
        uf = rebuild(4, edges)
        for a, b in itertools.product(range(4), repeat=2):
            assert uf.connected(a, b) == reachable(4, edges, a, b)
            checks += 1
    self_loop = rebuild(2, {(0, 0)})
    assert self_loop.connected(0, 0) and not self_loop.connected(0, 1)
    checks += 1
    assert rebuild(0, set()).parent == []
    checks += 1
    try:
        UnionFind(2).find(-1)
        raise AssertionError("negative vertex accepted")
    except ValueError:
        checks += 1
    print(json.dumps({"checks_passed": checks, "same_parent_before_delete": before,
                      "stale_union_find_says_connected": stale,
                      "line_after_delete_connected": after_line,
                      "triangle_after_delete_connected": after_triangle,
                      "oracle_graphs": 64,
                      "boundary": "simple undirected graphs; rebuilding, not a fully dynamic algorithm"},
                     indent=2, ensure_ascii=False))


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中执行 python -B union_find.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。原创无向简单图并查集,保存边后重新构建,并与BFS核对;没有实现一般在线全动态连通性、回滚并查集或带重边的删除计数。

运行后对照结果
{
  "checks_passed": 1029,
  "same_parent_before_delete": [
    0,
    0,
    0
  ],
  "stale_union_find_says_connected": true,
  "line_after_delete_connected": false,
  "triangle_after_delete_connected": true,
  "oracle_graphs": 64,
  "boundary": "simple undirected graphs; rebuilding, not a fully dynamic algorithm"
}

重建很直接,但需要承认它的代价

这份方案保留顶点与边,删除后重新对剩余边做 union。它容易解释,也适合小图或删除很少的任务;但如果每秒都在大量增删,就不应把反复扫描全部边说成高效在线方案。本文没有做大图压测,也没有实现论文里的全动态算法。

参考并查集的合并与查找在适当优化下有很小的摊还代价,但那个结论针对相应的 union/find 操作序列,不能跨过条件直接套到任意删除。本例重建函数还为了输出可复核而排序边,因此它的开销还包含排序;不能照抄标准并查集公式当作整个程序复杂度。

先问关系会怎样变化,再决定用什么结构

如果关系只会增加,普通并查集往往很自然;如果要查询真实路径,必须保留更多图信息;如果允许任意删边,就需要重建、离线处理或更专门的动态结构。目标和更新方式一起决定数据结构,而不只是看一个操作在示例里有多快。

程序把图当作无向简单图,边存在与否只有一个状态。若两地之间允许两条独立公路,关闭其中一条不等于完全删掉该点对;这时还得为边设置身份或数量,不能继续用一个 set 直接代表全部业务关系。

记住这三点

  • 并查集维护连通组;父指针树不是原始图的道路图。
  • 不同图能被压成相同分组,因此任意删边后需要的信息可能已经丢失。
  • 回滚、重建和一般全动态连通性有不同前提,不能互相冒名。

别把例子用过头

  • 把删除图中的边等同于删除某个内部父指针。
  • 修改边集合后继续相信从旧边构建的分组。
  • 把普通并查集的快速合并保证推广到重建、任意删边或带重复边的业务图。

自己试一小步

给图增加两条同端点的独立道路后,怎样表达关闭其中一条?

  1. 自行设计边的身份或计数表示,至少给两个点之间安排两条不同道路。
  2. 先关闭其中一条,再关闭另一条,分别预测连通性。
  3. 保留一个独立搜索方法作对照,并写明自环、重复关闭及不存在道路的处理方式。

做到这些,就算完成

  • 一次关闭操作不会误删另一条仍存在的道路。
  • 查询依据当前有效边,而非过期分组。
  • 明确这是你扩展的图契约;本节不提供实现或结果答案。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Sedgewick / Wayne:UF.java 作者源码 (新标签页)↗Robert Sedgewick、Kevin Wayne / Algorithms 4e · 英文

    parent、rank、find 的路径折半与 union 合并集合;查询和合并的适用操作范围。

    2026-10-17 实际打开核对:实际读取 find、union 与类契约。教材站正文请求失败后改读作者仓库原文件;本文未复制其实现。

  • Sedgewick / Wayne:BreadthFirstPaths.java 作者源码 (新标签页)↗Robert Sedgewick、Kevin Wayne / Algorithms 4e · 英文

    按真实邻接关系从源点做广度优先遍历,支持本篇独立搜索对照的思路。

    2026-10-17 实际打开核对:实际读取 bfs 循环及 hasPathTo 契约;本文只求可达性,没有实现其全部最短路径接口。

  • Gibb 等:Dynamic graph connectivity (新标签页)↗David Gibb、Bruce Kapron、Valerie King、Nolan Thorn · 英文

    全动态连通性的问题包含在线插边、删边和路径是否存在的查询,区别于只合并集合。

    2026-10-17 实际打开核对:实际阅读论文摘要与书目信息;仅用来界定后续研究问题,未声称审阅全文、实现算法或复现性能。