Tarjan算法(Robert Tarjan提出的求解有向图强连通分量的线性时间的算法)

在舍友和同学的帮助下,终于初步理解了用于求强连通分量的Tarjan算法。Tarjan算法是基于对图深度优先搜索的算法,每个强连通分量为搜索树中的一棵子树。搜索时,把当前搜索树中未处理的节点加入一个堆栈,回溯时可以判断栈顶到栈中的节点是否为一个强连通分量。
定义DFN(u)为节点u搜索的次序编号,Low(u)为u或u的子树能够追溯到的最早的栈中节点的次序号。
当DFN(u)=Low(u)时,以u为根的搜索子树上所有节点是一个强连通分量。
对于节点u和与其相邻的节点v(v不是u的父亲节点)考虑三种情况:
1.v没被搜索过:继续对v进行深度优先搜索,在回溯过程中,用low[v]更新low[u]
2.v已经被访问过,已经在栈中:则用dfn[v]更新low[u]
3.v被访问过,不在栈中,说明v已经搜索完毕,其所在强连通分量已经被处理,所以不用对其操作
对于一个连通分量图,我们很容易想到,在该连通图中有且仅有一个dfn[u]==low[u] 。该结点一定是在深度遍历的过程中,该连通分量中第一个被访问过的结点,因为它的 DFN 值和 LOW 值最小,不会被该连通分量中的其他结点所影响。
因此,在回溯的过程中,判定 dfn[u]==low[u]的条件是否成立,如果成立,则栈中从 u后面的结点构成一个 强连通分量。
举个简单的栗子:
在这里插入图片描述

从边的顺序假设依次为1→2,2→3,3→4,4→2。
1、2、3、4、5结点的权值也设为1、2、3、4、5
开始dfs:
1→2,2没被搜过,dfn[1]=low[1]=1,1进栈
2→3,3没被搜过,dfn[2]=low[2]=2,2进栈
3→4,4没被搜过,dfn[3]=low[3]=3,3进栈
4→2,2已被搜过,也是先赋值dfn[4]=low[4]=4,之后再进行判断low[4]=min(low[4],dfn[2])=dfn[2]=2,此时low[4]!=dfn[4],并且已经走到头了
,开始回溯并维护low[]的值
low[3]=min(low[3],low[4])=low[4]=2,low[3]!=dfn[3]
继续回溯
low[2]=min(low[2],low[3])=2,low[2]=dfn[2]
此时触发判断条件,找到了一个强连通分量,可以进行需要的操作,比如缩点,也需要用到出栈操作。
继续回溯
low[1]=min(low[1],low[2])=low[1]=1
如果进行缩点操作,我理解的缩点操作类似于染色,在强连通分量的所有点的染色等于强连通分量中按顺序的最先访问的结点的值,而强连通分量的权值都累加到这个点上去,按照上图就可以开两个数组bj(用于染色标记),power(存储权值),以上图为例,bj[1]=1,bj[2]=bj[3]=bj[4]=2,power[1]=1,power[2]=9,power[3]=3,power[4]=4,而且经过缩点之后的有向图一定没有环,是一张DAG,所以就可以在缩点之后的DAG上进行DP或者其他操作,这也是缩点的常用应用。
下面附Tarjan算法模板:

void tarjan(int x)
{
	sta[ind]=x;			//进栈
	ind++;
	instack[x]=true;		//是否入栈
	low[x]=dfn[x]=++tmp;	//初始化
	for(int i=head[x];i>0;i=edge[i].next)
	{
		if(!dfn[edge[i].to])		//这个点没搜过
		{
			tarjan(edge[i].to);		//tarjan算法搜点
			low[x]=min(low[x],low[edge[i].to]);//赋值为最小的可回溯的点
		}
		else if(instack[edge[i].to])//这个点搜过并且入栈
			low[x]=min(low[x],dfn[edge[i].to]);
	}
	if(low[x]==dfn[x])	//缩点染色
	{
		col++;			//染色标志
		int len=0;		//记录强连通分量中元素个数的变量
		ind--;			//栈顶标志
		while(1)
		{
			len++;
			instack[ind]=false;//出栈
			bj[sta[ind]]=col;
			if(sta[ind]==x)	//出栈到找到的强连通分量的最小点则退出循环
				break;
			ind--;		//栈元素减减
		}
		bj_col[col]=len;	//在此染色区中的元素个数
	}
	return ;
}

