Tarjan算法求SCC 学习笔记

说8月份要好好补一补算法来着(实在太缺算法了,啥也不会/dk),学习笔记可能会一篇一篇地更(flag

现在还没到8月,先学个一年前就学了,但是忘掉的算法,起个头/cy

强连通分量

定义就懒得说了。

现在我们要求一张有向图的所有SCC,满足每个点都恰好包含在一个SCC里。

求法(Tarjan算法)

先随便选一个点作为起点DFS,这样显然有可能不会访问到所有节点。没关系,我们可以DFS完再选起点DFS没被访问的点导出的子图,然后继续DFS,DFS,直到所有点被访问过为止。显然,每次DFS所访问的点集的SCC情况是互相独立的,因为若\(x,y\)属于同一个SCC,那么它们肯定在同一次DFS访问到。于是,在此我们只讨论一次DFS,即假装所有点都能够从起点到达。

显然,本次DFS有一个搜索树,每个点\(i\)都有一个时间戳\(dfn_i\)

我们维护一个栈,每到一个节点的时候压入,暂时不讨论弹出。设\(low_i\)表示所有从子树\(i\)可以一条边到达的在栈中的节点的最小时间戳与\(dfn_i\)\(\min\)的结果。这个\(low\)可以类似树形DP地算出来,即先令\(low_i=dfn_i\),然后找它的每个邻居\(x\):若未访问过,则DFS到\(x\),并转移,令\(low_i=\min(low_i,low_x)\)(由于\(dfn_x>dfn_i\),不影响);否则,若在栈中,则它是\(i\)可以一条边到达的在栈中的节点,令\(low_i=\min(low_i,dfn_x)\)

算完之后,在从\(i\)回溯之前,若满足\(dfn_i=low_i\),则说明栈中从顶到\(i\)恰好构成一个SCC(这个后面再详细证),全部弹出。

时间复杂度显然跟普通的深度优先遍历相同,\(\mathrm O(n)\)

代码:

int dfn[N+1],low[N+1],nowdfn;//时间戳相关
int stk[N],top;//栈
bool ins[N+1];//是否在栈中
void dfs(int x){//Tarjan算法
	dfn[x]=low[x]=++nowdfn;
	ins[stk[top++]=x]=true;//入栈
	for(int i=0;i<nei[x].size();i++){//算low[x]
		int y=nei[x][i];
		if(!dfn[y])dfs(y),low[x]=min(low[x],low[y]);
		else if(ins[y])low[x]=min(low[x],dfn[y]);
	}
	if(dfn[x]==low[x]){
		cnt++;
		while(true){
			int y=stk[--top];
                        ins[y]=false;//弹出
			_____//将y加入第cnt个SCC
			if(y==x)break;
		}
	}
}

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

正确性证明

当年就是因为不会证,才导致学完就忘/xk

其实很简单。考虑归纳,只要证:若在每次弹出前的所有弹出的段都独立构成SCC,那么本段也独立构成SCC。

原图中的边一共可以分为四种:

  1. 树枝边:搜索树中的边。这种边被认为基本边;
  2. 前向边:搜索树中长辈连向晚辈的非树枝边边。这种边对连通性的帮助卵用没有;
  3. 后向边:搜索树中晚辈连向长辈的边;
  4. 横叉边:不是以上三种边的边。设为\((x,y)\),显然\(dfn_x>dfn_y\),否则\(y\)就是\(x\)的后代了。

不难发现,只有后向边和横叉边是我们需要考虑的构成环而形成SCC的边,而它们的另一端的时间戳都要\(<dfn_i\)(即在\(i\)之前被访问、压入栈)。而\(dfn_i=low_i\)表示没有以子树\(i\)中的点为起点、以栈中节点为终点的后向边和横叉边了,而根据归纳的假设,那些访问过而不在栈中的节点(被弹出的点)又独立构成SCC,于是子树\(i\)是真的“自闭”了,即不可能跟外界共同构成SCC了。不难发现,从\(i\)回溯前,栈相比于刚到\(i\)时的栈多出的点(即从顶到\(i\)的那一段)全是子树\(i\)内的(也有可能有被弹出的),于是此时只要证,这些多出来的点强连通,那么极大性即可由“自闭”性推出。

易证,多出来的点强连通当且仅当所有多出来的点都可以到达\(i\)。反证,假设有多出来的点是到不了\(i\)的,设为集合\(C\)。不难发现,若\(x\in C\)\(y\notin C\),则\(x\)到不了\(y\)。那么若\(x\in C\),则它们所有的儿子\(y\)也满足\(y\in C\),因为\(x\)\(y\)有树枝边。则\(C\)是由若干个不相交的子树构成的。每个子树的时间戳是一段区间,而它们不相交,我们可以将它们排序,观察第一个。根据子树\(i\)的“自闭”性,第一个子树只有可能跟子树\(i\)内连后向边和横叉边,而根据“若\(x\in C\)\(y\notin C\),则\(x\)到不了\(y\)”,它只能连向其它\(\in C\)的子树,而它的时间戳又是最小的,所以此子树也满足“自闭”性,要被弹出,不属于“多出来的点”,矛盾了矛盾了。至此最后一步的正确性得证。

又,所有点都被访问恰好一次,则所有点都被入栈恰好一次,而每次DFS从起点回溯前时肯定会清空栈,所以每个点恰好被分到一个SCC里。总正确性得证。

性质

我们可以把所有SCC缩成一个点,若两个SCC中各存在一个点,使得它们之间有边,则这两个SCC缩成的点之间有边。显然,缩出来的是一个DAG。而这个算法的性质是:每个SCC产生的次序是它在DAG中的拓扑逆序。

这个形象理解一下吧,就是Tarjan是在搜索树中自底向上产生SCC的,很简单,不再详细证了。

例题

CSES 1686 - Coin Collector

CSES题目页面传送门

给定一个有向图,每个点\(i\)有点权\(a_i\)。你可以任意选择起点,在图里随便走,最终得分是经过的所有点的点权之和(经过多次只算一次)。求得分最大值。

\(n\in\left[1,10^5\right],m\in\left[1,2\times10^5\right]\)

经典的SCC缩点,每个SCC的点权就是原点权之和。设缩出\(cnt\)个点,边集为\(E\),新点权为\(sum_i\)。缩点之后得到一张DAG,在上面DP即可。设\(dp_i\)表示从\(i\)出发的最大得分。目标:\(\max\limits_{i=1}^{cnt}\{dp_i\}\),转移方程:\(dp_i=\max\limits_{(i,j)\in E}\{dp_j\}+sum_i\)

代码:

#include<bits/stdc++.h>
using namespace std;
#define int long long
#define pb push_back
const int N=100000;
int n,m;
int a[N+1];
vector<int> nei[N+1];
int dfn[N+1],low[N+1],nowdfn;
int stk[N],top;
bool ins[N+1];
int cnt,cid[N+1];
int sum[N+1];
void dfs(int x){
	dfn[x]=low[x]=++nowdfn;
	ins[stk[top++]=x]=true;
	for(int i=0;i<nei[x].size();i++){
		int y=nei[x][i];
		if(!dfn[y])dfs(y),low[x]=min(low[x],low[y]);
		else if(ins[y])low[x]=min(low[x],dfn[y]);
	}
	if(dfn[x]==low[x]){
		cnt++;
		while(true){
			int y=stk[--top];
			ins[y]=false;
			cid[y]=cnt;sum[cnt]+=a[y];
			if(y==x)break;
		}
	}
}
vector<int> cnei[N+1];
int dp[N+1];
signed main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++)scanf("%lld",a+i);
	while(m--){
		int x,y;
		scanf("%lld%lld",&x,&y);
		nei[x].pb(y);
	}
	for(int i=1;i<=n;i++)if(!dfn[i])dfs(i);//求SCC 
	for(int i=1;i<=n;i++)for(int j=0;j<nei[i].size();j++){//缩点 
		int x=nei[i][j];
		if(cid[i]!=cid[x])cnei[cid[i]].pb(cid[x]);
	}
	int ans=0;
	for(int i=1;i<=cnt;i++){//DP 
		for(int j=0;j<cnei[i].size();j++)dp[i]=max(dp[i],dp[cnei[i][j]]);
		ans=max(ans,dp[i]+=sum[i]);
	}
	cout<<ans;
	return 0;
}

