Tarjan强连通分量算法

本文介绍了Tarjan算法在求解有向图强连通分量(SCC)中的应用。通过DFS生成树的概念,解释了如何利用反祖边找到SCC,并给出了具体的实现步骤。文章还提供了例题和代码,帮助理解算法的运用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

“Tarjan 陪伴强连通分量
生成树完成后思路才闪光
欧拉跑过的七桥古塘
让你 心驰神往”
——《膜你抄》

引入

什么是强联通分量(SCC)呢?

有向图强连通分量:在有向图 G G G 中,如果两个顶点 v i , v j v_i,v_j vi,vj 间( v i > v j v_i>v_j vi>vj)有一条从 v i v_i vi v j v_j vj 的有向路径,同时还有一条从 v j v_j vj v i v_i vi 的有向路径,则称两个顶点强连通(strongly connected)。如果有向图 G G G 的每两个顶点都强连通,称 G G G 是一个强连通图。有向图的极大强连通子图,称为强连通分量(strongly connected components)。

看下面这个有向图:

请添加图片描述
它有 3 3 3 个强联通分量,如图:
请添加图片描述
显然,任何一个强连通分量之中的所有点之间均可到达。

那我们怎么求出一个图中的强连通分量呢?

直接DFS?显然会超时。

对于这个问题,美国计算机科学家Robert Tarjan(罗伯特·塔扬)提出了著名的Tarjan算法。

【题外话】

CSP之前膜拜大佬增加RP:

Robert Tarjan

Tarjan算法

首先,我们应该了解一下DFS生成树,即按照DFS次序形成的树。

以下选自OI Wiki


请添加图片描述
有向图的 DFS 生成树主要有 4 种边(不一定全部出现):

  1. 树边(tree edge):示意图中以黑色边表示,每次搜索找到一个还没有访问过的结点的时候就形成了一条树边。
  2. 反祖边(back edge):示意图中以红色边表示(即 7 → 1 7\rightarrow1 71),也被叫做回边,即指向祖先结点的边。
  3. 横叉边(cross edge):示意图中以蓝色边表示(即 9 → 7 9\rightarrow7 97),它主要是在搜索的时候遇到了一个已经访问过的结点,但是这个结点 并不是 当前结点的祖先时形成的。
  4. 前向边(forward edge):示意图中以绿色边表示(即 3 → 6 3\rightarrow6 36),它是在搜索的时候遇到子树中的结点的时候形成的。

以上选自OI Wiki

对于文章开头的那张图,我们尝试对它进行DFS,组成DFS树:
请添加图片描述

我们发现,那条反祖边(红边)指向了DFS树的根,形成了一个环,环是强连通的。

但是,我们怎么知道这个环是不是强联通分量呢?

很显然,我们只需要找到一条终点是DFS树的根,起点尽量靠下的反祖边,这条反祖边形成的一定是SCC。

注意,这里说的DFS树不一定是整个图的DFS树,也可以是子图的DFS树。

然而,我们又怎么确定哪些点是在SCC中呢?

如果结点 u u u 是某个强连通分量在搜索树中遇到的第一个结点,那么这个强连通分量的其余结点肯定是在搜索树中以 u u u 为根的子树中。 ——OI Wiki

为什么呢?

即得易见平凡,仿照上例显然,留作习题答案略,读者自证不难。反之亦然同理,推论自然成立,略去过程QED ,由上可知证毕。
——《西江月·证明》

我们假设一个点 x x x 在SCC中却不在以 u u u 为根的子树中,那么从 u u u x x x 的过程中一定有一个反祖边或横叉边使其在子树外,而根据定义,这两种边都在 u u u 之前被搜索到,与“结点 u u u 是某个强连通分量在搜索树中遇到的第一个结点”矛盾,得证。

我们只需要开一个栈,从树根开始,把遍历到的点都加到栈里。当我们发现一个SCC存在时,就不断地弹出栈,直到弹出DFS树的树根为止。这样,弹出的节点就是SCC中的节点。

那么,算法的基本原理已经了解了,我们来具体地实现一下。

首先,我们要记录每个节点是第几个被搜索到的(即时间戳),记作 d f n [ x ] dfn[x] dfn[x]

然后,我们要记录每个节点所能到达的 d f n dfn dfn 最小的节点的 d f n dfn dfn ,记作 l o w [ x ] low[x] low[x]

我们还要开一个栈,将遍历到的所有点加入栈中。

