Tarjan 求 SCC 学习笔记
2026-05-16
1 强连通图与强连通分量(SCC)
1.1 基本概念
- 强连通图:在一个有向图中,如果任意两个点互相都能到达,则称这个图是强连通图。
- 强连通分量(SCC):有向图的极大强连通子图。“极大”的意思是,这个子图是强连通的,并且不能添加图中任何其他顶点来扩大它而保持强连通性。
通俗理解:强连通分量可以理解为一个“环”或者多个“环”的叠加。一个点自己也算作一个强连通分量。
2 DFS 生成树与边的分类
在学习 Tarjan 算法之前,先来了解 DFS 生成树。在有向图上进行 DFS 时,我们会生成 DFS 树,树的边可以分为四类:
- 树边(tree edge) :每次搜索找到一个未被访问的结点时,形成一条树边。所有树边构成 DFS 生成树。
- 反祖边(back edge) :也称回边,从一个结点指向其祖先结点(即返回上级/上上级)。
- 前向边(forward edge) :从一个结点指向其子树中的后代结点,但不是直接的父子关系。
- 横叉边(cross edge) :从一个结点指向非祖先、非后代,且已经被访问过的结点。
对边的分类理解非常重要,因为 Tarjan 算法中的 low 值更新规则正是基于这些不同类型的边来决定的。
3 Tarjan 算法的核心数组
Tarjan 算法引入了两个关键数组:
- dfn[u](深度优先数):结点 u 在 DFS 过程中被访问的次序,也就是“时间戳”。同一个强连通分量里面,进入该 SCC 的第一个点的 dfn 值最小。
- low[u]:结点 u 的子树中能够“回溯到”的最早的已经在栈中的结点的 dfn 值。具体定义如下:
- 子树结点本身
- 从子树通过一条不在搜索树上的边能到达的结点
- 以上所有结点的 dfn 值的最小值
理解 low 值:low[u] 表示 u 能够回溯到的最早祖先。如果 low[u] = dfn[u],说明 u 无法回溯到更早的祖先,那么 u 就是一个 SCC 的“根”。
3.1 强连通分量与 low/dfn 的关系
通过分析可以得出以下规律:
- 每个强连通分量中,所有点的 low 值都等于该 SCC 的根的 dfn 值。
- 每个 SCC 的根节点满足 low[u] = dfn[u]。
- 在 DFS 栈中,属于同一个 SCC 的所有点是连续的一段,并且根节点最先入栈。
于是,算法的核心思路就变得清晰了:在 DFS 回溯的过程中,当遇到一个满足 low[u] = dfn[u] 的结点时,就将栈顶到该结点之间的所有点弹出作为一个 SCC。
4 算法流程详解
4.1 朴素思路的启发
我们可以先从“找环”的思路来理解。如果从起点开始 DFS 遍历,沿途把点压入栈中,如果发现有一条边指向栈中已经存在的点(即找到了一个环),就把栈中从当前点到那个点之间的所有元素弹出。
但是!当多个环共用顶点(比如两个环共享一个点)时,不能一找到环就马上弹出,否则后面的环就会被错误拆分。应该在确定该点不再能扩展时才弹出。这正是 Tarjan 算法中 low 数组要解决的核心问题。
4.2 low 数组的更新规则
在 DFS 遍历时,对于一条边 u → v:
-
树边(v 未被访问) :先递归处理 v,然后
low[u] = min(low[u], low[v])。如果 low[v] 比 low[u] 小,说明 v 可以访问到更早的祖先,那么 u 也能间接访问到,所以要用 low[v] 更新 low[u]。 -
前向边:指向子树中的后代,不操作。因为后代本就在 u 的子树中,low[u] 已经通过树边更新过了。
-
返祖边(v 是祖先,且在栈中):
low[u] = min(low[u], dfn[v])。这是找到了一个通往更早祖先的路径。 -
横叉边(v 已被访问但不属于 u 的祖先/后代):需要判断 v 是否还在栈中。如果还在栈中,说明 u 可以通过这条横叉边到达祖先(因为栈中的点一定属于当前正在处理的 SCC 路径),此时
low[u] = min(low[u], dfn[v]);如果 v 不在栈中,说明 v 已经被划入其他 SCC 了,不能用来更新 low[u]。
4.3 算法流程总结
Tarjan 算法的整体流程如下:
- 初始化:
dfn[u] = low[u] = ++timestamp,将 u 入栈,标记in_stk[u] = true。 - DFS 遍历所有邻接点 v:
- 若
!dfn[v](未被访问过),递归调用tarjan(v),然后low[u] = min(low[u], low[v])。 - 若
in_stk[v](已被访问且在栈中),low[u] = min(low[u], dfn[v])。
- 若
- 回溯:若
low[u] == dfn[u],则说明找到了一个 SCC 的根。不断弹出栈顶元素,直到弹出 u 本身,这些弹出的点属于同一个 SCC。
为防止图不连通,需要对每个未被访问的点都调用一次 tarjan(外层循环)。
4.4 代码实现(C++)
#include<bits/stdc++.h>
using namespace std;
const int N=100005;
vector<int> g[N];
stack<int> stk;
int dfn[N],low[N],ts;
int scc_cnt,id[N],sz[N];
bool in[N];
void tarjan(int u){
// 1. 初始化
dfn[u]=low[u]=++ts;
stk.push(u);
in[u]=true;
// 2. 遍历所有邻接边
for(int v:g[u]){
if(!dfn[v]){ // 树边:未被访问过
tarjan(v);
low[u]=min(low[u],low[v]);
}
else if(in[v]){ // 返祖边或栈中的横叉边
low[u]=min(low[u],dfn[v]);
}
// 不在栈中的横叉边/前向边:不处理
}
// 3. 回溯时判断是否为 SCC 的根
if(low[u]==dfn[u]){
scc_cnt++;
int v;
do{
v=stk.top();
stk.pop();
in[v]=false;
id[v]=scc_cnt;
sz[scc_cnt]++;
}while(v!=u); // 一直弹出到 u 本身
}
}
int main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n,m;
cin>>n>>m;
for(int i=1;i<=m;i++){
int u,v;
cin>>u>>v;
g[u].push_back(v);
}
// 图可能不连通,需要对每个点检查
for(int i=1;i<=n;i++){
if(!dfn[i]){
tarjan(i);
}
}
cout<<scc_cnt<<'\n';
for(int i=1;i<=scc_cnt;i++){
cout<<sz[i]<<" \n"[i==scc_cnt];
}
return 0;
}
代码核心要点:
- 使用栈来维护当前 DFS 路径上的节点。由于栈是“先进先出”(实际上是后进先出),先入栈的节点必然在路径的“上游”,后入栈的节点在“下游”,这为我们判断是否有“回头路”提供了基础。
low[u]用min(low[u], low[v])更新是因为 v 的子树能够到达的祖先,u 经过 v 也能间接到达。- 为什么横叉边只更新栈中的节点?因为只有当 v 还在栈中时,说明 u 和 v 属于同一个正在搜索的 SCC 路径,此时横叉边可能形成“反向跳跃”。如果 v 已经出栈(已确定属于其他 SCC),说明 u 和 v 不在同一个 SCC 中,不能混用更新。
5 缩点:将 SCC 转化为 DAG
找到所有 SCC 之后,我们可以把每一个 SCC 看成一个“超级节点”。缩点的方法很简单:
for(int u=1;u<=n;u++){
for(int v:graph[u]){
if(scc_id[u]!=scc_id[v]){
new_graph[scc_id[u]].push_back(scc_id[v]);
// 注意去重,用 set 或判重数组
}
}
}
缩点后的新图会变成一张 DAG(有向无环图) 。这是一个非常有用的性质!很多复杂的问题都可以先用 Tarjan 缩点消除环,再在 DAG 上运用拓扑排序、DP 等技巧轻松求解。事实上,Tarjan 算法的输出顺序(SCC 编号)本身就是新图拓扑序的逆序。

浙公网安备 33010602011771号