POJ 2762 - Going from u to v or from v to u?

POJ题目页面传送门

给定一张图,求是否满足每一个点对都至少可以从一个到达另一个。本题多测。

显然,一个SCC内的点对是肯定可以的,而且与其它点的可以性相同。于是跑Tarjan,缩点。这时候会得到一个DAG。稍微有点脑子就能发现这个DAG必须是一条链。然后判一下度数即可。

多测不清空,爆零两行泪。

POJ不能用bits,爷青结。

代码:

#include<iostream>
#include<vector>
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
#define pb push_back
const int N=1000;
int n,m;
vector<int> nei[N+1];
int dfn[N+1],low[N+1],nowdfn;
int stk[N],top;
bool ins[N+1];
int cnt,cid[N+1];
void dfs(int x){
	dfn[x]=low[x]=++nowdfn;
	ins[stk[top++]=x]=true;
	for(int i=0;i<nei[x].size();i++){
		int y=nei[x][i];
		if(!dfn[y])dfs(y),low[x]=min(low[x],low[y]);
		else if(ins[y])low[x]=min(low[x],dfn[y]);
	}
	if(dfn[x]==low[x]){
		cnt++;
		while(true){
			int y=stk[--top];
			ins[y]=false;
			cid[y]=cnt;
			if(y==x)break;
		}
	}
}
vector<int> cnei[N+1];
int ideg[N+1],odeg[N+1];
void mian(){
	cin>>n>>m;
	for(int i=1;i<=n;i++)nei[i].clear(),cnei[i].clear();
	while(m--){
		int x,y;
		scanf("%d%d",&x,&y);
		nei[x].pb(y);
	}
	cnt=nowdfn=top=0;memset(ins,0,sizeof(ins));memset(dfn,0,sizeof(dfn));
	for(int i=1;i<=n;i++)if(!dfn[i])dfs(i);
	for(int i=1;i<=n;i++)for(int j=0;j<nei[i].size();j++){
		int x=nei[i][j];
		if(cid[i]!=cid[x])cnei[cid[i]].pb(cid[x]);
	}
	memset(ideg,0,sizeof(ideg));memset(odeg,0,sizeof(odeg));
	for(int i=1;i<=cnt;i++){
		vector<int> &v=cnei[i];
		sort(v.begin(),v.end());
		v.resize(unique(v.begin(),v.end())-v.begin());
		for(int j=0;j<v.size();j++)odeg[i]++,ideg[v[j]]++;
	}
	bool ok=true;
	if(cnt>1){
		ok=ideg[1]==1&&odeg[1]==0&&ideg[cnt]==0&&odeg[cnt]==1;
		for(int i=2;i<cnt;i++)ok&=ideg[i]==1&&odeg[i]==1;
	}
	puts(ok?"Yes":"No");
}
int main(){
	int testnum;
	cin>>testnum;
	while(testnum--)mian();
	return 0;
}

