tarjan算法 割点割边强联通 算法讲解&模板 自用整理

26 篇文章 0 订阅

很早就学过tarjan算法(割点割边与强联通)了,但是因为久不用老是忘,也有收藏过几篇不错的博客,但是每次需要时都要翻出那几篇太麻烦了,所以自己开篇记录方便自己的复习。图片和部分文字来源自其他博客,文末注明具体来源

 

首先是割点割边比较系统的定义

割点:无向连通图中,去掉一个顶点及和它相邻的所有边,图中的连通分量数增加,则该顶点称为割点。

桥(割边):无向联通图中,去掉一条边,图中的连通分量数增加,则这条边,称为桥或者割边。

割点与桥(割边)的关系

1)有割点不一定有桥,有桥一定存在割点

2)桥一定是割点依附的边。

下图中顶点C为割点,但和C相连的边都不是桥

 当然我们是可以通过DFS暴力算法去找出割点和割边的,这里就不过多赘述,本文假设已有DFS算法基础,直接从DFS讲tarjan算法。

假设DFS中我们从顶点U访问到了顶点V(此时顶点V还未被访问过),那么我们称顶点U为顶点V的父顶点,V为U的孩子顶点。在顶点U之前被访问过的顶点,我们就称之为U的祖先顶点

显然如果顶点U的所有孩子顶点可以不通过父顶点U而访问到U的祖先顶点,那么说明此时去掉顶点U不影响图的连通性,U就不是割点。相反,如果顶点U至少存在一个孩子顶点,必须通过父顶点U才能访问到U的祖先顶点,那么去掉顶点U后,顶点U的祖先顶点和孩子顶点就不连通了,说明U是一个割点。

上图中的箭头表示DFS访问的顺序(而不表示有向图),对于顶点D而言,D的孩子顶点可以通过连通区域1红色的边回到D的祖先顶点C(此时C已被访问过),所以此时D不是割点。

上图中的连通区域2中的顶点,必须通过D才能访问到D的祖先顶点,所以说此时D为割点。再次强调一遍,箭头仅仅表示DFS的访问顺序,而不是表示该图是有向图。

这里我们还需要考虑一个特殊情况,就是DFS的根顶点(一般情况下是编号为0的顶点),因为根顶点没有祖先顶点。其实根顶点是不是割点也很好判断,如果从根顶点出发,一次DFS就能访问到所有的顶点,那么根顶点就不是割点。反之,如果回溯到根顶点后,还有未访问过的顶点,需要在邻接顶点上再次进行DFS,根顶点就是割点。

 接下来谈一谈tarjan算法的具体实现细节

在具体实现Tarjan算法上,我们需要在DFS(深度优先遍历)中,额外定义三个数组dfn[],low[],parent[]

 

dfn数组

dnf数组的下标表示顶点的编号,数组中的值表示该顶点在DFS中的遍历顺序(或者说时间戳),每访问到一个未访问过的顶点,访问顺序的值(时间戳)就增加1。子顶点的dfn值一定比父顶点的dfn值大(但不一定恰好大1,比如父顶点有两个及两个以上分支的情况)。在访问一个顶点后,它的dfn的值就确定下来了,不会再改变。

 

 low数组

low数组的下标表示顶点的编号,数组中的值表示DFS中该顶点不通过父顶点能访问到的祖先顶点中最小的顺序值(或者说时间戳)。

每个顶点初始的low值和dfn值应该一样,在DFS中,我们根据情况不断更新low的值。

假设由顶点U访问到顶点V。当从顶点V回溯到顶点U时,

如果

dfn[v] < low[u]

那么

low[u] = dfn[v]

如果顶点U还有它分支,每个分支回溯时都进行上述操作,那么顶点low[u]就表示了不通过顶点U的父节点所能访问到的最早祖先节点。

 

parent数组

parent[]:下标表示顶点的编号,数组中的值表示该顶点的父顶点编号,它主要用于更新low值的时候排除父顶点,当然也可以其它的办法实现相同的功能。

 接下来用一个具体的例子来观看tarjan算法的具体执行过程

下图中蓝色实线箭头表示已访问过的路径,无箭头虚线表示未访问路径。已访问过的顶点用黄色标记,未访问的顶点用白色标记,DFS当前正在处理的顶点用绿色表示。带箭头的蓝色虚线表示DFS回溯时的返回路径。

当DFS走到顶点H时,有三个分支,我们假设我们先走H-I,然后走H-F,最后走H-J。从H访问I时,顶点I未被访问过,所以I的dfn和low都为9。根据DFS的遍历顺序,我们应该从顶点I继续访问。

上图表示由顶点I访问顶点D,而此时发现D已被访问,当从D回溯到I时,由于

dfn[D] < dfn[I]

说明D是I的祖先顶点,所以到现在为止,顶点I不经过父顶点H能访问到的小时间戳为4。

根据DFS的原理,我们从顶点I回到顶点H,显然到目前为止顶点H能访问到的最小时间戳也是4(因为我们到现在为止只知道能从H可以通过I访问到D),所以low[H] = 4

