Tarjan 求 SCC 学习笔记

2026-05-16

1 强连通图与强连通分量(SCC)

1.1 基本概念

  • 强连通图:在一个有向图中,如果任意两个点互相都能到达,则称这个图是强连通图。
  • 强连通分量(SCC):有向图的极大强连通子图。“极大”的意思是,这个子图是强连通的,并且不能添加图中任何其他顶点来扩大它而保持强连通性。

通俗理解:强连通分量可以理解为一个“环”或者多个“环”的叠加。一个点自己也算作一个强连通分量。

2 DFS 生成树与边的分类

在学习 Tarjan 算法之前,先来了解 DFS 生成树。在有向图上进行 DFS 时,我们会生成 DFS 树,树的边可以分为四类:

  1. 树边(tree edge) :每次搜索找到一个未被访问的结点时,形成一条树边。所有树边构成 DFS 生成树。
  2. 反祖边(back edge) :也称回边,从一个结点指向其祖先结点(即返回上级/上上级)。
  3. 前向边(forward edge) :从一个结点指向其子树中的后代结点,但不是直接的父子关系。
  4. 横叉边(cross edge) :从一个结点指向非祖先、非后代,且已经被访问过的结点。

对边的分类理解非常重要,因为 Tarjan 算法中的 low 值更新规则正是基于这些不同类型的边来决定的。

3 Tarjan 算法的核心数组

Tarjan 算法引入了两个关键数组:

  1. dfn[u](深度优先数):结点 u 在 DFS 过程中被访问的次序,也就是“时间戳”。同一个强连通分量里面,进入该 SCC 的第一个点的 dfn 值最小。
  2. 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:

  1. 树边(v 未被访问) :先递归处理 v,然后 low[u] = min(low[u], low[v])。如果 low[v] 比 low[u] 小,说明 v 可以访问到更早的祖先,那么 u 也能间接访问到,所以要用 low[v] 更新 low[u]。

  2. 前向边:指向子树中的后代,不操作。因为后代本就在 u 的子树中,low[u] 已经通过树边更新过了。

  3. 返祖边(v 是祖先,且在栈中):low[u] = min(low[u], dfn[v])。这是找到了一个通往更早祖先的路径。

  4. 横叉边(v 已被访问但不属于 u 的祖先/后代):需要判断 v 是否还在栈中。如果还在栈中,说明 u 可以通过这条横叉边到达祖先(因为栈中的点一定属于当前正在处理的 SCC 路径),此时 low[u] = min(low[u], dfn[v]);如果 v 不在栈中,说明 v 已经被划入其他 SCC 了,不能用来更新 low[u]。

4.3 算法流程总结

Tarjan 算法的整体流程如下:

  1. 初始化dfn[u] = low[u] = ++timestamp,将 u 入栈,标记 in_stk[u] = true
  2. DFS 遍历所有邻接点 v
    • !dfn[v](未被访问过),递归调用 tarjan(v),然后 low[u] = min(low[u], low[v])
    • in_stk[v](已被访问且在栈中),low[u] = min(low[u], dfn[v])
  3. 回溯:若 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 编号)本身就是新图拓扑序的逆序。

posted @ 2026-05-16 17:28  Sail-With-Dawn  阅读(23)  评论(0)    收藏  举报