跳转至

第 112A 章 割点与桥

前置:强连通分量

有向图讲强连通,无向图讲割点(cut vertex)和桥(bridge)—— 「删掉哪个点 / 哪条边会让网络断开」,是网络可靠性的标准建模。

判定条件建立在 dfn 与 low 之上,那两个数组以及 DFS 生成树的四类边 在 强连通分量 的前两节,本章直接使用。 差别只在一个符号:割点用 \(\ge\),桥用 \(>\);根节点要特判。

概念 定义
割点 删掉它(及其所有边)后,连通块数量增加
桥 删掉这条边后,连通块数量增加

1 判定条件

设 DFS 生成树上 \(u\) 是 \(v\) 的父亲:

对象 条件
\(u\) 是割点(\(u\) 非根) 存在儿子 \(v\) 使 \(low[v] \ge dfn[u]\)
\(u\) 是割点(\(u\) 是根) 根在 DFS 树上有 \(\ge 2\) 个儿子
边 \((u,v)\) 是桥 \(low[v] > dfn[u]\)

记忆:

  • 割点用 \(\ge\):儿子最多只能回到 \(u\) 自己,回不到 \(u\) 的上面 → 拿掉 \(u\) 就断了;
  • 桥用 \(>\):儿子连 \(u\) 都回不到 → 只能靠这条边连着。

根节点的特判是最常见的 bug。根没有父亲,\(low[v] \ge dfn[root]\) 恒成立 (\(dfn[root] = 1\) 是全局最小),所以不特判会把所有有儿子的根都判成割点。


2 另一种「割点」:DAG 上的必经点

有一类题目也叫「割点」,讲的却是完全不同的东西:

求 DAG 的「割点」:给出一张 \(n\) 个点的拓扑图,保证点 1 能够到达点 \(n\)。 找出所有的节点 \(x\),满足删除 \(x\) 后,点 1 不能到达点 \(n\)。\(n \le 10^5\)。

DP 求出 \(S[x]\) 和 \(T[x]\),分别表示从点 1 到 \(x\) 的路径总数,和 \(x\) 到 \(n\) 的路径总数。 所谓割点,就是每条从 1 到 \(n\) 的路径都会经过的点。 那么对于割点 \(x\),\(S[x] \cdot T[x]\) 必定等于 \(S[n]\)。每个点都试一下就好了。

这是 DAG 上的「必经点」,和无向图的割点是两回事,别混淆。 它的做法是路径计数:正向 DP 一遍求 \(S\),反向 DP 一遍求 \(T\), 然后 \(S[x] \cdot T[x] = S[n]\) 就是必经点。 路径数会指数级增长,实现时要取模(用两个不同的模数降低碰撞概率), Python 大整数虽然不会溢出,但位数爆炸后乘法会变慢,照样要取模。


3 模板:迭代版求割点与桥