这样一来,如果我们跑完DFS,发现根节点的 d f n dfn dfn 等于 l o w low low,则说明根节点又回到了自身。这时我们就可以依次弹出栈中的节点,加入这个强连通分量,知道弹出根为止。

我们模拟一下下图:
请添加图片描述
1 1 1 开始遍历, d f n [ 1 ] = l o w [ 1 ] = 1 dfn[1]=low[1]=1 dfn[1]=low[1]=1 1 1 1 入栈;
1 → 2 1\rightarrow2 12 d f n [ 2 ] = l o w [ 2 ] = 2 dfn[2]=low[2]=2 dfn[2]=low[2]=2 2 2 2 入栈;
2 → 3 2\rightarrow3 23 d f n [ 3 ] = l o w [ 3 ] = 3 dfn[3]=low[3]=3 dfn[3]=low[3]=3 3 3 3 入栈;
3 → 4 3\rightarrow4 34 d f n [ 4 ] = l o w [ 4 ] = 4 dfn[4]=low[4]=4 dfn[4]=low[4]=4 4 4 4 入栈;
4 4 4 的子树遍历完了,发现 d f n [ 4 ] = l o w [ 4 ] dfn[4]=low[4] dfn[4]=low[4] 4 4 4 出栈, 4 4 4 自己为一个SCC;
3 3 3 的子树遍历完了,发现 d f n [ 3 ] = l o w [ 3 ] dfn[3]=low[3] dfn[3]=low[3] 3 3 3 出栈, 3 3 3 自己为一个SCC;
2 → 5 2\rightarrow5 25 d f n [ 5 ] = l o w [ 5 ] = 5 dfn[5]=low[5]=5 dfn[5]=low[5]=5 5 5 5 入栈;
5 → 1 5\rightarrow1 51 l o w [ 5 ] = 1 low[5]=1 low[5]=1,向前转移, l o w [ 2 ] = 1 low[2]=1 low[2]=1
2 2 2 的子树遍历完了,发现 d f n [ 2 ] ≠ l o w [ 2 ] dfn[2]\ne low[2] dfn[2]=low[2],不操作;
1 1 1 的子树遍历完了,发现 d f n [ 1 ] = l o w [ 1 ] dfn[1]=low[1] dfn[1]=low[1] 5 5 5 出栈, 2 2 2 出栈, 1 1 1 出栈, 1 , 2 , 5 1,2,5 1,2,5 为一个SCC,结束。

具体代码实现呢?

void tarjan(int x)
{
	dfn[x]=low[x]=++times;//时间戳+1并记录,low先设为times
	vis[x]=1;//标记为已入栈
	sta.push(x);
	for(int i=head[x];~i;i=edge[i].nxt)
	{
		if(!dfn[edge[i].to])//如果没搜过
		{
			tarjan(edge[i].to);//搜它
			low[x]=min(low[x],low[edge[i].to]);//更新
		}
		else if(vis[edge[i].to])//如果在栈中
			low[x]=min(low[x],dfn[edge[i].to]);//更新
	}//如果这个节点被搜过却没在栈中,说明它已经在别的SCC中了,不用处理
	if(dfn[x]==low[x])//找到SCC
	{
		tot++;//SCC总数+1
		while(1)
		{
			int t=sta.top();
			sta.pop();
			vis[t]=0;
			scc[t]=tot;
			if(t==x)
				break;
		}
	}
}

但是我们不确定给定的图是否是联通的,所以通常我们将所有的点循环作为DFS的根,如下:

for(int i=1;i<=n;i++)
	if(!dfn[i])
		tarjan(i);

还有不理解的地方可以看后面例题代码。

例题

洛谷 P3387 【模板】缩点

给定一个 n n n 个点 m m m 条边有向图,每个点有一个权值,求一条路径,使路径经过的点权值之和最大。你只需要求出这个权值和。
允许多次经过一条边或者一个点,但是,重复经过的点,权值只计算一次。

显然,一个SCC中的点都可以到达,那么我们可以将一个SCC缩为一个点,点权为SCC中的点的点权之和。同时将缩成的点重新连边,这时形成的新图一定是一个DAG(有向无环图)。我们在DAG上跑一遍拓扑排序,按照拓扑序进行DP即可。

代码

