qinghuan
← 回到知识探索

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

不逐条删边,怎样一次找出所有关键边?两条平行边会改变什么

用发现时间和 low 值找出单独断开就会拆散连通分量的边。给每条路保留身份,再用逐边删除后的 BFS 独立核对。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先说清楚:删的是一条路,不是一对端点
  4. 沿路走到底,同时记下哪里还能绕回去
  5. 为什么必须严格大于,等于就不能判成桥?
  6. 只记父顶点,会怎样误伤平行边?
  7. 运行完整程序,再让真实删边作裁判
  8. 把一次扫描和反复删边的成本分开
  9. 这份清单只回答当前图的单边失效
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
桥(割边)
单独删掉这一条边,会让图的连通分量数增加。
low 值
从当前点沿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 上还有一条回到自己的环;每条路保留自己的编号。

e0e1e2e3e4e5e601234

橙色:原图的桥边;蓝色:原图其他边;灰色虚线:本次移除的边。每次只从当前原图移除一条。

原图桥边:e2。

连通块:1 → 1。请选择一条路进行单独删除实验。

0—1 的两条路可以互相替代;它们各自都不是桥。自环 e6 不连接不同顶点,删掉它不会使图断开。

查看原图的一次 DFS 记录
顶点发现时间 tinlow
000
110
222
332
442

这些记录属于删除前的当前原图。沿 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项检查。图数、删边次数与断言数是三种不同计数。

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

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

小实验 · python
"""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核对,能独立发现父顶点跳过错误。

别把例子用过头

  • 把删除一条边写成删除同端点的全部边。
  • 把严格大于改成大于等于。
  • 只从一个起点遍历,遗漏其他分量。
  • 把静态单边失效清单用于同时多边失效。

自己试一小步

换一张图和边的排列,桥的真实身份会改变吗?

  1. 建立六个顶点与边列表[(0,1),(1,2),(2,0),(2,3),(3,4),(3,4),(4,5)],先画图并预测。
  2. 分别添加一个自环、颠倒每条边端点、重新排列整个边列表,保存每次输入与输出。
  3. 对每次运行都逐ID删除并数分量,把输出ID映射回当次列表后比较。

做到这些,就算完成

  • 每个报告的桥都使分量数恰好增加1,未报告边的单独删除不增加分量。
  • 端点颠倒不改变桥对应的实际边;列表重排后允许ID变化,必须检查映射。
  • 自环不会被报告为桥,两条平行边的身份始终分别保存。

查原始资料

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

  • CP-Algorithms 作者:Finding bridges in O(N+M) (新标签页)↗CP-Algorithms 贡献者 · 英文

    桥定义、tin/low 判据、全分量遍历,以及多重边必须区分入边。

    2026-10-18 已实际核读:Definition、Algorithm、Implementation 正文行245–315;包括 parent_skipped 与传入边索引两种处理。

  • NetworkX 官方:bridges (新标签页)↗NetworkX Developers · 英文

    连通分量增加的桥定义、无向图范围,以及平行边排除。

    2026-10-18 已实际核读:bridges 契约与 Notes 行95–148,尤其 root 范围和多重边处理。该库此处使用链分解;没有安装或运行 NetworkX。

  • Jens M. Schmidt:A Simple Test on 2-Vertex- and 2-Edge-Connectivity (新标签页)↗Jens M. Schmidt / MPI für Informatik · 英文

    桥与环/替代路线的关系,并提供与 low 值不同的链分解视角。

    2026-10-18 已实际核读:论文第2页 Notation 与简单连通图前提,第4页 Lemma 4 及证明。没有把论文的简单图算法直接称为本文多重图实现,也未运行链分解。