跳转至

第 112 章 连通性:强连通分量与割点

配套例题:BISHI98 谍中谍中谍中谍中谍…、BISHI99 我朋友的朋友不是我的朋友 来源:S3 day7《简单图论》第 72–103 页(求拓扑图的「割点」/ 强连通分量 / DFS 生成树 / Tarjan 算法 / LG P3916 图的遍历);S4 模板.docx「图论 → 强连通分量 → tarjan」 前置90-图的表示与遍历93-拓扑排序与二分图60-DFS深度优先搜索

112.0 这一章为什么存在

S3 day7 有整整 30 页讲 DFS 生成树、割点和 Tarjan 强连通分量, S4 的模板清单里也把「强连通分量 tarjan」列为 NOIP 复赛必备。 但牛客的 147 道笔试题里没有一道需要缩点

这不奇怪:企业笔试的图论题止步于「最短路 + 拓扑排序 + 并查集」, Tarjan 属于 NOIP/ACM 的领域。

那为什么还要写这一章?

理由 说明
它是「有向图版的并查集」 无向图问连通性用并查集(38 章),有向图问强连通性只能用 Tarjan
缩点后是 DAG 把每个强连通分量捏成一个点后,图里再不会有环,变成 DAG(有向无环图,directed acyclic graph);「有环图上的最长路 / 计数」的标准套路就是缩点 → DAG DP
递归实现在 Python 里一定爆栈 这是 Python 选手必须解决的问题,本章给出完整迭代版
割点 / 桥是网络可靠性的标准建模 「删掉哪个点会让网络断开」

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


112.1 DFS 生成树与四类边

S3 day7 第 78、90 页:

从任意一个点开始 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

S3 day7 第 91–92 页:

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

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


112.2 low 数组

S3 day7 第 93、97 页:

除了 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. 无向图求割点时,必须跳过通往父亲的那条边,而且要按边编号跳,不是按点编号 (否则重边会被误判成桥)。


112.3 Tarjan 强连通分量

S3 day7 第 74–77 页给了定义:

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

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

第 97 页解释了终止条件:

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

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

课件给的伪代码(第 99 页,原文照录):

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,一律迭代。60-DFS深度优先搜索

递归 → 迭代的通用套路

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

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

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


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

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

# [片段]
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\) 顺序处理即可, 不需要再跑一次拓扑排序。


112.5 缩点与 DAG 上 DP

S3 day7 第 100–103 页给的例题(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,代码短一半。 拿到「有环图」的题,先问一句「有没有绕开缩点的做法」—— 反向图、按值排序后染色、并查集,往往都能替代。


112.6 无向图:割点与桥

有向图讲强连通,无向图讲割点(cut vertex / articulation point)和(bridge / cut edge)。

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

判定条件

设 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\) 是全局最小),所以不特判会把所有有儿子的根都判成割点。

S3 day7 里的另一种「割点」

课件第 72 页讲了一个同名但完全不同的东西:

求拓扑图的「割点」:给出一张 \(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 大整数虽然不会溢出,但位数爆炸后乘法会变慢,照样要取模

模板:迭代版求割点与桥

# [片段]
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)\) (平行边不是桥,若按点编号跳父边就会错判)。


112.7 可行规模判断

任务 复杂度 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 值得写。


112.8 例题

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

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

ℹ️ solutions/BISHI98.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/BISHI98.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 更重要的能力。

BISHI99 我朋友的朋友不是我的朋友(中等)

\(n, m \le 10^5\)\(m\) 对朋友关系(字符串姓名,无向边)。 记 \(\deg(x)\) 为好友数,\(\operatorname{avg}(x) = \frac{\sum_{y\in N(x)}\deg(y)}{\deg(x)}\)。 若 \(\deg(x) > \operatorname{avg}(x)\)\(x\) 是「社牛」。按字典序输出所有社牛,无则输出 None。 题面见 BISHI99 原题(牛客)

ℹ️ solutions/BISHI99.py 已存在,下面的代码与它一致,并由 scripts/verify_docs.py官方样例实测通过。