解释一下点和边的存储(模拟邻接表),由于基础太差但是通过舍友和同学终于给我讲明白了
首先定义边结构体和头结点数组(初始化为0)
每次插入时类似于头插法,head数组每次记录该结点的第一条边,也就是插入该结点的最后一条边(因为是头插法),edge[].from表示有向边的开始结点,而edge[].to就是记录指向的结点,edge[].next记录了下一条边的序号。
查找的时候只需要从结点的第一条边挨个去找下一条边、下一个结点就可以了。

边的存储代码:

const int MAXM=50050;
struct node{
	int from,next,to;
}edge[MAXM];
void add(int a,int b)
{
	edge[++sum].next=head[a];
	edge[sum].from=a;
	edge[sum].to=b;
	head[a]=sum;
	return ;
}

附洛谷练习题(Tarjan缩点)

https://www.luogu.com.cn/problem/P2341

题目描述
每头奶牛都梦想成为牛棚里的明星。被所有奶牛喜欢的奶牛就是一头明星奶牛。所有奶牛都是自恋狂,每头奶牛总是喜欢自己的。奶牛之间的“喜欢”是可以传递的——如果 A 喜欢 B,B 喜欢 C,那么 A 也喜欢 C。牛栏里共有 N 头奶牛,给定一些奶牛之间的爱慕关系,请你算出有多少头奶牛可以当明星。
输入格式
第一行:两个用空格分开的整数:N 和 M。
接下来 M 行:每行两个用空格分开的整数:A 和 B,表示 A 喜欢 B。
输出格式
一行单独一个整数,表示明星奶牛的数量。
输入输出样例
输入 #1
3 3
1 2
2 1
2 3
输出 #1
1
说明/提示
只有 3 号奶牛可以做明星。

【数据范围】
对于 10% 的数据,N≤20,M≤50。
对于30% 的数据,≤103,M≤2×104
对于 70% 的数据,N≤5×103,M≤5×104
对于100% 的数据,1≤N≤104,1≤M≤5×104

我们再来分析一下这道题:
如果这所有的牛都存在同一个强联通分量里,那么它们一定互相受欢迎,所以首先是缩点。找出度为0的强联通分量中的点。这样可以保证所有的人都喜欢它。
此题还有一个特殊情况:
如果有两个以上的点分别满足出度为零的条件,则没有明星,这样无法满足所有的牛喜欢他。
所以思路为:首先用Tarjan算法进行缩点得到一张DAG图,在遍历DAG图上所有的点再计算其出度和入度,得到DAG图时定义了一个染色数组,而计算出度就需要再定义一个出度数组,初始化每个元素的出度为0,而判断出度则需要遍历每一个点的每一条边,若两个点不属于同一个强连通分量,则开始结点强连通分量的出度加1。遍历完之后在进行上面的判断就可以了。
有了上边的解释,题目就不是那么难了,但也需要注意细枝末节,比如栈元素的增加和减少对于标志的影响,AC代码如下:

#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
#include<queue>
#include<stack>
#include<vector>
using namespace std;
const int MAXN=10010;
const int MAXM=50050;
int dfn[MAXN],low[MAXN],sta[MAXN],head[MAXM];
int bj[MAXN],outdeg[MAXN],bj_col[MAXN];
bool instack[MAXN];
int ind=1,tmp,sum,flag,col,cmp;
struct node{
	int from,next,to;
}edge[MAXM];

