tarjan算法求有向图的强连通分量
连通性的相关知识
无向图的连通分量
在无向图中,如果从节点 v i v_i vi到节点 v j v_j vj有路径,则称节点 v i v_i vi和节点 v j v_j vj是连通的。如果图中任意两个节点之间都是连通的,则称图 G G G为连通图。
如下图所示:
无向图 G G G的极大连通子图被称为图 G G G的连通分量。极大连通子图是图 G G G的连通子图,如果再向其中加入一个节点,则该子图就不再是连通的。连通图的连通分量就是它自身;非连通图则有两个及以上的连通分量。
如下图所示,有3个连通分量:
有向图的强连通分量
在有向图中,如果图中的任意两个节点从 v i v_i vi到 v j v_j vj都有路径,且从 v j v_j vj到 v i v_i vi也有路径,则称图 G G G为强连通图。
有向图 G G G的极大强连通子图被称为图 G G G的强连通分量。极大强连通子图是图 G G G的强连通子图,如果再向其中加入一个节点,则该子图就不再是强连通的。
如下图所示,(a)是强连通图,(b)不是强连通图,©是(b)的强连通分量:
tarjan算法
首先引入时间戳和追溯点的概念
- 时间戳:
dfn[u]
表示节点 u u u深度优先遍历的序号(也就是节点 u u u被访问的时间点) - 追溯点:
low[u]
表示节点 u u u或者节点 u u u的子孙能够通过非父子边追溯到的dfn最小的节点序号,即回到最早的过去(也就是节点 u u u通过有向边可回溯到的最早的时间点)
这里有必要说以下dfs遍历的两种方式:
- 方式一:先访问当前节点,然后再递归访问相邻节点(这类似于树的先序遍历)
- 方式二:先递归相邻节点,到达叶子节点后回溯时再依次访问路径中的节点(这类似于树的后序遍历)
如下图所示,这两种方式输出的结果是不同的:
在tarjan算法中,采用的是方式二
举个栗子,在深度优先搜索中,每个点的时间戳和追溯点的求解过程如下:
我们用栈stk来存储访问的节点
初始时, d f n [ u ] = l o w [ u ] dfn[u]=low[u] dfn[u]=low[u],从节点 1 1 1开始深搜,如果该节点的邻接点还没有被访问过,则一直递归进行深度优先遍历, 1 → 2 → 3 → 5 → 6 → 4 1\to2\to3\to5\to6\to4 1→2→3→5→6→4,此时栈中内容为{1,2,3,5,6,4},此时节点 4 4 4的邻接点 1 1 1已经被访问过了,并且节点 1 1 1并不是节点 4 4 4的父节点,节点 4 4 4的父节点是节点 6 6 6(深度优先搜索树上的父节点),那么这条边 4 → 1 4\to1 4→1就是一条非父子边,那么节点 4 4 4顺着这条非父子边能回到最早的节点是节点 1 1 1,那么此时就需要修改节点 4 4 4它的追溯点为 d f n [ 1 ] dfn[1] dfn[1]。我们知道 d f n [ 1 ] dfn[1] dfn[1]必然是小于 l o w [ 4 ] low[4] low[4]的,因为节点 1 1 1先于节点 4 4 4被访问,它的时间戳小。所以我们可以直接用 l o w [ 4 ] = d f n [ 1 ] low[4]=dfn[1] low[4]=dfn[1],但是为了严谨,一般都是用 l o w [ 4 ] = m i n ( l o w [ 4 ] , d f n [ 1 ] ) low[4]=min(low[4],dfn[1]) low[4]=min(low[4],dfn[1])
但是有个问题,设当前访问节点 u = 4 u=4 u=4,遍历到它的邻接点 v = 1 v=1 v=1,要执行 l o w [ u ] = m i n ( l o w [ u ] , d f n [ v ] ) low[u]=min(low[u],dfn[v]) low[u]=min(low[u],dfn[v])的前提是节点 v v v它此刻还是在栈中,为什么呢?
如上图所示,当节点 4 4 4要访问节点 1 1 1时,此时栈内的元素为{1,2,3,5,6,4},可以发现节点 v = 1 v=1 v=1还在栈中,也就是说节点 u = 4 u=4 u=4可以通过这条非父子边到达节点 v = 1 v=1 v=1。如果节点 v = 1 v=1 v=1不在栈中,那么可以认为此时节点 u = 4 u=4 u=4与节点 v = 1 v=1 v=1这条非父子边是不存在的。既然非父子边不存在,那么就不能通过非父子边回溯到更早,于是也就不能更新节点 u u u的时间戳。
在上面分析中,我们知道了 l o w [ 4 ] = m i n ( l o w [ 4 ] , d f n [ 1 ] ) = 1 low[4]=min(low[4],dfn[1])=1 low[4]=min(low[4],dfn[1])=1,接下来就要开始回溯了。回溯时需要执行 l o w [ u ] = m i n ( l o w [ u ] , l o w [ v ] ) low[u]=min(low[u],low[v]) low[u]=min(low[u],low[v]),为什么呢?因为既然子孙能够回到更早过去,那么其祖先节点也可以回到更早的过去,于是回溯时此刻 u = 6 , v = 4 u=6,v=4 u=6,v=4,更新 l o w [ 6 ] = m i n ( l o w [ 6 ] , l o w [ 4 ] ) = 1 low[6]=min(low[6],low[4])=1 low[6]=min(low[6],low[4])=1,以此类推,更新节点 5 , 3 , 2 5,3,2 5,3,2的时间戳。
我们可以知道{1,2,3,5,6,4}是一个强连通分量了。同理分析,从节点 1 1 1开始深搜,如果该节点的邻接点还没有被访问过,则一直递归进行深度优先遍历, 1 → 2 → 3 → 5 → 7 1\to2\to3\to5\to7 1→2→3→5→7,此时栈中内容为{1,2,3,5,7}。当前节点 u = 7 u=7 u=7,可以知道它没有非父子边,所以它不能通过非父子边回溯到更早的过去。于是它自己就是一个强连通分量{7}。
那么我们什么时候才能输出这个强连通分量呢?
当回溯时,一直回溯到某个节点,它的时间戳
dfn[u]
与追溯点low[u]
是相同时,就可以输出这个强连通分量了。因为此时说明这个节点 u u u它不再能够通过非父子边回溯到更早的过去了。
代码
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int N=1e5+10,M=1e6+10;
int n,m;
int h[N],e[M],ne[M],idx;
//用栈stk来存储强连通分量中的节点
int stk[N],top;
//num是时间戳
int dfn[N],low[N],num;
//scc_cnt是记录这是第几个强连通分量
//id数组用来存储某个节点是属于哪个强连通分量
//cnt用来记录某个强连通分量中的节点个数
int id[N],cnt[N],scc_cnt;
//判断某个节点是否在栈中
bool in_stk[N];
void add(int a,int b)
{
e[idx]=b,ne[idx]=h[a],h[a]=idx++;
}
void tarjan(int u)
{
dfn[u]=low[u]=++num;
stk[++top]=u;
in_stk[u]=true;
printf("调用tarjan(%d) dfn[%d]=low[%d]=%d,栈顶stk[%d]=%d\n",u,u,u,num,top,u);
for(int i=h[u];~i;i=ne[i])
{
int j=e[i];
if(!dfn[j])
{
tarjan(j);
low[u]=min(low[u],low[j]);
printf("回溯 %d->%d low[%d]=%d\n",j,u,u,low[u]);
}
else if(in_stk[j])
{
low[u]=min(low[u],dfn[j]);
printf("有环 %d->%d low[%d]=%d\n",u,j,u,low[u]);
}
}
printf("判断if(%d==%d)?\n",dfn[u],low[u]);
if(dfn[u]==low[u])
{
scc_cnt++;
int y;
printf("输出第%d个强连通分量:{",scc_cnt);
do{
y=stk[top--];
in_stk[y]=false;
id[y]=scc_cnt;
cnt[scc_cnt]++;
printf("%d ",y);
}while(y!=u);
printf("}\n输出此时栈顶stk[%d]=%d\n\n",top,stk[top]);
}
}
int main()
{
memset(h,-1,sizeof h);
cin >>n>>m;
while(m--)
{
int a,b;
cin >>a>>b;
add(a,b);
}
for(int i=1;i<=n;i++)
{
if(!dfn[i])
tarjan(i);
}
}
例如上图中的栗子来说,输出结果如下:
- 第一个连通分量为{7}
- 第二个连通分量为{4,6,5,4,2,1}