图论 · 一次 DFS 完成分组

强连通分量 SCC
Tarjan 工作原理

把“已经访问但归属未定”的点留在栈里,用 low 判断这片区域何时真正封口。

核心判据dfn[u] == low[u] ⇒ 从栈顶弹到 u

一、一句话看懂 Tarjan

DFS 负责向前探索,dfn 记录到达顺序,low 记录最早能追回的未封口点,栈保存归属待定的点;追不到更早处时,整段一起弹出成为 SCC。

1编号

dfn 是 Depth-First Number,记录首次访问顺序;令 low[u]=dfn[u]

2等待

u 入栈,因为它的 SCC 还没有确定。

3追溯

沿树边继承 low[v];遇栈内点使用 dfn[v]

4封口

dfn[u]==low[u] 时,从栈顶弹到 u

建模对象有向图的互相可达区域
算法范式DFS + 连通性分解
维护信息dfn、low、栈、inStack
压缩信息不保存所有可达路径
易卡点两种 low 更新与弹栈边界

二、先分清 SCC、环与连通块

它们都在说“连在一起”,但环看路线,SCC 看有向互达分组,连通块看无向连接。

看一条路线

1 → 2 → 3 → 1

沿箭头走一圈回到起点。环是一条闭合路线,不要求包含尽可能多的点。

看一个极大点集

强连通分量 SCC

任意 u、v:u ⇝ v 且 v ⇝ u

集合内任意两点都能互相到达,并且不能再加入其他点。所有原点都会被分入某个 SCC。

先忽略箭头

连通块

把 → 暂时看成 —

通常用于无向图。有向图忽略方向后得到的分组,更准确地叫弱连通分量。

同一张 6 点图,三个问题有三个答案
有哪些环?1→2→3→1
4→5→4
有哪些 SCC?{1,2,3}{4,5}{6}
忽略方向有几个连通块?只有 1 个:{1,2,3,4,5,6}
辨认口令:环看闭合路线;SCC 看两两互达的极大点集;连通块看忽略方向后是否连成一片。
有向环上的点一定在同一 SCC 一个 SCC 可以包含多个环 一个弱连通分量可以包含多个 SCC 点 6 无环也能自成 SCC

三、跟着状态单步运行

看红色当前边、粗黄环当前点、右侧执行规则和下方 dfn/low 状态一起变化。

先认识这张图

步骤 0 / 10
① 编号入栈
② DFS 探索
③ low 追溯
④ SCC 封口
未访问 栈内待定 当前点 当前边 已归入 SCC
Tarjan SCC 原理演示图 有向图包含六个点,逐步显示 DFS、low 更新、栈和强连通分量。 1→2 2→3 3→1 回边 3→4 4→5 5→4 回边 5→6 1dfn=— low=— 2dfn=— low=— 3dfn=— low=— 4dfn=— low=— 5dfn=— low=— 6dfn=— low=—

四、为什么等号就是封口线

还不能弹

low[u] < dfn[u]
更早点u子树点

u 所在区域还能追回左侧更早的未封口点。现在弹栈,会把本应属于同一个 SCC 的点拆开。

现在可以弹

low[u] == dfn[u]
u · 根同 SCC栈顶

这片区域追不到比 u 更早的栈内点,边界已经确定。从栈顶弹到 u,刚好得到一个极大的 SCC。

五、动画对应到四处代码

读实现时只抓状态的“建立、传播、回边更新、封口”四个位置。

tarjan.cpp · 核心函数
void tarjan(int u) {
    // ① 建立状态:dfn 固定,low 从自己开始
    dfn[u] = low[u] = ++timer;
    nodeStack.push(u);
    inStack[u] = true;

    for (int v : graph[u]) {
        if (!dfn[v]) {
            tarjan(v);
            // ② 树边:递归返回后继承子树的追溯能力
            low[u] = min(low[u], low[v]);
        } else if (inStack[v]) {
            // ③ 直接连到栈内点 v:使用 dfn[v]
            low[u] = min(low[u], dfn[v]);
        }
    }

    // ④ u 是当前 SCC 最早访问的点
    if (dfn[u] == low[u]) {
        ++componentCount;
        while (true) {
            int x = nodeStack.top();
            nodeStack.pop();
            inStack[x] = false;
            component[x] = componentCount;
            if (x == u) break;
        }
    }
}
① 入栈
已访问,但 SCC 归属还没确定。
② 树边
通过子树到达,所以继承 low[v]
③ 栈内边
直接到达点 v,所以使用 dfn[v]
④ 弹栈
从栈顶一直弹到根 u,不能只弹一个点。

六、Tarjan 的一个应用:缩点

先用 Tarjan 给每个原点贴上 SCC 编号,再把原边的两个端点换成 SCC 编号。

本质:把每个 SCC 当成一个超级点。分量内部的绕行细节被压掉,分量之间的方向关系被保留下来。

原图:环藏在三个 SCC 中

1 2 3 4 5 6 同色节点将被压成同一个超级点
comp[u] == comp[v]
内部边消失
comp[u] != comp[v]
保留 comp[u]→comp[v]

新图:三个超级点组成 DAG

SCC 3{1,2,3} SCC 2{4,5} SCC 1{6}
1. 分组Tarjan 求出每个点的 component[u];编号大小无意义,只看是否相同。
2. 换点建边重扫原边,只保留端点属于不同 SCC 的边。
3. 合并信息若有点权,把同一 SCC 内的点权累加到超级点。
for (auto [u,v] : edges) {
  if (component[u] != component[v])
    dag[component[u]].push_back(component[v]);
}

为什么一定是 DAG? 如果缩点后仍有分量环,环上的各分量就能互相到达,本应被 Tarjan 合并成同一个 SCC,产生矛盾。

七、三问验收

1. 栈中保存的是什么?

2. 看到栈内边 u→v,更新什么?

3. 什么时候封口?

复杂度
O(n+m):每个点入栈、出栈一次,每条边检查一次。
最常见错误
忽略 inStack[v]、栈内边误用 low[v]、只弹出根节点。