# [片段]
def cut_and_bridge(n, start, to, eid):
    """迭代版求无向图的割点与桥。O(n + m)。

    图用 CSR 存,且必须额外给出 eid:eid[i] 是第 i 条**有向半边**对应的
    无向边编号(一条无向边的两个方向共用同一个编号)。
    ——这是正确处理**重边**的关键:跳过父边要按边编号跳,不能按点编号跳。

    返回 (is_cut, bridges):
      is_cut[u] 为 1 表示 u 是割点;
      bridges 是桥的**无向边编号**列表。
    """
    dfn = [0] * (n + 1)                      # 0 兼作「未访问」标记
    low = [0] * (n + 1)                      # low[u]:u 子树内经至多一条返祖边能爬到的最小 dfn
    is_cut = bytearray(n + 1)
    bridges = []
    it = [0] * (n + 1)                       # 出边游标,替代递归的程序计数器
    pe = [-1] * (n + 1)                      # pe[u] = u 的父边编号;根没有父边,记 -1
    idx = 0
    for s in range(1, n + 1):
        if dfn[s]:
            continue                         # 图可能有多个连通块,每块各跑一次 DFS
        idx += 1
        dfn[s] = low[s] = idx
        it[s] = start[s]
        pe[s] = -1
        call = [s]
        root_children = 0                    # 根在 DFS 树上的儿子数,只对本轮的根 s 计数
        while call:
            u = call[-1]
            if it[u] < start[u + 1]:
                i = it[u]; it[u] += 1        # i 是半边下标,先推进游标再处理,避免死循环
                v = to[i]; e = eid[i]
                if e == pe[u]:               # ★ 按边编号跳过父边,重边不会被误跳
                    continue                 # 换成 v == 父点 的写法,平行边会被一起跳掉而错判成桥
                if dfn[v] == 0:
                    idx += 1
                    dfn[v] = low[v] = idx
                    pe[v] = e                # 记下 v 是从哪条无向边下来的
                    it[v] = start[v]
                    call.append(v)
                    if u == s:
                        root_children += 1   # 只有从根直接长出的树边才算根的儿子
                elif dfn[v] < low[u]:        # 返祖边:无向图没有横跨边,不必判「在不在栈上」
                    low[u] = dfn[v]
            else:
                call.pop()
                if call:                     # 有父亲才谈得上「u 能不能爬过父亲」
                    f = call[-1]
                    if low[u] < low[f]:      # 先把 low 回传,等价于递归返回后父亲取 min
                        low[f] = low[u]
                    if low[u] >= dfn[f] and f != s:      # 割点:>=,且父亲不是根
                        is_cut[f] = 1        # u 这一坨最高只爬到 f,删掉 f 就把 u 隔离了
                    if low[u] > dfn[f]:                  # 桥:>
                        bridges.append(pe[u])            # u 连 f 都爬不到,只能靠父边 pe[u] 连着
        if root_children >= 2:               # 根的特判:根没有父亲,low[儿子] >= dfn[根] 恒成立
            is_cut[s] = 1                    # 根只有靠「儿子数」判断:两棵子树全靠根串在一起
    return is_cut, bridges


def build_undirected_csr(n, edges):
    """无向图 CSR,附带边编号 eid。edges 是 (u, v) 列表。"""
    deg = [0] * (n + 2)                      # 无向边贡献两条半边,两端的度都要加
    for u, v in edges:
        deg[u] += 1; deg[v] += 1
    start = [0] * (n + 2)                    # 度数的前缀和 = 每个点在 to 里的起始下标
    acc = 0
    for i in range(1, n + 1):
        start[i] = acc
        acc += deg[i]
    start[n + 1] = acc                       # 哨兵位,使 start[u+1] 对 u = n 也可用
    cur = start[:]                           # 各点的下一个空位
    to = [0] * acc
    eid = [0] * acc
    for i, (u, v) in enumerate(edges):       # 一条无向边拆成两条半边,共用编号 i
        to[cur[u]] = v; eid[cur[u]] = i; cur[u] += 1
        to[cur[v]] = u; eid[cur[v]] = i; cur[v] += 1
    return start, to, eid

自测:环 \(1-2-3-1\) 接上环 \(4-5-6-4\),中间用边 \(3-4\) 相连。 割点应是 \(\{3, 4\}\),桥应是 \(\{(3,4)\}\)——上面的模板实测输出正是如此。 重边测试:\(1-2\) 有两条平行边、\(2-3\) 一条,割点只有 \(2\),桥只有 \((2,3)\) (平行边不是桥,若按点编号跳父边就会错判)。



4 本章速查

要点 结论
割点(非根) 存在儿子 \(v\) 使 \(low[v] \ge dfn[u]\)
割点(根) DFS 树上儿子数 \(\ge 2\)
桥 \(low[v] > dfn[u]\)(严格大于)
跳过父边 按边编号跳,不能按点编号(否则重边被误判成桥)
DAG 的「必经点」 路径计数 \(S[x]\cdot T[x] = S[n]\),和割点是两回事
Python 的铁律 一律写迭代,递归 \(n \ge 10^4\) 就危险
建图 用 CSR,比 list of list 快 2 倍、省一半内存
数据规模 → Python 现实性
迭代版割点/桥,\(n+m \le 3\times10^5\)
迭代版割点/桥,\(n+m \le 10^6\)

dfn / low / 四类边、以及 Tarjan 强连通分量的速查条目在 强连通分量 章末。