现在我们继续执行DFS,走H-F路径,发现顶点F已被访问且dfn[F] < dfn[H],说明F是H的祖先顶点,但此时顶点H能访问的最早时间戳是4,而F的时间戳是6,依据low值定义low[H]仍然为4。

最后我们走H-J路径,顶点J未被访问过所以 dfn[J] = 10   low[J] = 10

同理,由DFS访问顶点B,dfn[J] > dfn[B],B为祖先顶点,顶点J不经过父顶点H能访问到的最早时间戳就是dfn[B],即low[J] = 2

我们从顶点J回溯到顶点H,显然到目前为止顶点H能访问到的最早时间戳就更新为2(因为我们到现在为止知道了能从H访问到J),所以low[H] = 2

根据DFS原理,我们从H回退到顶点E(H回退到G,G回退到F,F回退到E的过程省略),所经过的顶点都会更新low值,因为这些顶点不用通过自己的父顶点就可以和顶点B相连。当回溯到顶点E时,还有未访问过的顶点,那么继续进行E-K分支的DFS。


从E-K分支访问到顶点L时,顶点k和L的的dfn值和low值如图上图所示

接着我们继续回溯到了顶点D(中间过程有所省略),并更新low[D]

最后,按照DFS的原理,我们回退到顶点A,并且求出来了每个顶点的dfn值和low值。

tarjan算法求割点割边执行的全部过程如上图所示,现在我们拥有了全部顶点的dfn值和low值,可以开始判断是否是割点还是割边了

割点:判断顶点U是否为割点,用U顶点的dnf值和它的所有的孩子顶点的low值进行比较,如果存在至少一个孩子顶点V满足low[v] >= dnf[u],就说明顶点V访问顶点U的祖先顶点,必须通过顶点U,而不存在顶点V到顶点U祖先顶点的其它路径,所以顶点U就是一个割点。对于没有孩子顶点的顶点,显然不会是割点。

桥(割边):low[v] > dnf[u] 就说明V-U是桥

需要说明的是,Tarjan算法从图的任意顶点进行DFS都可以得出割点集和割边集。

从上图的结果中我们可以看出,顶点B,顶点E和顶点K为割点,A-B以及E-K和K-L为割边。

 以上是tarjan算法求割点割边的理论,下面来看看具体的tarjan函数模板