void add(int a,int b)
{
	edge[++sum].next=head[a];
	edge[sum].from=a;
	edge[sum].to=b;
	head[a]=sum;
	return ;
}

void init()
{
	memset(dfn,0,sizeof(dfn));
	memset(low,0,sizeof(low));
	memset(instack,false,sizeof(instack));
	return ;
}

void tarjan(int x)
{
	sta[ind++]=x;			//进栈
	//ind++;
	instack[x]=true;		//是否入栈
	low[x]=dfn[x]=++tmp;	//初始化
	for(int i=head[x];i>0;i=edge[i].next)
	{
		if(!dfn[edge[i].to])		//这个点没搜过
		{
			tarjan(edge[i].to);		//tarjan算法搜点
			low[x]=min(low[x],low[edge[i].to]);//赋值为最小的可回溯的点
		}
		else if(instack[edge[i].to])//这个点搜过并且入栈
			low[x]=min(low[x],dfn[edge[i].to]);
	}
	if(low[x]==dfn[x])	//缩点
	{
		col++;
		int len=0;
		ind--;
		while(1)
		{
			len++;
			instack[ind]=false;
			bj[sta[ind]]=col;
			if(sta[ind]==x)
				break;
			ind--;
		}
		bj_col[col]=len;
	}
	return ;
}
int main()
{
	init();
	int n,m;
	scanf("%d%d",&n,&m);
	for(int i=0;i<m;i++)
	{
		int a,b;
		scanf("%d%d",&a,&b);
		add(a,b);
	}
	for(int i=1;i<=n;i++)
		if(!dfn[i])		//记忆化
			tarjan(i);	//保证所有点都被搜过
	for(int i=1;i<=n;i++)
	{
		for(int j=head[i];j;j=edge[j].next)
		{
			if(bj[i]!=bj[edge[j].to])
				outdeg[bj[i]]++;
		}
	}
	for(int i=1;i<=col;i++)
	{
		if(!outdeg[i])
		{
			if(cmp)
			{
				printf("0\n");
				return 0;
			}
			cmp=i;
		}
	}
	printf("%d\n",bj_col[cmp]);
	return 0;
}

洛谷P3387

题目描述
给定一个 n 个点 m 条边有向图,每个点有一个权值,求一条路径,使路径经过的点权值之和最大。你只需要求出这个权值和。允许多次经过一条边或者一个点,但是,重复经过的点,权值只计算一次。
输入格式
第一行两个正整数 n,m
第二行 n 个整数,其中第 i 个数 ai,表示点 i 的点权。
第三至 m+2 行,每行两个整数 u,v,表示一条 u→v 的有向边。
输出格式
共一行,最大的点权之和。
输入输出样例
输入 #1
2 2
1 1
1 2
2 1
输出 #1
2
说明/提示
对于 100% 的数据,1≤n≤104,0≤ai≤103
根据题目意思,我们只需要找出一条点权最大的路径就行了,不限制点的个数。那么考虑对于一个环上的点被选择了,一整条环是不是应该都被选择,这一定很优,能选干嘛不选。很关键的是题目还允许我们重复经过某条边或者某个点,我们就不需要考虑其他了。因此整个环实际上可以看成一个点,那么思路就很清晰了,首先缩点,权值累加,然后在DAG上找一条权值最大的路径就可以了。
而找权值最大的路径可以在缩点后的图上dp(需要记忆化搜索),不断更新最大点权和然后输出。或者用拓扑排序,因为拓扑排序可以从入度为0的点开始找到一个最优路径

下面附AC代码(缩点后拓扑排序做法以及dp做法):

#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
#include<queue>
#include<stack>
#include<vector>
using namespace std;
const int MAXN=10010;
const int MAXM=100050;
int dfn[MAXN],low[MAXN],sta[MAXN],head[MAXM],newhead[MAXM];
int indeg[MAXN],power[MAXN],dp[MAXN],cmf[MAXN];
bool instack[MAXN];
int n,m;
int top,tmp,sum,newsum;
struct node{
	int from,next,to;
}edge[MAXM],newedge[MAXM];

