跳转至

第 112 章 强连通分量

本章配套 1 道例题,见 §例题。 前置:图的表示与遍历、拓扑排序、DFS深度优先搜索

无向图问连通性用并查集(并查集),有向图问强连通性只能用 Tarjan。 把每个强连通分量捏成一个点之后,图里再不会有环——缩点 → DAG DP 是 「有环图上的最长路 / 计数」的标准套路。

这一章最有价值的部分是迭代实现:Tarjan 的递归版在 C++ 里 20 行, 在 Python 里 \(n = 10^5\) 时必然段错误。把它改写成迭代需要一点技巧, 而这个技巧对所有深度可能到 \(10^5\) 的 DFS 都通用。

前两节讲的 DFS 生成树、四类边、dfn 与 low 是整个 Tarjan 系的共同地基, 割点与桥 以及后续的双连通分量、支配树都建立在它们之上。


1 DFS 生成树与四类边

从任意一个点开始 DFS,保留所有走过的边,构成一棵树。 上图中绿色边构成了 DFS 生成树,虚线边是非树边。

有向图的非树边分为两类:一类是返祖边,边的终点是起点的祖先;其余的是横跨边。 每条返祖边都代表一个环,所以在 Tarjan 算法中是需要被缩点的。 横跨边一般不会构成环,但是返祖边缩环之后,横跨边也有可能成为返祖边。

严格地讲,有向图 DFS 生成树上的边有四类:

类型 定义 用 dfn / dfs 状态判定 是否成环
树边(tree edge) DFS 真正走过的边 目标点未访问 —
返祖边(back edge) 指向当前栈上的祖先 目标点已访问且在栈上 ✅ 必成环
前向边(forward edge) 指向已完成的后代 目标点已访问、不在栈上、dfn 更大 ❌
横跨边(cross edge) 指向已完成的其他子树 目标点已访问、不在栈上、dfn 更小 ❌ 但缩点后可能变成返祖边

无向图只有两类:树边和返祖边(无向图不可能有横跨边—— 若存在 \(u\to v\) 的横跨边,DFS 到 \(u\) 时一定会从 \(v\) 那边先走过来)。 这个差别正是「无向图的割点/桥」比「有向图的 SCC(strongly connected component,强连通分量: 分量内任意两点互相可达的极大点集)」好判的原因。

DFS 序 dfn

还可以记录下 DFS 中每个节点首次访问的时间 dfn。 dfn 就是 DFS 序。在 DFS 树上,父亲节点的 dfn 比儿子要小。

dfn 是所有 Tarjan 系算法的地基。有了它,「\(v\) 是不是 \(u\) 的祖先」 可以在 \(O(1)\) 内回答(配合子树区间 [dfn[u], dfn[u]+sz[u]),见 树的基础与遍历)。



2 low 数组

除了 dfn 数组外,算法还会记录 low 数组,用于记录「返祖路径」。 正如字面意思 low[x] 记录的是从 \(x\) 出发能到达的 DFS 序最小的祖先。 DFS 序越小,找到的强连通分量越大。

low[u] 的精确定义(有向图版): 从 \(u\) 的子树内出发,经过至多一条非树边(且该非树边必须指向仍在栈上的点), 能到达的最小 dfn。

递推:

\[low[u] = \min\Big(dfn[u],\; \min_{u\to v \text{ 树边}} low[v],\; \min_{u\to v \text{ 且 } v \text{ 在栈上}} dfn[v]\Big)\]

用大白话说 low[u] 是什么:站在 \(u\) 上,允许往下走任意多条树边(进自己的子树), 最后允许「抄一条近路」跳回一条非树边,问这样最高能爬到树上的哪个位置。 dfn 越小代表位置越高,所以 low[u] 就是「\(u\) 这一坨最高能爬到哪」。

由此得到两个直觉,后面所有判定都从它们出来:

  • low[u] == dfn[u]:\(u\) 这一坨爬不出 \(u\) 自己,\(u\) 是它那个强连通分量的根;
  • low[v] >= dfn[u](\(u\) 是 \(v\) 的父亲):\(v\) 这一坨最高只能爬到 \(u\), 拿掉 \(u\) 就上不去了,于是 \(u\) 是割点。