#include<iostream>
#include<cstdio>
#include<cstring>
#include<stack>
#include<queue>
#define MAXN 10010
#define MAXM 100010
using namespace std;
struct Edge
{
	int from;
	int to;
	int nxt;
}
edge[MAXM];
int head[MAXN],size;
void add(int from,int to)
{
	edge[++size].nxt=head[from];
	edge[size].from=from;
	edge[size].to=to;
	head[from]=size;
}
struct Edge_//新边
{
	int to;
	int nxt;
}
edge_[MAXM];
int head_[MAXN],size_;
void add_(int from,int to)
{
	edge_[++size_].nxt=head_[from];
	edge_[size_].to=to;
	head_[from]=size_;
}
void init()
{
	memset(edge,-1,sizeof(edge));
	memset(head,-1,sizeof(head));
	memset(edge_,-1,sizeof(edge_));
	memset(head_,-1,sizeof(head_));
}
int n,m;
int a[MAXN];
int u,v;
int dfn[MAXN],low[MAXN];
bool vis[MAXN];
int dis[MAXN];//新点权
int scc[MAXN],tot;
int times;
stack<int> sta;
void tarjan(int x)
{
	dfn[x]=low[x]=++times;
	vis[x]=1;
	sta.push(x);
	for(int i=head[x];~i;i=edge[i].nxt)
	{
		if(!dfn[edge[i].to])
		{
			tarjan(edge[i].to);
			low[x]=min(low[x],low[edge[i].to]);
		}
		else if(vis[edge[i].to])
			low[x]=min(low[x],dfn[edge[i].to]);
	}
	if(dfn[x]==low[x])
	{
		tot++;
		while(1)
		{
			int t=sta.top();
			sta.pop();
			vis[t]=0;
			scc[t]=tot;
			dis[tot]+=a[t];//处理点权
			if(t==x)
				break;
		}
	}
}
int in[MAXN];
int top[MAXN],cnt;
void topsort()//拓扑排序,便于DP
{
	queue<int> q;
	for(int i=1;i<=tot;i++)
		if(in[i]==0)
			q.push(i);
	while(!q.empty())
	{
		u=q.front();
		q.pop();
		top[++cnt]=u;
		for(int i=head_[u];~i;i=edge_[i].nxt)
		{
			in[edge_[i].to]--;
			if(in[edge_[i].to]==0)
				q.push(edge_[i].to);
		}
	}
}
int f[MAXN];
int ans;
int main()
{
	init();
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++)
		scanf("%d",&a[i]);
	for(int i=1;i<=m;i++)
	{
		scanf("%d%d",&u,&v);
		add(u,v);
	}
	for(int i=1;i<=n;i++)
		if(!dfn[i])
			tarjan(i);
	for(int i=1;i<=size;i++)//缩点,连边
	{
		u=edge[i].from;
		v=edge[i].to;
		if(scc[u]==scc[v])
			continue;
		add_(scc[u],scc[v]);
		in[scc[v]]++;
	}
	topsort();
	for(int i=1;i<=tot;i++)
		f[i]=dis[i];
	for(int i=1;i<=cnt;i++)//DP
		for(int j=head_[top[i]];~j;j=edge_[j].nxt)
			if(top[i]!=edge_[j].to)
				f[edge_[j].to]=max(f[edge_[j].to],f[top[i]]+dis[edge_[j].to]);
	for(int i=1;i<=tot;i++)
		ans=max(ans,f[i]);
	printf("%d",ans); 
	return 0;
} 

后记

在写这篇文章的时候思路很清晰,却不知从何讲起,查阅了很多资料才完成了这篇文章。事实上,我在文章中引用了很多OI Wiki中的片段,因为我觉得OI Wiki中的讲解更加的严谨。

在这里,我将我所查阅的几份主要资料放上:

百度百科 - 强连通分量

OI Wiki - 强连通分量

洛谷 P3387 题解

谢谢大家的阅读!