洛谷 P4819 - 杀人游戏

洛谷题目页面传送门

题意见洛谷。说句闲话,BZOJ上的输入格式咋这么好,洛谷真没意思。

显然一个SCC内只需要访问任意一个即可知道所有人的身份。于是考虑跑Tarjan,缩点。然后贪心地想,所有入度为\(0\)的SCC都必须访问内部的一个。然后你就会发现,把这些入度为\(0\)的SCC都访问过之后,已经结束了。然鹅警察有一个推理手段:排除法,所以说只需要知道\(n-1\)个人的身份即可。不难发现,只有大小为\(1\)、入度为\(0\)、所有连向的SCC的入度都\(>1\)(可以通过其它\(0\)入度SCC推到)的SCC才可以给访问人数贡献一个\(-1\),而且最多贡献一次。

设最小访问人数为\(x\),那么答案是\(1-\dfrac xn\)

代码:

#include<bits/stdc++.h>
using namespace std;
#define pb push_back
const int N=100000;
int n,m;
vector<int> nei[N+1];
int dfn[N+1],low[N+1],nowdfn;
int stk[N],top;
bool ins[N+1];
int cnt,cid[N+1],sz[N+1];
void dfs(int x){
	dfn[x]=low[x]=++nowdfn;
	ins[stk[top++]=x]=true;
	for(int i=0;i<nei[x].size();i++){
		int y=nei[x][i];
		if(!dfn[y])dfs(y),low[x]=min(low[x],low[y]);
		else if(ins[y])low[x]=min(low[x],dfn[y]);
	}
	if(dfn[x]==low[x]){
		cnt++;
		while(true){
			int y=stk[--top];
			ins[y]=false;
			sz[cnt]++,cid[y]=cnt;
			if(y==x)break;
		}
	}
}
int ideg[N+1];
vector<int> cnei[N+1];
int main(){
	cin>>n>>m;
	while(m--){
		int x,y;
		scanf("%d%d",&x,&y);
		nei[x].pb(y);
	}
	for(int i=1;i<=n;i++)if(!dfn[i])dfs(i);
	for(int i=1;i<=n;i++)for(int j=0;j<nei[i].size();j++){
		int x=nei[i][j];
		if(cid[i]!=cid[x])ideg[cid[x]]++,cnei[cid[i]].pb(cid[x]);
	}
	int ans=0;
	for(int i=1;i<=cnt;i++)if(ideg[i]==0)ans++;
	for(int i=1;i<=cnt;i++)if(ideg[i]==0&&sz[i]==1){
		bool flg=true;
		for(int j=0;j<cnei[i].size();j++)flg&=ideg[cnei[i][j]]>1;
		if(flg){ans--;break;}
	}
	printf("%.6lf\n",1-1.*ans/n);
	return 0;
}

洛谷 P5025 - 炸弹

题解传送门

posted @ 2020-07-29 13:38  ycx060617  阅读(584)  评论(3编辑  收藏  举报