三个高频错误: 1. 对返祖边写成 low[u] = min(low[u], low[v]) ——必须用 dfn[v]。 用 low[v] 在有横跨边的图上会把不该合并的分量合并; 2. 忘记判断「\(v\) 在栈上」,导致横跨边被当成返祖边; 3. 无向图求割点时,必须跳过通往父亲的那条边,而且要按边编号跳,不是按点编号 (否则重边会被误判成桥)。



3 Tarjan 强连通分量

先把定义摆清楚:

弱连通:将有向边转为无向边,如果转换后的无向图是连通的,则原有向图是弱连通的。 强连通:如果对于任意两个点之间都至少有一条有向路径,则为强连通的。

Tarjan 老爷爷首先给出了找有向图中所有强连通分量的算法:一个简单的 DFS。 最简单的强连通分量就是一个环。Tarjan 算法的主要思想就是将找到的环全部缩起来, 最后每个强连通分量就会缩成一个点。同时,缩完点后的图由于没有环,所以是一个拓扑图。

终止条件:

当 low 等于 dfn 时,说明此时已经没有能够走出 DFS 子树的路径了, 子树之外的部分对于子树而言是不可达的,因此强连通分量就此而止。 除去子树内部已经找出的强连通分量外,其余的部分属于同一个强连通分量。

这个强连通分量内其余的点都是自己的后代,所以它们的 DFS 序都比自己大。 为了快速访问,使用一个栈来记录所有访问过的节点。 每当找到强连通分量时,就可以从栈的尾部依次弹出强连通分量里面的节点。

伪代码(原文照录):

int cur = 0, dfn[], low[]
bool ok[]  // 是否已经出栈
function dfs(int x):
    dfn[x] = low[x] = ++cur
    stk.push(x)
    for v in G[x]:  // 邻接表实现:遍历 x 的所有出边 x -> v
        if dfn[v] == 0:  // 还未访问过
            dfs(v)
        if not ok[v]:  // 还未出栈
            low[x] = min(low[x], low[v])
    if low[x] == dfn[x]:
        do:
            int u = stk.pop()  // 弹出来的所有 u 在同一个强连通分量内
            ok[u] = true
        until u == x

这份代码用「未出栈」统一处理了树边和返祖边(都取 low[v]), 这是可行的写法(对树边取 low[v]、对栈上的非树边取 low[v] 也正确, 因为栈上点的 low 不会小于它所在分量根的 dfn)。 但更主流、更不容易错的写法是显式区分:树边取 low[v],栈上非树边取 dfn[v]。 下面的模板用后者。

★ 为什么必须写迭代版

问题 后果
递归深度 = DFS 树高,链状图上可达 \(n\) \(n = 10^5\) 时必然爆 C 栈
sys.setrecursionlimit(300000) 只改计数器,C 栈照样溢出 → 段错误,没有任何报错
换成大栈线程(threading.stack_size) 可行但麻烦,且牛客部分判题机会拒绝
函数调用开销 \(10^5\) 次递归 \(\approx\) 0.1 秒,不是主要矛盾但也不小

结论:Python 写 Tarjan,一律迭代。 见 DFS深度优先搜索。

递归 → 迭代的通用套路

递归函数的状态有三块:局部变量、参数、程序计数器(走到第几行了)。 改写成迭代就是把这三块显式存下来:

递归里的东西 迭代版的替身
调用栈 一个 call 列表存节点编号
「循环走到第几条边了」 it[u] 数组:每个点一个出边游标
函数返回时的「回传」动作 弹栈后手动 low[父] = min(low[父], low[u])

it[u] 是整个改写的关键——它替代了「程序计数器」。



4 模板:迭代版 Tarjan 强连通分量

模板用 CSR(compressed sparse row,压缩稀疏行)存图: 两个扁平数组 start 和 to,点 \(u\) 的所有出边终点就是切片 to[start[u] : start[u+1]]。 它等价于邻接表,但只有两个整数列表,没有 \(n\) 个小 list。 建法见本节末尾的 build_csr,邻接表的常规写法见 图的表示与遍历。

