开始前,会这些就够了
- 知道点和边可以表示关系
- 会读Python列表与类
- 了解队列的先进先出
- 需要补基础时:先补图与广度优先搜索
把两座村庄之间修上公路后,你只想快速知道哪些村庄已经能互相到达。并查集很适合把原来分开的两组连成一组。但如果某段公路关闭,能否把上次合并简单反过来?
问题往往不是少写了一个 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 使用实际边集合行走,因此能作为这个小范围的独立对照。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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 直接代表全部业务关系。
记住这三点
- 并查集维护连通组;父指针树不是原始图的道路图。
- 不同图能被压成相同分组,因此任意删边后需要的信息可能已经丢失。
- 回滚、重建和一般全动态连通性有不同前提,不能互相冒名。
别把例子用过头
- 把删除图中的边等同于删除某个内部父指针。
- 修改边集合后继续相信从旧边构建的分组。
- 把普通并查集的快速合并保证推广到重建、任意删边或带重复边的业务图。
自己试一小步
给图增加两条同端点的独立道路后,怎样表达关闭其中一条?
- 自行设计边的身份或计数表示,至少给两个点之间安排两条不同道路。
- 先关闭其中一条,再关闭另一条,分别预测连通性。
- 保留一个独立搜索方法作对照,并写明自环、重复关闭及不存在道路的处理方式。
做到这些,就算完成
- 一次关闭操作不会误删另一条仍存在的道路。
- 查询依据当前有效边,而非过期分组。
- 明确这是你扩展的图契约;本节不提供实现或结果答案。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Sedgewick / Wayne:UF.java 作者源码 (新标签页)↗
parent、rank、find 的路径折半与 union 合并集合;查询和合并的适用操作范围。
- Sedgewick / Wayne:BreadthFirstPaths.java 作者源码 (新标签页)↗
按真实邻接关系从源点做广度优先遍历,支持本篇独立搜索对照的思路。
- Gibb 等:Dynamic graph connectivity (新标签页)↗
全动态连通性的问题包含在线插边、删边和路径是否存在的查询,区别于只合并集合。