void add(int a,int b)
{
	edge[++sum].next=head[a];
	edge[sum].from=a;
	edge[sum].to=b;
	head[a]=sum;
	return ;
}
void tarjan(int x)
{
	dfn[x]=low[x]=++tmp;	//初始化
	sta[++top]=x;		//进栈
	instack[x]=true;		//是否入栈
	for(int i=head[x];i;i=edge[i].next)
	{
		if(!dfn[edge[i].to])		//这个点没搜过
		{
			tarjan(edge[i].to);		//tarjan算法搜点
			low[x]=min(low[x],low[edge[i].to]);//赋值为最小的可回溯的点
		}
		else if(instack[edge[i].to])//这个点搜过并且入栈
			low[x]=min(low[x],dfn[edge[i].to]);
	}
	if(low[x]==dfn[x])	//缩点
	{
		int y;
		while(y=sta[top--])
		{
			instack[y]=false;
			cmf[y]=x;
			if(x==y)
				break;
			power[x]+=power[y];
		}
	}
	return ;
}
void dfs(int x)//直接dp的做法 
{
	if(dp[x]) return ;//记忆化 
	dp[x]=power[x];
	int maxsum=0;
	for(int i=newhead[x];i;i=newedge[i].next)
	{
		if(!dp[newedge[i].to])
			dfs(newedge[i].to);
		maxsum=max(maxsum,dp[newedge[i].to]);//不断更新最大的权值路径 
	}
	dp[x]+=maxsum;
	return ;
}
/*
int topsort()//拓扑排序做法 
{
	queue<int>q;
	for(int i=1;i<=n;i++)
	{
		if(cmf[i]==i&&!indeg[i])	//保证为强连通分量并且入度为0 
		{
			dp[i]=power[i];
			q.push(i);
		}
	}
	while(!q.empty())
	{
		int now=q.front();
		q.pop();
		for(int i=newhead[now];i;i=newedge[i].next)
		{
			dp[newedge[i].to]=max(dp[newedge[i].to],dp[now]+power[newedge[i].to]);
			indeg[newedge[i].to]--;
			if(!indeg[newedge[i].to])
				q.push(newedge[i].to);
		}
	}
	int ans=0;
	for(int i=1;i<=n;i++)
		ans=max(ans,dp[i]);
	return ans;
}
*/
int main()
{
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++)
	{
		scanf("%d",&power[i]);
	}
	for(int i=1,x,y;i<=m;i++)
	{
		scanf("%d%d",&x,&y);
		add(x,y);
	}
	for(int i=1;i<=n;i++)
		if(!dfn[i])
			tarjan(i);	//保证所有点都被搜过
	/*
	//因为wa了好多次,看一下缩点的结果,检查用的 
	for(int i=1;i<=n;i++)
		cout<<cmf[i]<<" "<<power[i]<<endl;
	*/
	for(int i=1;i<=m;i++)	//建立新图
	{
		if(cmf[edge[i].from]!=cmf[edge[i].to])
		{
			newedge[++newsum].next=newhead[cmf[edge[i].from]];
			newedge[newsum].from=cmf[edge[i].from];
			newedge[newsum].to=cmf[edge[i].to];
			newhead[cmf[edge[i].from]]=newsum;
			indeg[cmf[edge[i].to]]++;
		}
	}
	int ans=0;
	for(int i=1;i<=n;i++)
	{
		if(cmf[i]==i&&!dp[i])
		//cmf[i]==i是保证i点是强连通分量或者一个强连通分量的最早查找的点 
		{
			dfs(i);
			ans=max(ans,dp[i]);
		}
	}
	printf("%d",ans);
	//printf("%d\n",topsort());
	return 0;
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值