# [片段]
def tarjan_scc(n, start, to):
    """迭代版 Tarjan 强连通分量。点编号 1..n,图用 CSR (start, to) 存。

    返回 (comp, nc):
      comp[u] 是 u 所在分量的编号(0..nc-1);
      分量编号按**完成顺序**给出,因此 comp 序是缩点后 DAG 的一个**反拓扑序**
      (即若存在边 c1 -> c2,则 comp 编号满足 c2 < c1)。

    复杂度 O(n + m)。Python 下 n + m <= 3e5 约 1-2 秒。
    """
    dfn = [0] * (n + 1)                      # dfn[u]=0 兼作「u 未访问」标记,所以时间戳从 1 开始发
    low = [0] * (n + 1)                      # low[u]:u 子树内经至多一条非树边能爬到的最小 dfn
    comp = [-1] * (n + 1)                    # -1 = 还没被划入任何分量
    onstk = bytearray(n + 1)                 # onstk[u]=1 表示 u 在分量栈上,即所属分量尚未定下来
    it = [0] * (n + 1)                       # ★ 出边游标:迭代版的「程序计数器」
    stk = []                                 # Tarjan 的分量栈
    call = []                                # 显式递归栈
    idx = 0                                  # 时间戳计数器
    nc = 0                                   # 已确定的分量数,同时也是下一个分量的编号
    for s in range(1, n + 1):
        if dfn[s]:
            continue                         # 图可能不连通,每个点都要当一次起点;已访问的跳过
        idx += 1
        dfn[s] = low[s] = idx                # 刚访问时 low 只能取自己,之后单调变小
        stk.append(s); onstk[s] = 1
        it[s] = start[s]                     # 游标指向 s 的第一条出边
        call.append(s)
        while call:
            u = call[-1]                     # 栈顶 = 「当前正在执行的那一层递归」
            if it[u] < start[u + 1]:         # 还有出边没走
                v = to[it[u]]; it[u] += 1    # 取一条出边并推进游标,等价于 for 循环走一步
                if dfn[v] == 0:              # 树边:「递归」下去
                    idx += 1
                    dfn[v] = low[v] = idx
                    stk.append(v); onstk[v] = 1
                    it[v] = start[v]
                    call.append(v)           # 压栈代替函数调用,下一轮 while 就从 v 接着走
                elif onstk[v]:               # 返祖边 / 指向栈内的横跨边:v 与 u 可能同属一个分量
                    if dfn[v] < low[u]:      # ★ 用 dfn[v] 不是 low[v]
                        low[u] = dfn[v]      # 取 low[v] 会把 v 抄近路到的别人家分量也粘进来
                # 剩下一种情况:v 已访问且不在栈上,说明 v 的分量已经封闭,u 到不了 u 自己头上,忽略
            else:                            # 出边走完,「函数返回」
                call.pop()
                if low[u] == dfn[u]:         # u 这一坨爬不出 u 自己 -> u 是分量的根
                    while True:              # 栈上 u 以及压在 u 之上的点,正好构成一个分量
                        w = stk.pop(); onstk[w] = 0; comp[w] = nc
                        if w == u:
                            break            # 弹到 u 本身为止,u 也算这个分量里的一员
                    nc += 1
                if call:                     # 把 low 回传给父亲,等价于递归返回后父亲取 min
                    f = call[-1]
                    if low[u] < low[f]:
                        low[f] = low[u]
    return comp, nc

配套的 CSR 建图(比 list of list 省一半内存,且遍历更快):

# [片段]
def build_csr(n, us, vs):
    """有向边 us[i] -> vs[i] 压成 CSR。返回 (start, to),
    点 u 的出边是 to[start[u] : start[u+1]]。"""
    deg = [0] * (n + 2)                      # 第一遍:数出每个点有几条出边
    for u in us:
        deg[u] += 1
    start = [0] * (n + 2)                    # 第二遍:出度的前缀和,就是每个点的起始下标
    acc = 0
    for i in range(1, n + 1):
        start[i] = acc                       # 先写下标再累加,得到的是「不含自己」的前缀和
        acc += deg[i]
    start[n + 1] = acc                       # 多留一位哨兵,让 start[u+1] 对最后一个点也有定义
    cur = start[:]                           # cur[u] 是 u 的下一个空位,随着填入不断右移
    to = [0] * acc                           # acc 此时等于总边数,一次开够,不用 append
    for i in range(len(us)):                 # 第三遍:把每条边放进它该在的槽位
        u = us[i]
        to[cur[u]] = vs[i]
        cur[u] += 1
    return start, to