<think>好的,我现在需要给用户介绍C++中的Tarjan强连通分量算法。首先,我得回忆一下Tarjan算法的基本概念和步骤,确保自己理解正确。 Tarjan算法是由Robert Tarjan提出的,用于寻找有向图中的强连通分量(SCC)。强连通分量指的是图中最大的子图,其中任意两个顶点都可以互相到达。这个算法使用深度优先搜索(DFS)来遍历图,并通过维护一个栈和一些辅助数组来跟踪节点的访问状态。 接下来,我应该分步骤详细说明算法的实现过程。首先,每个节点都有一个索引(index)和低链接值(lowlink)。索引表示节点被访问的顺序,低链接值是该节点能回溯到的最早访问的节点的索引。当某个节点的低链接值等于其索引时,说明找到了一个强连通分量,需要将栈中的节点弹出,直到当前节点为止,这些节点组成一个SCC。 然后,我需要考虑如何在C++中实现这个算法。通常会用邻接表来表示图结构,使用栈来保存当前路径上的节点,以及两个数组来存储索引和低链接值。还需要一个布尔数组来记录节点是否在栈中,以避免重复处理。 可能会遇到的问题包括如何初始化这些数组,如何处理未访问的节点,以及在DFS过程中如何更新低链接值。例如,当访问一个已经在栈中的节点时,需要比较当前节点的低链接值和目标节点的索引,取较小值来更新当前的低链接值。 此外,需要注意递归DFS的实现细节,比如递归终止条件,以及何时将节点弹出栈并记录SCC。代码示例需要清晰,变量命名要合理,确保用户能够理解每一步的作用。 最后,我应该测试一下这个算法,比如使用一个简单的有向图来验证输出是否正确。例如,对于包含四个节点的图,边为0→1,1→2,2→0,1→3,算法应该输出两个SCC:{0,1,2}和{3}。 在编写代码示例时,要注意注释和格式,帮助用户理解关键步骤。同时,要提醒用户关于时间复杂度的部分,Tarjan算法的时间复杂度是O(V+E),适合处理较大的图结构。 总结下来,我需要结构清晰地介绍算法原理、步骤,并提供可运行的C++代码示例,确保用户能够理解并实现该算法。同时,要检查是否有遗漏的关键点,比如栈的使用和低链接值的更新逻辑,确保解释准确无误。</think>以下是关于Tarjan强连通分量算法的逐步说明和C++实现: **算法核心思想** 1. 通过DFS遍历图时维护一个栈 2. 使用两个关键数组: - $index[]$ 记录访问顺序 - $lowLink[]$ 记录能回溯到的最早节点 3. 当$lowLink[u] == index[u]$时,栈顶到当前节点的元素构成一个SCC **算法步骤** 1. 初始化全局计数器和标记数组 2. 对未访问的节点进行DFS 3. 每次DFS时: - 设置当前节点的index和lowLink - 将节点压入栈并标记在栈中 4. 遍历邻接节点: - 如果未访问,递归访问并更新lowLink - 如果已在栈中,更新lowLink值 5. 发现根节点时弹出栈中元素构成SCC **C++实现代码** ```cpp #include <vector> #include <stack> #include <algorithm> using namespace std; vector<vector<int>> sccTarjan(vector<vector<int>>& graph) { int n = graph.size(), index = 0; vector<int> indexMap(n, -1), lowLink(n, -1); vector<bool> onStack(n, false); stack<int> st; vector<vector<int>> scc; auto dfs = [&](auto self, int u) -> void { indexMap[u] = lowLink[u] = index++; st.push(u); onStack[u] = true; for(int v : graph[u]) { if(indexMap[v] == -1) { // 未访问 self(self, v); lowLink[u] = min(lowLink[u], lowLink[v]); } else if(onStack[v]) { // 已在栈中 lowLink[u] = min(lowLink[u], indexMap[v]); } } if(lowLink[u] == indexMap[u]) { // 发现SCC vector<int> component; while(true) { int v = st.top(); st.pop(); onStack[v] = false; component.push_back(v); if(v == u) break; } scc.push_back(component); } }; for(int i = 0; i < n; ++i) { if(indexMap[i] == -1) { dfs(dfs, i); } } return scc; } ``` **算法特征** - 时间复杂度:$O(V + E)$(线性时间) - 空间复杂度:$O(V)$(栈和辅助数组) - 特点:单次DFS即可找出所有SCC **使用示例** ```cpp // 构造图:0→1→2→0,1→3 vector<vector<int>> graph = { {1}, // 0 {2,3}, // 1 {0}, // 2 {} // 3 }; auto components = sccTarjan(graph); // 输出结果:[[3], [0,1,2]] ``` **应用场景** 1. 编译器中的代码优化 2. 电子电路分析 3. 社交网络社群发现 4. 代码依赖分析 5. 强连通性检测 关键理解点:$lowLink[u]$的更新策略既包含前向边的更新(递归返回时),也包含横向边的更新(遇到栈中节点时),这保证了能正确识别环路结构。当某个节点的lowLink等于其index值时,说明找到了环路的根节点。
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值