开始前,会这些就够了
- 先读 图与 BFS
/DFS,理解邻接表和遍历。 - 再读 并查集为什么不能直接删边,分清分组状态与真实边。
上一题说明:普通并查集没有保存足够的信息,不能把任意删边倒着执行。现在我们保留完整道路图,换一个更明确的问题:如果每次只关闭一条路,哪些路会让原本互通的一部分地方分开?
逐条删掉再遍历能得到答案,但会反复扫描同一张图。本文让一次深度优先遍历记录哪里还有绕回去的路线,并把每条关键边对应到具体输入编号。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- bridges.py:完整可运行示例
- README.md:运行说明与独立练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和产物验证记录
- SHA256SUMS.txt:内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B bridges.py解压到独立练习文件夹,在含 bridges.py 的目录打开终端运行。静态无向多重图,按边ID单独失效;最多128顶点、512边;独立删边BFS参考限5顶点24边。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
删掉哪一条路,地图才会分成两块?
一条“桥边”单独消失后,会让连通块变多。0 与 1 之间原有两条不同的路,0 上还有一条回到自己的环;每条路保留自己的编号。
橙色:原图的桥边;蓝色:原图其他边;灰色虚线:本次移除的边。每次只从当前原图移除一条。
原图桥边:e2。
连通块:1 → 1。请选择一条路进行单独删除实验。
0—1 的两条路可以互相替代;它们各自都不是桥。自环 e6 不连接不同顶点,删掉它不会使图断开。
查看原图的一次 DFS 记录
| 顶点 | 发现时间 tin | low |
|---|---|---|
| 0 | 0 | 0 |
| 1 | 1 | 0 |
| 2 | 2 | 2 |
| 3 | 3 | 2 |
| 4 | 4 | 2 |
这些记录属于删除前的当前原图。沿 DFS 树边 parent → child,只有 low[child] > tin[parent] 才是桥;回到父顶点的另一条平行边必须纳入检查。
固定的无向小图,一次只移除一条带编号的边。没有计算同时删多条路的后果,也没有实现动态在线维护。
先说清楚:删的是一条路,不是一对端点
桥,也叫割边:从当前无向图删除这条边后,连通分量数增加。图原本就有几个互不相通的区域也没关系;判断的是删除前后数量是否增加,不能只问删完以后全图是否连通。
用三个点做例子:边0连接0和1,边1也连接0和1,边2连接1和2。边0与边1是两条独立的路;删掉其中一条,另一条仍在。边2却没有替代路线,所以只有边2是桥。
输入列表的位置就是边ID。即使端点完全相同,两条边也不能被 set 合并成一条。本文按每条边ID分别考虑一次失效,不同时删除两条平行边。
沿路走到底,同时记下哪里还能绕回去
DFS 第一次到达顶点u时,记一个递增编号 tin[u]。沿尚未访问的点走出的边组成遍历树;从已走到的地方发现另一条通向早先顶点的路,就有机会绕过某条树边。tin 是发现先后,不是距离或重要程度。
low[u] 保存这样一个最小发现编号:从u沿树边向下走零步或若干步,再使用至多一条回边,能够触及哪里;也允许直接停在u本身。初始 low[u]=tin[u]。看见已访问邻点v时,用 tin[v] 更新;子节点v全部处理完后,用 low[v] 更新。
这里排除的是进入u时使用的那条边ID。沿原路立即返回不能算备用路线,但另一条平行边可以。无向DFS没有把两个已完成独立子树连接起来却从未发现的跨边;看到较晚的后代不会把当前最小编号错误地减小。
外层仍遍历所有顶点,从每个未访问点启动一次DFS。只有这样才能处理断开的部分和孤立点;一次从0出发的结果不能代表整张图。
为什么必须严格大于,等于就不能判成桥?
考虑树边u→v。若 low[v]>tin[u],说明v所在子树没有另一条路能回到u或u的祖先。切断进入这棵子树的边后,两边就分开,因此这条边是桥。
若 low[v]≤tin[u],则子树里存在一条绕回u或更早祖先的路线,可以绕开这条树边。尤其相等也已经够用:绕回u本身,就已经能替代原来的进入边,所以不能把条件写成大于等于。
例子里 tin=[0,1,2],low=[0,0,2]。1通过第二条0—1边回到0,于是 low[1]=tin[0]=0,边0不是桥;2没有别的出口,low[2]=2>tin[1]=1,边2是桥。程序还逐条输出 tree_checks,让这个比较可以直接核对。
不是树边的那条额外连接,本来就配合树上的路线构成绕路,不会单独成为桥。自环从一个点回到自身,删掉也不会改变分量数。
只记父顶点,会怎样误伤平行边?
容易写错的一句是:只要邻点等于父顶点,就跳过。它会把进入边和另一条平行边一起丢掉,导致low看不到真实存在的绕路。源码中的 bad_parent_vertex_version 故意保留这一错误,供对照使用。
在三条边的小例子上,正确实现输出 [2],错误版本却输出 [0,2]。对边0做真实删除后,BFS仍能经边1到达另一端,因此这不是两个算法随意选择了不同答案,而是错误版本把一条有备用路的边误报为桥。
修正方式是把邻接项保存为(邻点,边ID),递归时传进入边ID,只跳过这一项。父顶点相同而ID不同的边继续按已访问邻点处理。
运行完整程序,再让真实删边作裁判
保存为 bridges.py,执行 python -B bridges.py。parallel_example 中 bridge_ids 为 [2],bad_parent_vertex_ids 为 [0,2];disconnected_bridge_ids 为 [3],另外还保留孤立点所在的分量。下面是此次实际运行输出。
独立参考不使用tin或low。它先用BFS数原图分量,然后每次只移除一个边ID,重建剩余邻接表并再次计数。增加了分量的边才进入参考答案。
程序枚举0到4个顶点的所有这些小多重图:每对不同顶点有0、1或2条边,每个点有0或1个自环。共11,895张图,参考算法实际执行94,309次单边删除;再加最大128点路径、512个自环和非法输入,共通过47,599项检查。图数、删边次数与断言数是三种不同计数。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Static bridges of an undirected multigraph, with edge-ID semantics."""
from collections import deque
from itertools import combinations, product
import json
def checked_graph(n, edges):
if type(n) is not int or not 0 <= n <= 128:
raise ValueError("n must be an integer in 0..128")
if not isinstance(edges, (list, tuple)) or len(edges) > 512:
raise ValueError("edges must be a list/tuple of at most 512 pairs")
result = []
for edge in edges:
if not isinstance(edge, (list, tuple)) or len(edge) != 2:
raise ValueError("each edge must contain two endpoints")
u, v = edge
if any(type(x) is not int or not 0 <= x < n for x in (u, v)):
raise ValueError("endpoint outside graph")
result.append((u, v))
return result
def find_bridges(n, edges):
edges = checked_graph(n, edges)
adjacency = [[] for _ in range(n)]
for edge_id, (u, v) in enumerate(edges):
adjacency[u].append((v, edge_id))
adjacency[v].append((u, edge_id))
tin, low = [-1] * n, [-1] * n
bridge_flags = [False] * len(edges)
tree_checks = []
timer = 0
def dfs(u, incoming_edge):
nonlocal timer
tin[u] = low[u] = timer
timer += 1
for v, edge_id in adjacency[u]:
if edge_id == incoming_edge:
continue
if tin[v] != -1:
low[u] = min(low[u], tin[v])
else:
dfs(v, edge_id)
low[u] = min(low[u], low[v])
bridge_flags[edge_id] = low[v] > tin[u]
tree_checks.append({"edge_id": edge_id, "parent": u,
"child": v, "tin_parent": tin[u],
"low_child": low[v],
"bridge": bridge_flags[edge_id]})
roots = []
for root in range(n):
if tin[root] == -1:
roots.append(root)
dfs(root, -1)
# Scanning flags keeps original edge-ID order without an extra sorting cost.
return {"bridge_ids": [i for i, yes in enumerate(bridge_flags) if yes],
"tin": tin, "low": low, "roots": roots, "tree_checks": tree_checks}
def bad_parent_vertex_version(n, edges):
"""Deliberate bug: skips every edge to the parent vertex."""
edges = checked_graph(n, edges)
adjacency = [[] for _ in range(n)]
for edge_id, (u, v) in enumerate(edges):
adjacency[u].append((v, edge_id))
adjacency[v].append((u, edge_id))
tin, low = [-1] * n, [-1] * n
flags = [False] * len(edges)
timer = 0
def dfs(u, parent):
nonlocal timer
tin[u] = low[u] = timer
timer += 1
for v, edge_id in adjacency[u]:
if v == parent: # WRONG for parallel edges.
continue
if tin[v] != -1:
low[u] = min(low[u], tin[v])
else:
dfs(v, u)
low[u] = min(low[u], low[v])
flags[edge_id] = low[v] > tin[u]
for root in range(n):
if tin[root] == -1:
dfs(root, -1)
return [i for i, yes in enumerate(flags) if yes]
def component_count(n, edges, skipped_id=None):
# Independent oracle: rebuild actual remaining roads, then count BFS groups.
adjacency = [[] for _ in range(n)]
for edge_id, (u, v) in enumerate(edges):
if edge_id != skipped_id:
adjacency[u].append(v)
adjacency[v].append(u)
seen = set()
count = 0
for start in range(n):
if start in seen:
continue
count += 1
seen.add(start)
queue = deque([start])
while queue:
u = queue.popleft()
for v in adjacency[u]:
if v not in seen:
seen.add(v)
queue.append(v)
return count
def deletion_oracle(n, edges):
if n > 5 or len(edges) > 24:
raise ValueError("deletion oracle limited to five vertices and 24 edges")
before = component_count(n, edges)
after = [component_count(n, edges, i) for i in range(len(edges))]
return [i for i, count in enumerate(after) if count > before], before, after
def main():
checks = 0
graphs = 0
deletions = 0
def check(condition):
nonlocal checks
if not condition:
raise AssertionError("bridge verification failed")
checks += 1
for n in range(5):
pairs = list(combinations(range(n), 2))
for multiplicities in product((0, 1, 2), repeat=len(pairs)):
nonloops = [edge for edge, count in zip(pairs, multiplicities)
for _ in range(count)]
for loops in product((0, 1), repeat=n):
edges = nonloops + [(v, v) for v, yes in enumerate(loops) if yes]
result = find_bridges(n, edges)
wanted, before, after = deletion_oracle(n, edges)
check(result["bridge_ids"] == wanted)
check(sorted(result["tin"]) == list(range(n)))
check(all(0 <= low <= tin for low, tin in zip(result["low"], result["tin"])))
check(len(result["roots"]) == before)
graphs += 1
deletions += len(after)
parallel_edges = [(0, 1), (0, 1), (1, 2)]
parallel = find_bridges(3, parallel_edges)
bad = bad_parent_vertex_version(3, parallel_edges)
check(parallel["bridge_ids"] == [2])
check(bad == [0, 2])
check(parallel["low"] == [0, 0, 2])
disconnected_edges = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 4)]
disconnected = find_bridges(6, disconnected_edges)
check(disconnected["bridge_ids"] == [3])
check(len(disconnected["roots"]) == 3)
path = [(i, i + 1) for i in range(127)]
check(find_bridges(128, path)["bridge_ids"] == list(range(127)))
check(find_bridges(1, [(0, 0)] * 512)["bridge_ids"] == [])
invalid = [(True, []), (-1, []), (129, []), (2.0, []), (2, None),
(2, [(0, 2)]), (2, [(False, 1)]), (2, [(0, 1.0)]),
(2, [(0,)]), (2, ["01"]), (0, [(0, 0)]),
(1, [(0, 0)] * 513)]
for args in invalid:
try:
find_bridges(*args)
except ValueError:
check(True)
else:
raise AssertionError("invalid graph accepted")
report = {"checks_passed": checks, "exhaustive_multigraphs": graphs,
"oracle_single_edge_deletions": deletions,
"parallel_example": {"edges": parallel_edges, **parallel,
"bad_parent_vertex_ids": bad},
"disconnected_bridge_ids": disconnected["bridge_ids"],
"disconnected_roots": disconnected["roots"],
"maximum_path_vertices_checked": 128,
"invalid_inputs_rejected": len(invalid),
"scope": "static undirected graph; delete one edge ID at a time; no dynamic maintenance"}
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()
怎样运行:在独立练习文件夹中执行 python -B bridges.py。2026-10-18 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。静态无向多重图,按边ID单独失效;最多128顶点、512边;独立删边BFS参考限5顶点24边。
运行后对照结果
{
"checks_passed": 47599,
"exhaustive_multigraphs": 11895,
"oracle_single_edge_deletions": 94309,
"parallel_example": {
"edges": [
[
0,
1
],
[
0,
1
],
[
1,
2
]
],
"bridge_ids": [
2
],
"tin": [
0,
1,
2
],
"low": [
0,
0,
2
],
"roots": [
0
],
"tree_checks": [
{
"edge_id": 2,
"parent": 1,
"child": 2,
"tin_parent": 1,
"low_child": 2,
"bridge": true
},
{
"edge_id": 0,
"parent": 0,
"child": 1,
"tin_parent": 0,
"low_child": 0,
"bridge": false
}
],
"bad_parent_vertex_ids": [
0,
2
]
},
"disconnected_bridge_ids": [
3
],
"disconnected_roots": [
0,
3,
5
],
"maximum_path_vertices_checked": 128,
"invalid_inputs_rejected": 12,
"scope": "static undirected graph; delete one edge ID at a time; no dynamic maintenance"
}把一次扫描和反复删边的成本分开
主算法建立两份方向相反的邻接项,DFS访问每个顶点和每条邻接项,时间为O(V+E)。结果用布尔数组标记,再按原边ID扫描输出,没有额外排序。图、状态、树边检查记录和递归栈合计占O(V+E)空间。
逐边删除的参考方法为每条边重新建图和BFS,时间是O(E(V+E))量级;穷举所有小图还会进一步放大工作量。它用于找实现错误,不能拿它的速度代表正式算法。
教学函数限定最多128个顶点、512条边,并在当前默认Python递归配置下实跑了128点路径。改变递归限制、放大图规模或转成线上服务,需要另外检查栈与资源限制;检查次数不是性能基准。
这份清单只回答当前图的单边失效
桥清单说明每条边单独移除时的结果。两条都不是桥的边一起移除,仍可能让图断开;新增或删除别的边以后,原来的桥清单也可能过时。本文没有维护动态图,也没有计算割点、同时失效多条边或有向图的强桥。
参考资料中,NetworkX和Schmidt论文还展示链分解视角。它与这里的low值实现是不同路线;论文相关证明先假定简单连通图,本文的多重图输入与逐边BFS核对另行写清。我们没有运行NetworkX或实现链分解。
源码只依赖Python标准库,已在Python 3.14.0运行,并通过3.10语法解析;没有在3.10解释器上另跑。这里验证的是图算法,不包含真实道路故障概率、网络设备配置或可靠性承诺。
记住这些要点
- 桥由单独删除后分量数增加定义,原图不必连通。
- low[child]>tin[parent] 才是桥;相等说明存在绕回父点的路线。
- 平行边必须保留ID,只跳过进入子节点的那条边。
- 用逐边删除后的BFS核对,能独立发现父顶点跳过错误。
别把例子用过头
- 把删除一条边写成删除同端点的全部边。
- 把严格大于改成大于等于。
- 只从一个起点遍历,遗漏其他分量。
- 把静态单边失效清单用于同时多边失效。
自己试一小步
换一张图和边的排列,桥的真实身份会改变吗?
- 建立六个顶点与边列表[(0,1),(1,2),(2,0),(2,3),(3,4),(3,4),(4,5)],先画图并预测。
- 分别添加一个自环、颠倒每条边端点、重新排列整个边列表,保存每次输入与输出。
- 对每次运行都逐ID删除并数分量,把输出ID映射回当次列表后比较。
做到这些,就算完成
- 每个报告的桥都使分量数恰好增加1,未报告边的单独删除不增加分量。
- 端点颠倒不改变桥对应的实际边;列表重排后允许ID变化,必须检查映射。
- 自环不会被报告为桥,两条平行边的身份始终分别保存。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- CP-Algorithms 作者:Finding bridges in O(N+M) (新标签页)↗
桥定义、tin
/low判据、全分量遍历,以及多重边必须区分入边。 - NetworkX 官方:bridges (新标签页)↗
连通分量增加的桥定义、无向图范围,以及平行边排除。
- Jens M. Schmidt:A Simple Test on 2-Vertex- and 2-Edge-Connectivity (新标签页)↗
桥与环/替代路线的关系,并提供与 low 值不同的链分解视角。