comp 编号天然是反拓扑序,这是 Tarjan 相对 Kosaraju 的一大便利: 缩点后想在 DAG 上做 DP,按 comp 编号从 0 到 \(nc-1\) 顺序处理即可, 不需要再跑一次拓扑排序。



5 缩点与 DAG 上 DP

一道典型例题(LG P3916 图的遍历):

给出一张有向图,求出每个点能到达的点中编号最大者。\(n, m \le 10^5\)。

之前提到过,将所有找出的强连通分量缩点后,会得到一张拓扑图。 对于每个强连通分量,内部的点是互相可达的。此外还需要在拓扑图上 DP,找到其他可到达的点。

这题还有一个贪心的做法:将图反着建,然后按编号从大到小依次 DFS,填充答案数组。

缩点的标准三步:

# [片段]
comp, nc = tarjan_scc(n, start, to)

# 1) 分量内部信息聚合(求和 / 取最值 / 计数)
val = [0] * nc
for u in range(1, n + 1):
    c = comp[u]
    if w[u] > val[c]:
        val[c] = w[u]

# 2) 建缩点后的 DAG(去掉分量内部的边,重边一般不影响求最值,求和时要去重)
eus = []; evs = []
for u in range(1, n + 1):
    cu = comp[u]
    for i in range(start[u], start[u + 1]):  # 遍历 u 的每条出边,CSR 里就是一段下标区间
        cv = comp[to[i]]
        if cu != cv:                         # 同分量内的边缩没了,留着会在 DAG 上造出自环
            eus.append(cu); evs.append(cv)
dstart, dto = build_csr(nc, eus, evs)        # 注意 build_csr 是 1..n 的,需按 0..nc-1 调整

# 3) 按 comp 编号**升序**处理(反拓扑序:所有后继的编号都更小)
dp = val[:]
for c in range(nc):
    for i in range(dstart[c], dstart[c + 1]):
        d = dto[i]
        if dp[d] > dp[c]:                    # 后继 d 的编号 < c,此时 dp[d] 已算完
            dp[c] = dp[d]

缩点能解决的经典问题:

问题 缩点后
每个点能到达的点权最大值 DAG 上取后继 max
有向图上的最长路(有环时无意义,缩点后有意义) DAG 最长路
「至少加几条边使整个图强连通」 \(\max(\text{入度为 0 的分量数},\ \text{出度为 0 的分量数})\)(图本身强连通时答案为 0)
2-SAT 判 \(x\) 与 \(\lnot x\) 是否同分量
「有多少点能被所有点到达」 出度为 0 的分量唯一时,即该分量大小

还有一个贪心做法值得记住:P3916 这题反向建图 + 按编号从大到小 DFS, 完全不需要 Tarjan,代码短一半。 拿到「有环图」的题,先问一句「有没有绕开缩点的做法」—— 反向图、按值排序后染色、并查集,往往都能替代。



6 可行规模判断

任务 复杂度 Python 可行规模
递归版 Tarjan \(O(n+m)\) ❌ 深度 \(\ge 10^4\) 就危险,\(10^5\) 必崩
迭代版 Tarjan \(O(n+m)\) \(n+m \le 3\times10^5\) ✅(约 1–2 秒)
迭代版 Tarjan \(O(n+m)\) \(n+m \le 10^6\) ⚠️ 约 5–8 秒,需要 5 秒以上时限
迭代版割点/桥 \(O(n+m)\) 同上
缩点 + DAG DP \(O(n+m)\) 同上(再乘 1.5 倍,因为要重建图)
CSR 建图 \(O(n+m)\) 比 list of list 快约 2 倍、省一半内存

