Tarjan强连通分量算法

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

前言

“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 题解

谢谢大家的阅读!

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值