先说清楚:这题的实际考点不是连通性,是「字符串建图 + 度数统计 + 避免浮点」。 大纲把它挂在本章,是因为它提供了一个标准的无向图读入与建图流程—— 把它当成 112.6 割点模板的输入预处理来读。

核心变形(避免浮点的通用技巧):

\[\deg(x) > \frac{\sum_{y\in N(x)}\deg(y)}{\deg(x)} \iff \deg(x)^2 > \sum_{y\in N(x)}\deg(y)\]

两边同乘 \(\deg(x) > 0\),不改变不等号方向,且全部变成整数比较

import sys


def main():
    data = sys.stdin.buffer.read().split()   # 整份输入一次读完,按空白切成 bytes 列表
    m = int(data[1])                         # data[0] 是人数 n,本题用不到;data[1] 才是边数

    idx = {}                                 # 姓名(bytes) -> 编号
    names = []
    edges = []
    p = 2                                    # 前两个 token 是 n 和 m,姓名从下标 2 开始
    for _ in range(m):
        a = data[p]; b = data[p + 1]; p += 2 # 每行两个姓名,指针一次前进 2
        ia = idx.get(a, -1)                  # -1 当哨兵,比 in 判断再取值少一次哈希
        if ia < 0:
            ia = len(names); idx[a] = ia; names.append(a)   # 首次出现的名字,分配下一个编号
        ib = idx.get(b, -1)
        if ib < 0:
            ib = len(names); idx[b] = ib; names.append(b)
        edges.append((ia, ib))

    k = len(names)                           # 真正出现过的人数,可能少于题面给的 n
    deg = [0] * k
    for ia, ib in edges:                     # 第一遍:把所有人的度数统计完整
        deg[ia] += 1
        deg[ib] += 1

    nbr = [0] * k                            # nbr[x] = 邻居度数之和
    for ia, ib in edges:                     # 第二遍:此时 deg 已定稿,才能拿去累加
        nbr[ia] += deg[ib]
        nbr[ib] += deg[ia]

    # deg(x) > avg(x)  <=>  deg(x)^2 > Σ deg(邻居),全整数比较
    res = [names[i] for i in range(k) if deg[i] * deg[i] > nbr[i]]
    if not res:
        sys.stdout.write("None\n")
        return
    res.sort()                               # bytes 排序 == 小写字母的字典序
    sys.stdout.write(b" ".join(res).decode() + "\n")


main()

四个要点

  1. 姓名先映射成整数编号,之后全在数组上做。 直接拿字符串当 dict 的 key 反复读写会慢 3–5 倍;
  2. bytes 不 decode 直接排序——小写字母的字节序就是字典序, 最后输出时才 b" ".join(...).decode()
  3. 两遍扫边:第一遍统计 deg,第二遍才能累加邻居的 deg。 一遍是做不到的(累加时对方的度数还没统计完);
  4. 度数为 0 的人 \(\operatorname{avg}\)\(0/0\) 无定义,自然不是社牛,直接忽略。

「两边同乘化成整数比较」是通用武器,凡是「\(a > b/c\)」形式的判定都该这么做。 见 23-浮点与科学计数法

接上本章主题:如果这题改成「删掉哪个人会让某两人失去联系」, 那就是真正的割点问题,直接把上面的 edges 喂给 112.6 的 build_undirected_csr + cut_and_bridge 即可。 建图部分完全不用改——这就是把它放在本章的实际价值。


112.9 本章速查

要点 结论
有向图 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 分量数})\)
割点(非根) 存在儿子 \(v\) 使 \(low[v] \ge dfn[u]\)
割点(根) DFS 树上儿子数 \(\ge 2\)
\(low[v] > dfn[u]\)(严格大于)
跳过父边 按边编号跳,不能按点编号(否则重边被误判成桥)
DAG 的「必经点」 路径计数 \(S[x]\cdot T[x] = S[n]\)和割点是两回事
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\)