估算方法:迭代 Tarjan 的主 while 循环,每条有向边执行一次,每个点额外执行两次 (进栈、出栈),总迭代次数 \(\approx m + 2n\),每次约 6–10 条 Python 字节码。 按 \(10^7\) 次简单迭代/秒算,\(n+m = 3\times10^5\) 约 0.3–0.5 秒的循环, 加上建图和读入,整体 1–2 秒。

建图往往比算法本身还慢。\(m = 10^5\) 条边, adj = [[] for _ in range(n+1)] 再 adj[u].append(v) 要做 \(10^5\) 次方法调用 + \(n\) 次列表分配,通常和整个 Tarjan 一样耗时。CSR 值得写。



7 例题

题号 题名 难度 题解
BISHI98 谍中谍中谍中谍中谍... 中等 题解

BISHI98 谍中谍中谍中谍中谍…(中等)

\(n \le 1000\) 名学生,每人 \(i\) 指认一个 \(p_i\),构成每点出度恰为 1 的有向图。 从起点 \(a\) 出发沿指认关系走,第一次遇到已被警告过的学生时该生退学。 对每个起点 \(a\),输出最终退学的学生编号。 时限:C/C++ 1 秒,其他语言 2 秒。 题面见 原题。

ℹ️ solutions/nowcoder/BISHI98/sol.py 已存在(用的是更直白的 \(O(n^2)\) 时间戳染色)。 下面给的是用 Tarjan 缩点的 \(O(n)\) 版本,已由 scripts/verify_docs.py 用官方样例实测通过,但未提交判题机。

建模:每点出度为 1 的有向图叫函数图(functional graph)。 从任意点出发的路径必然形如「一条尾巴 + 一个环」的 \(\rho\) 形。

关键观察:第一个被重复访问的点,就是路径进入环的那个点(环的入口)—— 进环之前每个点都是新的,进环之后绕一圈回到入口才第一次撞车。

这是 Tarjan 的完美练习场,因为在函数图上:

\[\text{强连通分量} = \begin{cases} \text{一个环(大小} > 1) \\ \text{一个自环(大小} = 1 \text{ 且 } p_u = u) \\ \text{尾巴上的孤立点(大小} = 1) \end{cases}\]

所以「\(u\) 在环上」\(\iff\)「\(u\) 所在 SCC 大小 \(> 1\),或 \(p_u = u\)」。

import sys


def tarjan_scc(n, start, to):
    """迭代版 Tarjan 强连通分量,见 112.4 的模板。"""
    dfn = [0] * (n + 1)                      # 首次访问时间;0 兼作「未访问」标记
    low = [0] * (n + 1)                      # 子树内经至多一条非树边能爬到的最小 dfn
    comp = [-1] * (n + 1)                    # 所属分量编号,-1 = 尚未确定
    onstk = bytearray(n + 1)                 # 是否还在分量栈上
    it = [0] * (n + 1)                       # 出边游标,替代递归的程序计数器
    stk = []
    call = []
    idx = 0
    nc = 0
    for s in range(1, n + 1):
        if dfn[s]:
            continue                         # 图不一定连通,逐点当起点,已访问的跳过
        idx += 1
        dfn[s] = low[s] = idx
        stk.append(s); onstk[s] = 1
        it[s] = start[s]
        call.append(s)
        while call:
            u = call[-1]                     # 栈顶就是当前这层「递归」
            if it[u] < start[u + 1]:
                v = to[it[u]]; it[u] += 1    # 取一条出边,游标右移
                if dfn[v] == 0:              # 树边:压栈代替递归调用
                    idx += 1
                    dfn[v] = low[v] = idx
                    stk.append(v); onstk[v] = 1
                    it[v] = start[v]
                    call.append(v)
                elif onstk[v]:               # 返祖边:只有仍在栈上的点才和 u 同分量
                    if dfn[v] < low[u]:
                        low[u] = dfn[v]      # 用 dfn[v] 而非 low[v]
            else:
                call.pop()                   # 出边走完,「函数返回」
                if low[u] == dfn[u]:         # u 爬不出自己,是分量的根
                    while True:
                        w = stk.pop(); onstk[w] = 0; comp[w] = nc
                        if w == u:
                            break            # 弹到 u 为止,弹出来的就是一整个分量
                    nc += 1
                if call:
                    f = call[-1]             # 把 low 回传给父亲
                    if low[u] < low[f]:
                        low[f] = low[u]
    return comp, nc