void tarjan(int u,int fa)
{
	dfn[u]=low[u]=++index;
	for(int i=0;i<G[u].size();i++)
	{
		int v=G[u][i];
		if(!dfn[v])
		{
			tarjan(v,u);
			low[u]=min(low[u],low[v]);
		}
		else if(v!=fa)
		{
			low[u]=min(low[u],dfn[v]);
		}
}

上面这篇很简单的模板来自于我自己的POJ3352题解的部分内容,https://blog.csdn.net/qq_19895789/article/details/80076591

求出了图的dfn和low数组,相信无论判断割点还是割边都是相当容易的了,具体的看题目要求。

这里有两点要注意的,第一这里我没开parent(father)数组记录父节点,而是用函数传参的方式,部分题目时还是需要开数组方便统计。同样也可以用是否在栈中来判断,马上的强联通中会讲,具体看题目要求

第二,else if里的low[u]=min(low[u],dfn[v]),有的人可能会在强联通中也写作low[u]=min(low[u],low[v]),在强联通里是可以AC的,但是放在求割点割边会WA,这是为什么呢?下面是一点资料

关于tarjan算法,一直有一个很大的争议,就是low[u]=min(low[u],dfn[v]);
这句话,如果改成low[u]=min(low[u],low[v])就会wa掉,但是在求强连通分量时却没有问题
根据许多大佬的观点,我想提出自己的一点看法,在求强连通分量时,如果v已经在栈中,那么说明uv一定在同一个强连通分量中,所以到最后low[u]=low[v]是必然的,提前更新也不会有问题,但是在求割点时,low的定义有了小小的变化,不再是最早能追溯到的祖先,(因为是个无向图)没有意义,应该是最早能绕到的割点,为什么用绕到,是因为是无向边,所以有另一条路可以走,如果把dfn[v]改掉就会上翻过头,可能翻进另一个环中,所以wa(转自洛谷)
low[u]=min(low[u],dfn[v])中的dfn[v]可不可以改成low[v]呢,答案是不可以,当你从u访问v到,发现v被访问过的时候,vlow值不一定确定了,说不定只有一个暂时的值而已,也就是:tmpdfn=lastdfn tmplow!=lastlow,所以不能代替。(转自Acfun栗主席)

 

上面的就是tarjan求割点和割边的概念和代码模板了,下面再来说一说tarjan求强联通的概念和代码模板。

首先看一看强联通的一些概念

在有向图G中,如果两个顶点间至少存在一条路径(注意是互相存在),称两个顶点强连通(strongly connected)。如果有向图G的每两个顶点都强连通,称G是一个强连通图。非强连通图有向图的极大强连通子图,称为强连通分量(strongly connected components)。

下图中,子图{1,2,3,4}为一个强连通分量,因为顶点1,2,3,4两两可达。{5},{6}也分别是两个强连通分量。

上面如何计算dfn数组和low数组已经说过了,下面我们既然要计算强联通分量,肯定也要储存强联通分量

而为了存储整个强连通分量,这里挑选的容器是,栈。每次一个新节点出现,就进栈,如果这个点有 出度 就继续往下找。直到找到底,每次返回上来都看一看子节点与这个节点的LOW值,谁小就取谁,保证最小的子树根。如果找到DFN[]==LOW[]就说明这个节点是这个强连通分量的根节点(毕竟这个LOW[]值是这个强连通分量里最小的。)最后找到强连通分量的节点后,就将这个栈里,比此节点后进来的节点全部出栈,它们就组成一个全新的强连通分量。

下面给出一段伪代码参考一下

tarjan(u)
{
    DFN[u]=Low[u]=++Index                      // 为节点u设定次序编号和Low初值
    Stack.push(u)                              // 将节点u压入栈中
    for each (u, v) in E                       // 枚举每一条边
        if (v is not visted)               // 如果节点v未被访问过
            tarjan(v)                  // 继续向下找
            Low[u] = min(Low[u], Low[v])
        else if (v in S)                   // 如果节点v还在栈内
            Low[u] = min(Low[u], DFN[v])
    if (DFN[u] == Low[u])                      // 如果节点u是强连通分量的根
        repeat
            v = S.pop                  // 将v退栈,为该强连通分量中一个顶点
            print v
        until (u== v)
}

 可以看到,这一段伪代码对比我上面求割点割边的代码就多了一个压栈和退栈储存强联通的过程(伪代码中为打印)

下面是对tarjan判断强联通算法的一个演示

从节点1开始DFS,把遍历到的节点加入栈中。搜索到节点u=6时,DFN[6]=LOW[6],找到了一个强连通分量。退栈到u=v为止,{6}为一个强连通分量。

返回节点5,发现DFN[5]=LOW[5],退栈后{5}为一个强连通分量。

返回节点3,继续搜索到节点4,把4加入堆栈。发现节点4向节点1有后向边,节点1还在栈中,所以LOW[4]=1。节点6已经出栈,(4,6)是横叉边,返回3,(3,4)为树枝边,所以LOW[3]=LOW[4]=1。

继续回到节点1,最后访问节点2。访问边(2,4),4还在栈中,所以LOW[2]=DFN[4]=5。返回1后,发现DFN[1]=LOW[1],把栈中节点全部取出,组成一个连通分量{1,3,4,2}。

至此,算法结束。经过该算法,求出了图中全部的三个强连通分量{1,3,4,2},{5},{6}。

最后呢附上我的HDOJ1269 迷宫城堡 AC代码,题目我就不贴了,有兴趣的可以自行搜索,大意就是判断该图是否强联通,如果是输出YES,如果不是输出NO,因为该题模板较为简单就不加注释了,如果有没看懂的可以返回去看伪代码的注释。最后这里是只判断了联通快数量,如果要输出每个联通块的节点可以再加一个数组储存(在弹栈时标记数组array[tmp]=cnt),具体看题目要求。

#include<iostream>
#include<cstdio>
#include<vector>
#include<stack>
#include<algorithm>
#include<cstring>
using namespace std;
vector<int> G[10005];
int dfn[10005]={0},low[10005]={0},ins[10005]={0};
stack<int> s;
int index=0,cnt=0;
void tarjan(int u)
{
	dfn[u]=low[u]=++index;
	s.push(u);
	ins[u]=1;
	for(int i=0;i<G[u].size();i++)
	{
		int v=G[u][i];
		if(!dfn[v])
		{
			tarjan(v);
			low[u]=min(low[u],low[v]);
		}
		else if(ins[v])
			low[u]=min(low[u],dfn[v]);
	}
	if(low[u]==dfn[u])
	{
		cnt++;
		ins[u]=0;
		int tmp;
		do
		{
			tmp=s.top();
			s.pop();
		}while(tmp!=u);
	}
}
int main()
{
	int n,m;
	while(scanf("%d %d",&n,&m)!=EOF&&(n!=0||m!=0))
	{
		vector<int> tmpg[10005];
		swap(tmpg,G);
		memset(dfn,0,sizeof(int)*10005);
		memset(low,0,sizeof(int)*10005);
		index=cnt=0;
		
		for(int i=0;i<m;i++)
		{
			int a,b;
			scanf("%d %d",&a,&b);
			G[a].push_back(b);
		}
		for(int i=1;i<=n;i++)
			if(!dfn[i])
				tarjan(i);
		printf("%s\n",cnt==1?"Yes":"No");
	}

	return 0;
}

 参考内容:

https://www.byvoid.com/zhs/blog/scc-tarjan

https://blog.csdn.net/qq_34374664/article/details/77488976

https://www.cnblogs.com/nullzx/p/7968110.html

 

  • 4
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值