【学习笔记】Tarjan 算法与无向图连通性


【学习笔记】Tarjan 算法与无向图连通性

参考资料:李煜东 《算法竞赛进阶指南》

Part.1 无向图的割点与桥

在无向连通图 \(G = (V,E)\) 上。

割点

若对于一点 \(x \in V\) 从原图中删去 \(x\) 及其所有的相连边,图分裂成互不相连的两个子图,则 \(x\) 为原图的一个割点。

割边

若对于一条边 \(e \in E\) 从原图中删去 \(e\) ,图分裂成互不相连的两个子图,则 \(e\) 为原图的一条割边。

方法基础

无向图深度优先遍历 (DFS)。

时间戳

DFS 过程中,按照每个节点第一次被访问的时间顺序,依次付给编号 \([1,n]\) 的标记,记为 \(dfn[x]\)

搜索树

在无向图中任取一点开始 DFS,DFS 过程中所有发生过递归的边及其端点构成的树。

在一般的无向图(非联通)中,表现为每一个连通块内搜索树形成的森林。

追溯值

记为 \(low[x]\)

\(subtree(x)\) 表示搜索树中以 \(x\) 为根的子树。

\(low[x]\) 定义为以下节点中 \(dfn[v]\) 的最小值:

  1. \(v \in subtree(x)\)

  2. 通过一条非搜索树边,可以到达 \(subtree(x)\) 的节点。

计算方法:

先令 \(low[x]=dfn[x]\)

遍历从 \(x\) 出发的所有边 \((x,y)\)

在搜索树上 \(x\)\(y\) 的父节点,则 \(low[x]=min(low[x],low[y])\)

在递归回溯后执行计算。

\((x,y)\) 不是搜索树上的边,则 \(low[x]=min(low[x],dfn[y])\)

判断割边

\((x,y)\) 为割边,当且仅当搜索树上存在 \(x\) 的一个子节点 \(y\),满足:

\(dfn[x]

从定义知道,上面的式子翻译一下,就是以 \(y\) 为根的子树 “卷” 起来了,形成了内环,而 \((x,y)\) 就好像两个环之间的“桥”一样。

一个问题:

如果图中有重边,怎么办?

可以考虑对一对边记录其编号(特征码唯一标识),然后只忽略进入这一点所经过的边的编号,也可以采用 \(xor 1\) 的成对变换的方法。

参考例题:

POJ3177 / BZOJ1718 / AcWing395 Redundant Paths(冗余路径)

参考代码:

冗余路径

判桥,缩点(e-DCC),然后对于“桥树”上度为 \(1\) 的点,两个两个地配对相连即可。

#include
#define pb push_back
using namespace std;

const int maxn=5010,maxm=10010;

struct edge{
	int to,ch;
};

int d[maxn];

vectorgo[maxn];

bool onb[maxn],isb[maxm];

int dfn[maxn],low[maxn],tim=0;

int stk[maxn],top=0,cnt=0,from[maxn];

int vis[maxm];

void tarjan(int nowa,int come){

	low[nowa]=dfn[nowa]=++tim;
	stk[++top]=nowa;
	
	for(edge tmp:go[nowa]){
		
		if(tmp.ch==come)continue;
		
		if(!dfn[tmp.to]){
			tarjan(tmp.to,tmp.ch);
			low[nowa]=min(low[nowa],low[tmp.to]);
			if(dfn[nowa]>n>>m;
	
	int u,v;
	for(int i=1;i<=m;i++){
		cin>>u>>v;
		//d[u]++;d[v]++;
		go[u].pb(edge{v,i});
		go[v].pb(edge{u,i});
	}
	
	tarjan(1,0);
	
	int tot=0;
	
	for(int i=1;i<=n;i++){
		for(edge tmp:go[i]){
			if(isb[tmp.ch]==0||vis[tmp.ch])continue;
			vis[tmp.ch]=1;
			d[from[i]]++;d[from[tmp.to]]++;
		}
	}
	
	for(int i=1;i<=cnt;i++){
		if(d[i]==1)tot++;
	}
	
	cout<

判断割点

\(x\) 不是搜索树的根,则 \(x\) 是割点当且仅当搜索树上存在 \(x\) 的一个子节点 \(y\),满足:

\(dfn[x] \leq low[y]\)

特别地,若 \(x\) 是根,那么需要存在至少两个子节点满足上面的条件。

证明方法:翻译一下,如果是割点,就是自己在一个卷起来的环上,而且还是这个环的连接点(像脖子一样)(取等号),或者自己有一条割边;如果是根的话,两个是为了保证切自己不是仅仅切掉一个点而已。

因为判断规则是小于等于,所以不考虑父节点和重边。

参考题目:BZOJ1123 / AcWing363 / Luogu3469 BLO(B城)

求割点,对于割点与非割点,有着不同的计算方法。非割点切开后除了他剩下都联通;而割点切开后分为几部分:满足判断条件的搜索子树,割点自己,还有剩下的连通块。

参考代码:

#include
#define pb push_back
#define ll long long
using namespace std;

const int maxn=100010;

int n,m;

vectorgo[maxn];

int cut[maxn];

ll ans[maxn];

int siz[maxn];

int dfn[maxn],low[maxn],tim=0;

void tarjan(int x){
	siz[x]=1;
	dfn[x]=low[x]=++tim;
	
	int flag=0;int sum=0;
	for(int to:go[x]){
		if(!dfn[to]){
			tarjan(to);
			low[x]=min(low[x],low[to]);
			siz[x]+=siz[to];
			if(dfn[x]<=low[to]){
				sum+=siz[to];
				flag++;
				ans[x]+=1ll*siz[to]*(n-siz[to]);
			}
		}
		else{
			low[x]=min(low[x],dfn[to]);
		}
	}
	if(x==1&&flag>=2){
		ans[x]+=1ll*(n-1)+1ll*(n-sum-1)*(sum+1);
	}
	else if(x!=1&&flag){
		ans[x]+=1ll*(n-1)+1ll*(n-sum-1)*(sum+1);
	}
	else{
		ans[x]=2*(n-1);
	}
}

int main(){
	ios::sync_with_stdio(false);
	cin.tie(nullptr);cout.tie(nullptr);
	
	cin>>n>>m;
	
	int u,v;
	for(int i=1;i<=m;i++){
		cin>>u>>v;
		go[u].pb(v);go[v].pb(u);
	}
	
	tarjan(1);
	
	for(int i=1;i<=n;i++)cout<

Part.2 无向图的双联通分量

点双连通图

若一张无向连通图不存在割点,则称它为“点双连通图”。

无向图的极大点双联通子图称为“点双连通分量”,简记为 “v-DCC”。

边双连通图

若一张无向连通图不存在割边,则称它为“边双联通图”。

无向图的极大边双联通子图称为“边双联通分量”,简记为 “e-DCC”。

二者统称为 “DCC”。

注:“极大”指对于一个 DCC: \(G'=(V',E') ,G' \subseteq G\);$ \nexists G''=(V'',E'') ,G'' \subseteq G,G' \subseteq G''$ 使得 \(G''\) 也是一个 DCC。

定理

一张无向连通图是“点双连通图”,当且仅当满足下列两个条件之一:

  1. 图的顶点数不超过 2

  2. 图中任意两点都同时包含在至少一个简单环中,

一张无向连通图是“边双连通图”,当且仅当任意一条边都包含在至少一个简单环中。

证明

To:“点双连通图”

顶点数不超过 2,根据定义,显然成立。

充分性:若任意两点 \(x,y\) 都同时包含在至少一个简单环中,则 \(x,y\) 之间至少有两条不相交的路径,无论从图中删去哪一个节点,\(x,y\) 之间仍然可以相连(想一想前面的 BLO 那道题),所以图中没有割点。

必要性:反证法,假设一张无向连通图是“点双连通图”,并且存在两点 \(x,y\) 他们不同时处于任何一个简单环中。

如果 \(x,y\) 之间仅存在 1 条简单路径,那么肯定存在割点,与定义矛盾。

如果 \(x,y\) 之间存在 2 条及以上的简单路径,那么除了 \(x,y\) 这两个交点,任意两条路径都必须至少有一个交点,进而得知他们肯定至少都交于一点,那该交点根据定义就是一个割点,与定义矛盾;如果不相交,就会出现简单环,就与假设矛盾了。

To:“边双连通图”

充分性:如果所有边都包含在至少一个简单环中,那么无论切去哪条边,剩下的所有节点仍然可以连通,故不存在割边。

必要性:反证法,假设一张无向连通图是“边双连通图”,并且存在一条不包含在任意一个简单环中的边。

那么它的端点 \(x,y\) 也不包含在任意一个简单环中,所以这条边是 \(x,y\) 之间的唯一通路,切去这条边以后,\(x,y\) 就不连通了,那么这条边就是割边,与定义矛盾。

e-DCC 的求法

对无向图进行判桥,然后把桥挖掉,剩下的就是无向图中全部的 e-DCC。

e-DCC 的缩点

先说一句,挖一个坑,听说 LCT 可以动态地在只加边的情况下完成这个过程,以后做 LCT 的时候再填坑。

很简单,把每个 e-DCC 看成一个大点,内部的边不要,只留下链接两个不同 e-DCC 之间的边,建一个新图,新图的形态是一棵树或森林。

v-DCC 的求法

维护一个栈,用以下方法维护栈中的元素:

  1. 当一个节点首次被访问时,把该节点入栈。

  2. 当割点判定法则中的条件 \(dfn[x] \leq low[y]\) 成立时,无论 \(x\) 是否为根,都要从栈中不断弹出节点直到节点 \(y\) 被弹出;且刚才弹出的所有节点和节点 \(x\) 一起构成一个 v-DCC 。

v-DCC 的缩点

因为一个节点可能属于多个 v-DCC,设图中有 \(p\) 个割点和 \(t\) 个 v-DCC。然后建立一张包含 \(p+t\) 个节点的新图,把每个 \(v-DCC\) 和每个割点都作为新图中的节点,并且在每个割点和包含它的所有 v-DCC 之间连边。

建成的新图也是一棵树或森林。

参考题目: POJ3694 Network / AcWing364 网络

第一眼看到这狗东西,这不是我以前看见过的一个 LCT 在只加边的情况下动态维护无向图桥数量的题目吗?

然后一看数据范围,缩点,纯暴力标记法,可以过。

但我们不能局限于暴力,我们要优化。

先缩一个点,然后建一棵树,拎起来,预处理好树的父子结构和深度。

对于每个节点,开一个并查集(带路径压缩),初始 \(fa[x]\) 就是自己,表示所属的 e-DCC 集合。

每次来一个询问,若当前两个节点属于同一个 e-DCC (用 \(find(x)\) 找),那就忽略。

如果不属于同一个 e-DCC ,那么像 LCA 暴力上跳法一样,对于两个节点所属的 e-DCC ,在树上跳到父亲,且每次把当前节点的 \(fa[x]\) 指向自己在原树上的父亲,之后就可以愉快的路径压缩了。此过程中,统计跳过的边数,这些边就不再是桥了。

参考代码:

#include
#define pb push_back
using namespace std;

const int maxn=100010,maxm=200010;

int n,m;

struct edge{
	int to,ch;
};
int vis[maxm];

vectorgo[maxn];

int dfn[maxn],low[maxn],isb[maxm],tim=0,cnt=0;
int stk[maxn],top=0;

vectorng[maxn];

int fa[maxn],f[maxn],dep[maxn];
int from[maxn];

int getfa(int x){
	return (fa[x]==x)?(x):( fa[x]=getfa(fa[x]) );
}

int jump(int x,int y){
	int res=0;
	while(getfa(x)!=getfa(y)){
		if(dep[getfa(x)]dfn[x]){
				isb[tmp.ch]=1;
			}
		}
		else{
			low[x]=min(low[x],dfn[tmp.to]);
		}
	}
	if(dfn[x]==low[x]){
		int tmp;
		cnt++;
		do{
			tmp=stk[top--];
			from[tmp]=cnt;
		}while(tmp!=x);
	}
}

int main(){
	ios::sync_with_stdio(false);
	cin.tie(nullptr);cout.tie(nullptr);
	
	int cas=0;
	
	while(cin>>n>>m){
		if(n==0&&m==0)break;
		
		for(int i=1;i>u>>v;
			go[u].pb(edge{v,i});
			go[v].pb(edge{u,i});
		}
		
		tarjan(1,0);
		
		int ans=0;
		
		for(int i=1;i<=n;i++){
			for(edge tmp:go[i]){
				if(vis[tmp.ch]||isb[tmp.ch]==0)continue;
				vis[tmp.ch]=1;
				ans++;
				ng[from[i]].pb(from[tmp.to]);
				ng[from[tmp.to]].pb(from[i]);
			}
		}
		
		dfs(1,0);
		
		int q;
		
		cin>>q;
		
		cout<<"Case "<<++cas<<":"<>u>>v;
			if(getfa(from[u])!=getfa(from[v]))
			ans-=jump(getfa(from[u]),getfa(from[v]));
			cout<

参考题目:POJ2942 / AcWing365 圆桌骑士

就是找图中所有不属于任何一个“奇环”(奇数个点构成的简单环)的点总数。

求出图中的所有 v-DCC ,然后对于每个 v-DCC 进行染色判定是否有奇环。

一个 v-DCC 中有奇环,那么其中所有点都包含在至少一个奇环内。

注意一下效率与常数问题,这是我久违地不使用任何 STL,还开了 C++98 卡常的代码了。

参考代码:

#include
#include
#define si short int
#define re register

const si maxn=1010;
const int maxm=1000010;
const char z='0';

si inr[maxn];

si n;int m;

struct edg{
	si u,v;
}e[maxm];

bool g[maxn][maxn];

si stk[maxn],top;
si dfn[maxn],low[maxn],tim;
bool dcc[maxn];
si ind[maxn],hed;

inline void init(){
	re si u,v;
	for(re int i=1;i<=m;++i){
		 u=e[i].u,v=e[i].v;
		g[v][u]=g[u][v]=true;
	}
	tim=0;
	for(re si i=1;i<=n;++i){
		dfn[i]=low[i]=inr[i]=0;
	}
}

inline si in(){
	re si res=0;
	re char tp=getchar();
	while(!isdigit(tp))tp=getchar();
	while(isdigit(tp))res=res*10+tp-z,tp=getchar();
	return res;
}

inline int inm(){
	re int res=0;
	re char tp=getchar();
	while(!isdigit(tp))tp=getchar();
	while(isdigit(tp))res=res*10+tp-z,tp=getchar();
	return res;
}

inline si minn(const si &x,const si &y){
	return (x=dfn[x]){
				re si tmp;
				hed=0;
				do{
					tmp=stk[top--];
					dcc[tmp]=true;
					ind[++hed]=tmp;
				}while(tmp!=y);
				dcc[x]=true;
				ind[++hed]=x;
				re si p;
				re bool res=!judge(x,0);
				while(hed){
					p=ind[hed];
					dcc[p]=false;
					inr[p]|=res;
					col[p]=-1;
					--hed;
				}
			}
		}
		else low[x]=minn(low[x],dfn[y]);
	}
}

int main(){
	//memset(col,0xcf,sizeof(col));
	for(re si i=1;i

之后的习题:

P3225 【HNOI2012】矿场搭建

求 v-DCC,分类讨论:

1.无割点,那么得设两个逃生口互相保证。

2.一个割点,那么在非割点处设一个逃生口,割点没了就从自己这里除去,逃生口没了就去别的 v-DCC 逃生。

3.两个割点,不用设,任意一个割点塌了都可以从另一个去别的 v-DCC 逃生。

方案数就是所有 v-DCC 的方案贡献相乘。

参考代码:

#include
#define pb push_back
#define ull unsigned long long
using namespace std;

int n;

int maxx;

vectorgo[1010];
vectorvdcc[1010];int tot;
int gd[1010];


int dfn[1010],low[1010],rt,ord;
int sta[1010],top=0;
void tarjan(int x){
	low[x]=dfn[x]=++ord;
	sta[++top]=x;
	int is=0;
	for(int to:go[x]){
		
		
		if(!dfn[to]){
			tarjan(to);
			low[x]=min(low[x],low[to]);
			if(dfn[x]<=low[to]){
				is++;tot++;
				if(x!=rt||is>1)gd[x]=1;
				int tmp=0;
				do{
					tmp=sta[top--];
					vdcc[tot].pb(tmp);
				}while(tmp!=to);
				vdcc[tot].pb(x);
			}
		}
		else{
			low[x]=min(low[x],dfn[to]);
		}
		
	}
	//if(x==rt&&is>=2)gd[x]=1;
	//else if(is>=1)gd[x]=1;
}

void initialize(){
	maxx=0;
	tot=0;
	ord=0;
	top=0;
	for(int i=1;i<=1000;i++){
		go[i].clear();
		vdcc[i].clear();
	}
	memset(gd,0,sizeof(gd));
	memset(dfn,0,sizeof(dfn));
	memset(low,0,sizeof(low));
}

int main(){
	ios::sync_with_stdio(false);
	cin.tie(nullptr);cout.tie(nullptr);
	
	int cas=0;
	while(cin>>n&&n){
		cas++;
		initialize();
		
		ull ans1=0,ans2=1;
		
		int u,v;
		for(int i=1;i<=n;i++){
			cin>>u>>v;
			maxx=max(u,maxx);
			maxx=max(v,maxx);
			go[u].pb(v);
			go[v].pb(u);
		}
		
//		for(int i=1;i<=maxx;i++){
//			
//			if(!dfn[i]){
//				rt=i;
//				tarjan(i);	
//			}
//			
//		}
		rt=1;
		tarjan(1);
		
		for(int i=1;i<=tot;i++){
			int gdt=0;
			ull siz=vdcc[i].size();
			for(int tmp:vdcc[i]){
				if(gd[tmp])gdt++;
			}
			if(gdt==0){
				ans1+=2;
				ans2*=(siz*(siz-1ull)/2ull);
			}
			else if(gdt==1){
				ans1++;
				ans2*=(siz-1ull);
			}
		}
		
		cout<<"Case "<

CF700C Break Up

找到任意一条从 \(s\)\(t\) 的合法路径,枚举其上面的边,作为第一条删边,因为第二条删边不存在或者在另一条删去第一条边的合法路径上,或者答案就为 \(-1\) ,所以取任意一条路径是正确的。

第二次也任意取一条从 \(s\)\(t\) 的合法路径,若答案存在,则不可能取到另一条起点为 \(s\) 终点为 \(t\) 的不相交合法路径(否则就没有割边了)。

若取不到这条合法路径,就说明已经不连通,只切一条边就够了。

然后对删边以后的图 \(tarjan\) 求割边,第二次找到的合法路径上的割边就是第二次的删边。

注意细节

参考代码:

#include
#define pb push_back
using namespace std;

const int maxn=1010,maxm=30010;

int n,m,s,t;

int ans=0x7fffffff;
int c;
int s1,s2;

struct edge{
	int to,len,ord;
};

int u[maxm],v[maxm],w[maxm];

vectorgo[maxn];

int vis[maxn];
bool dfs(int x,int fa,int del,vector&path){
	vis[x]=1;
	if(x==t)return true;
	for(edge tmp:go[x]){
		if(tmp.to==fa||vis[tmp.to]||tmp.ord==del)continue;
		if(dfs(tmp.to,x,del,path)){
			path.pb(tmp.ord);
			return true;
		}
	}
	return false;
}

int dfn[maxn],low[maxn],gb[maxm],seq=0;
//int stk[maxn],top=0;

void tarjan(int x,int come,int del){
	//stk[++top]=x;
	low[x]=dfn[x]=++seq;
	
	for(edge tmp:go[x]){
		if(tmp.ord==del||tmp.ord==come)continue;
		if(!dfn[tmp.to]){
			tarjan(tmp.to,tmp.ord,del);
			low[x]=min(low[x],low[tmp.to]);
			if(low[tmp.to]>dfn[x]){
				gb[tmp.ord]=1;
			}
		}
		else{
			low[x]=min(low[x],dfn[tmp.to]);
		}
	}
}

int main(){
	ios::sync_with_stdio(false);
	cin.tie(nullptr);cout.tie(nullptr);
	
	cin>>n>>m;
	
	cin>>s>>t;
	
	for(int i=1;i<=m;i++){
		cin>>u[i]>>v[i]>>w[i];
		go[u[i]].pb((edge){v[i],w[i],i});
		go[v[i]].pb((edge){u[i],w[i],i});
	}
	
	vectorpath;
	path.clear();
	
	if(!dfs(s,0,0,path)){
		cout<<"0"<np;
		np.clear();
		
		memset(vis,0,sizeof(vis));
		if(!dfs(s,0,now,np)){
			if(w[now]<=ans){
				ans=w[now];
				c=1;
				s1=now;
			}
			continue;
		}
		
		seq=0;
		memset(dfn,0,sizeof(dfn));
		memset(low,0,sizeof(low));
		memset(gb,0,sizeof(gb));
		tarjan(s,0,now);
		
		for(int tmp:np){
			if(!gb[tmp])continue;
			if(w[now]+w[tmp]=0x7fffffff)cout<<"-1"<