def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    p = [0] + [int(v) for v in data[1:1 + n]]

    # 函数图:每点恰一条出边,CSR 退化成 start[u] = u、to[u] = p[u]
    start = list(range(n + 2))
    to = p[:]
    comp, nc = tarjan_scc(n, start, to)

    csz = [0] * nc                           # 每个分量的点数
    for u in range(1, n + 1):
        csz[comp[u]] += 1

    ans = [0] * (n + 1)                      # 0 表示答案未知,题目点号从 1 起,不会歧义
    for u in range(1, n + 1):                # 环上的点(含自环)答案是自己
        if csz[comp[u]] > 1 or p[u] == u:    # 函数图里分量大小 > 1 只可能是环;自环单独判
            ans[u] = u
    for s in range(1, n + 1):                # 尾巴上的点继承后继的答案
        if ans[s]:
            continue
        path = []
        u = s
        while ans[u] == 0:                   # 一路走到已知答案的点
            path.append(u); u = p[u]         # 记下沿途的点,等会儿一次性填答案
        r = ans[u]                           # 终点的答案就是这条尾巴上所有点的答案
        for w in path:
            ans[w] = r                       # 回填让每个点只被走一次,总复杂度 O(n)
    sys.stdout.write(" ".join(map(str, ans[1:])) + "\n")


main()

样例复核:\(n=3\),\(p = [2,3,1]\) 构成一个三元环, SCC 只有一个、大小为 3,所以三个点都在环上,答案 1 2 3 ✓。

手工验证一个带尾巴的例子:\(n=5\),\(p = [2,3,4,3,4]\)。 环是 \(3 \to 4 \to 3\),尾巴是 \(1\to2\to3\) 和 \(5\to4\)。 输出 3 3 3 4 4——起点 1、2、3 都在 3 处撞车,起点 4、5 都在 4 处撞车 ✓。

两种做法的取舍:

做法 复杂度 代码量 什么时候用
时间戳染色(solutions/nowcoder/BISHI98/sol.py) \(O(n^2)\) 15 行 \(n \le 3000\),首选
迭代 Tarjan + 继承 \(O(n)\) 60 行 \(n \ge 10^5\),或本来就要缩点

\(n = 1000\) 时 \(O(n^2) = 10^6\),Python 里 0.2 秒,完全够。 这题选 Tarjan 是为了练手,不是因为必须。 拿到题先看数据范围——这是比会写 Tarjan 更重要的能力。


8 本章速查

要点 结论
有向图 DFS 树的边 树边 / 返祖边 / 前向边 / 横跨边
无向图 DFS 树的边 只有树边和返祖边(没有横跨边)
dfn[u] 首次访问时间(DFS 序),父亲一定比儿子小
low[u] 子树内经至多一条非树边能到的最小 dfn
返祖边更新 low low[u] = min(low[u], dfn[v]),不是 low[v]
SCC 判定 low[u] == dfn[u] → 弹栈弹出一整个分量
栈的作用 记录「还没被划入任何分量」的点
comp 编号顺序 反拓扑序,DAG DP 直接按编号升序做
缩点后 一定是 DAG(拓扑图)
「加几条边使强连通」 \(\max(\text{入度 0 分量数},\ \text{出度 0 分量数})\)
Python 的铁律 Tarjan 一律写迭代,递归 \(n \ge 10^4\) 就危险
迭代改写的关键 it[u] 出边游标,替代递归的程序计数器
setrecursionlimit 救不了,C 栈照样溢出(段错误、无报错)
建图 用 CSR,比 list of list 快 2 倍、省一半内存
数据规模 → Python 现实性
递归 Tarjan,深度 \(\ge 10^4\)
迭代 Tarjan,\(n+m \le 3\times10^5\)
迭代 Tarjan,\(n+m \le 10^6\)
缩点 + DAG DP,\(n+m \le 3\times10^5\)
函数图找环,\(n \le 3000\)

割点与桥的速查条目在 割点与桥 章末。