图的连通性相关(Tarjan算法)

xishanmeigao / 2023-08-07 / 原文

(大抄蓝书)

Part 1:无向图连通性

无向图的割点与桥

给定无向图 \(G=(V,E)\)

  • 若对于 \(x\in V\),从图中删去节点 \(x\) 以及所有与 \(x\) 关联的边之后,\(G\) 分裂成两个或两个以上不相连的子图,则称 \(x\)\(G\)割点

  • 若对于 \(e\in E\),从图中删去边 \(e\) 之后,\(G\) 分裂成两个不相连的子图,则称 \(e\)\(G\)割边

\(\mathrm{Tarjan}\) 算法基于无向图的深度优先遍历,能够在线性时间内求出无向图的割点和桥,进一步可求出无向图的双联通分量。下面给出一些定义及定理:

时间戳

在图的深度优先遍历中,按照每个节点第一次被访问的时间顺序,给予 \(1\dots n\) 的整数标记,该标记就被称为“时间戳”,记为 \(dfn[x]\)

搜索树

在无向联通图中任选一个节点进行深度优先遍历,每个点只访问一次,所以发生递归的边 \((x,y)\) 构成一棵树,这棵树被称为“无向联通图的搜索树”。

若无向图不连通,则各个连通块的搜索树构成无向图的“搜索森林”

追溯值

\(\mathrm{subtree(x)}\) 表示搜索树中以 \(x\) 为根的子树。\(x\) 的追溯值 \(low[x]\) 定义为以下节点的时间戳的最小值

  • \(\mathrm{subtree(x)}\) 中的节点
  • 通过 \(1\) 条不在搜索树上的边,能够达到 \(\mathrm{subtree(x)}\) 的节点

感性理解为不经过其父亲能到达的最小的时间戳。

割边判定法则

无向图 \((x,y)\) 是桥,当且仅党搜索树上存在一个 \(x\) 的子节点 \(y\),满足:

\[dfn[x]<low[y] \]

同时可以得出一些结论:

  • 桥一定是搜索树中的边

  • 一个简单环中的边一定都不是桥

一道割边模板题 P1656 炸铁路

考虑一个细节:

对于边 \((x,fa)\) 来说,如果无重边,\(fa\) 的时间戳不能更新 \(low[x]\)。但如果有重边就可以。所以不能单纯在递归时单纯记录每个节点的父节点。我们可以使用成对变换技巧,沿着编号 \(i\) 的边进入节点 \(x\),则忽略从 \(x\) 出发的编号为 \(i\:\mathrm{xor} 1\) 的边

#include<bits/stdc++.h>
using namespace std;

const int N=250,M=5010;

int n,m,dfn[N],low[N],num,cnt;
int head[N],ver[2*M],nxt[2*M],tot=1;
pair <int,int> ans[M];
bool bri[2*M];

bool cmp(pair<int,int> a,pair<int,int> b)
{
	if(a.first==b.first)
		return a.second<b.second;
	return a.first<b.first;
}

void add(int x,int y)
{
	ver[++tot]=y;
	nxt[tot]=head[x];
	head[x]=tot;
}

void tarjan(int x,int last)
{
	low[x]=dfn[x]=++num;
	
	for(int i=head[x]; i; i=nxt[i])
	{
		int y=ver[i];
		
		if(!dfn[y])
		{
			tarjan(y,i);
			low[x]=min(low[x],low[y]);
			if(dfn[x]<low[y]) //割边判定法则
				bri[i]=bri[i^1]=1;		
		}
		else if(i!=(last^1))
			low[x]=min(low[x],dfn[y]);
	}
}

int main()
{
	scanf("%d%d",&n,&m);
	for(int i=1; i<=m; i++)
	{
		int x,y;
		scanf("%d%d",&x,&y);
		add(x,y);  add(y,x);
	}
	
	for(int i=1; i<=n; i++)
		if(!dfn[i])
			tarjan(i,0);
			
	for(int i=2; i<=tot; i+=2)
	{
		if(bri[i])
		{
			int x=ver[i],y=ver[i^1];
			if(x>y)
				swap(x,y);
			ans[++cnt]=make_pair(x,y);
		}
	}
	
	sort(ans+1,ans+1+cnt,cmp);
	
	for(int i=1; i<=cnt; i++)
		printf("%d %d\n",ans[i].first,ans[i].second);
	
	return 0;
}

割点判定法则

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

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

特别地,若 \(x\) 是搜索树的根节点,则 \(x\) 是割点当且仅当搜索树上存在至少两个子节点 \(y_1,y_2\) 满足上述条件

一道割点模板题【模板】割点(割顶)

因为割点判定法则是小于等于号,所以在求割点时,不必考虑父节点和重边的问题,从 \(x\) 出发能访问到的所有节点的时间戳都能用来更新 \(low[x]\)

#include<bits/stdc++.h>
using namespace std;

const int N=20010,M=100010;

int n,m,dfn[N],low[N],num,rt;
int head[N],ver[2*M],nxt[2*M],tot;
bool cut[N];

void add(int x,int y)
{
	ver[++tot]=y;
	nxt[tot]=head[x];
	head[x]=tot;
}

void tarjan(int x)
{
	dfn[x]=low[x]=++num;
	int t=0;
	
	for(int i=head[x]; i; i=nxt[i])
	{
		int y=ver[i];
		if(!dfn[y])
		{
			tarjan(y);
			low[x]=min(low[x],low[y]);
			
			if(dfn[x]<=low[y]) //割点判定法则 
			{
				t++;
				if(x!=rt || t>1)
					cut[x]=1;
			}
		}
		else
			low[x]=min(low[x],dfn[y]);
	}
}

int main()
{
	scanf("%d%d",&n,&m);
	for(int i=1; i<=m; i++)
	{
		int x,y;
		scanf("%d%d",&x,&y);
		add(x,y);  add(y,x);
	}
	
	for(int i=1; i<=n; i++)
		if(!dfn[i])
			rt=i,tarjan(i);
	
	int cnt=0;
	for(int i=1; i<=n; i++)
		if(cut[i])
			cnt++;
	
	printf("%d\n",cnt);
	for(int i=1; i<=n; i++)
		if(cut[i])
			printf("%d ",i);
	
	return 0;
}

无向图的双联通分量

定义

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

  • 无向图的极大点双联通子图被称为“点双联通分量”,简记为“\(\mathrm{v\!-\!DCC}\)”。无向图的极大边双联通子图被称为“边双连通分量”,简记为“\(\mathrm{e\!-\!DCC}\)”。二者统称为“双连通分量”,简记为“\(\mathrm{DCC}\)

定理

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

  • 图中的顶点数不超过 \(2\)

  • 图中任意两点都同时包含在至少一个简单环中。其中“简单环”指的是不自交的环

定理

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

边双(e-DCC)的求法

边双连通分量的计算非常简单,只需求出无向图中所有的桥,把桥都删除后,剩余的若干个连通块就是若干个“边双连通分量”

实现时,可以先用 \(\mathrm{Tarjan}\) 标记出所有桥边,再对整个图进行一次深度优先遍历(不访问桥边),用 \(c\) 数组给每个节点 \(x\) 编号即可

模板题

int c[N],dcc;

void dfs(int x)
{
	c[x]=dcc;
	for(int i=head[x]; i; i=nxt[i])
	{
		int y=ver[i];
		if(c[y] || bri[i])
			continue;
		dfs(y);
	}
}

//在main函数中
for(int i=1; i<=n; i++)
	if(!c[i])
		dcc++,dfs(i);

边双(e-DCC)的缩点

把每个 \(\mathrm{e\!-\!DCC}\) 看成一个点,把桥边 \((x,y)\) 看作连接编号为 \(c[x]\)\(c[y]\)\(\mathrm{e\!-\!DCC}\) 对应节点的无向边,产生一棵树或森林。这就是 \(\mathrm{e\!-\!DCC}\) 的缩点

int hc[N],vc[2*N],nc[2*N],tc=1;

void add_c(int x,int y)
{
	vc[++tc]=y;
	nc[tc]=x;
	hc[x]=tc;
}

//在main函数中
for(int i=2; i<=tot; i+=2)
{
	int x=ver[i^1],y=ver[i];
	if(c[x]==c[y])
		continue;
	add_c(c[x],c[y]);
}

点双(v-DCC)

太难了,放弃,鸽在这

Part 2:有向图连通性

前置知识

给定一个有向图 \(G=(V,E)\),若存在 \(r\in V\),满足从 \(r\) 出发能够到达 \(V\) 中的所有点,则称 \(G\) 是一个“流图”,记为 \((G,r)\),其中 \(r\) 称为流图的源点

在一个流图 \((G,r)\) 上从 \(r\) 出发进行深度优先遍历,每个点只访问一次,所有发生递归的边 \((x,y)\) 构成一棵以 \(r\) 为根的树,我们把它称为流图 \((G,r)\)搜索树

同时,类似无向图,在流图的深度优先遍历中,按照每个节点第一次被访问的时间顺序,给予 \(1\dots n\) 的整数标记,该标记被称为“时间戳”,记为 \(dfn[x]\)

流图中的每条有向边 \((x,y)\) 必然是以下四种之一:

  • 树边:指搜索树中的边,即 \(x\)\(y\) 的父亲
  • 前向边:指搜索树中 \(x\)\(y\) 的祖先
  • 返祖边:指搜索树中 \(y\)\(x\) 的祖先
  • 横叉边:除了上述三种情况以外的边,它一定满足 \(dfn[y]<dfn[x]\)

有向图的强联通分量

  • 在有向图中,若两个节点 \(x,y\) 能够相互到达,则称这两个点是强连通的,它们之间具有强连通性

  • 给定一张有向图,若对于图中任意两个节点 \(x,y\) 都是强联通的,则称该有向图是一张强连通图

  • 有向图的极大强联通子图被称为“强连通分量”,简记为“\(\mathrm{SCC}\)

\(\mathrm{Tarjan}\) 算法基于有向图的深度优先遍历,能够在线性时间内求出一张有向图的各个强联通分量。

一个“环”一定是强联通图。如果节点 \(x,y\) 强联通,那么 \(x,y\) 显然在一个环中。因此,\(\mathrm{Tarjan}\) 算法的基本思路就是对于每个点,尽量找到与它一起能构成环的所有节点

容易发现,前向边没有什么用处,而返祖边非常有用,横叉边可能有用。为了找到通过“返祖边”和“横叉边”构成的环,\(\mathrm{Tarjan}\) 算法在深度优先遍历的同时维护了一个栈。当访问到节点 \(x\) 时,栈中需要保存以下两类节点:

  • 搜索树上 \(x\) 的祖先节点,记为集合 \(anc(x)\)

    \(y\in anc(x)\)。若存在返祖边 \((x,y)\),则 \((x,y)\)\(y\)\(x\) 的路径一起构成环

  • 已经访问过,并且存在一条路径到达 \(anc(x)\) 的节点

    \(z\) 是这样的一个点,从 \(z\) 出发存在一条路径到 \(t\in anc(x)\)。若存在横叉边 \((x,z)\),则 \((x,z)\)\(z\)\(y\) 的路径,\(y\)\(x\) 的路径构成一个环

综上,栈中的节点就是能与 \(x\) 出发的“返祖边”、“横叉边”形成环的节点。进而可以引入追溯值的概念

追溯值

\(\mathrm{subtree(x)}\) 表示流图的搜索树中以 \(x\) 为根的子树。\(x\) 的追溯值 \(low[x]\) 定义为满足以下条件的节点的最小时间戳:

  • 该点在栈中
  • 存在一条从 \(\mathrm{subtree(x)}\) 出发的有向边,以该点为终点

根据定义,可按照以下步骤计算“追溯值”

  • 当节点 \(x\) 第一次被访问时,把 \(x\) 入栈,初始化 \(low[x]=dfn[x]\)

  • 扫描从 \(x\) 出发的每条边 \((x,y)\)

    • \(y\) 没被访问过,说明 \((x,y)\) 是树边,递归访问 \(y\),从 \(y\) 回溯以后,令 \(low[x]=\min(low[x],low[y])\)
    • \(y\) 被访问过且 \(y\) 在栈中,则令 \(low[x]=\min(low[x],dfn[y])\)
  • \(x\) 回溯之前,判断是否有 \(low[x]=dfn[x]\)。若成立,则不断地从栈中弹出节点,直至 \(x\) 出栈

强连通分量判定法则

在追溯值的计算过程中,若在 \(x\) 回溯前,有 \(low[x]=dfn[x]\) 成立,则栈中从 \(x\) 到栈顶的所有节点构成一个强联通分量

SCC的缩点

\(\mathrm{e\!-\!DCC}\) 的缩点类似,对于每条有向边 \((x,y)\),若 \(c[x]\neq c[y]\),则在编号为 \(c[x]\)\(c[y]\)\(\mathrm{SCC}\) 之间连边,最后会得到一张有向无环图

\(\mathrm{SCC}\) 缩点模板题【模板】缩点

#include<bits/stdc++.h>
using namespace std;

const int N=10010,M=100010;

int n,m,a[N],mx;
int dfn[N],low[N],num;
int sta[N],top,ins[N],c[N],cnt,val[N];
int ans[N],p,in_deg[N],f[N];
int head[N],ver[M],from[M],nxt[M],tot;
vector <int> scc[N],g[N],gg[N];
queue <int> q;

void add(int x,int y)
{
	ver[++tot]=y;
	from[tot]=x;
	nxt[tot]=head[x];
	head[x]=tot;
}

void tarjan(int x)
{
	dfn[x]=low[x]=++num;
	sta[++top]=x;  ins[x]=1;
	
	for(int i=head[x]; i; i=nxt[i])
	{
		int y=ver[i];
		if(!dfn[y])
		{
			tarjan(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++;  int y;
		do
		{
			y=sta[top--];  ins[y]=0;
			c[y]=cnt;  
			scc[cnt].push_back(y);
			val[cnt]+=a[y];
		}while(x!=y);
	}
}

void topsort()
{
	for(int i=1; i<=cnt; i++)
		if(!in_deg[i])
			q.push(i);
	
	while(q.size())
	{
		int x=q.front();  q.pop();
		ans[++p]=x;
		
		for(int i=0; i<g[x].size(); i++)
		{
			int y=g[x][i];
			in_deg[y]--;
			if(in_deg[y]==0)
				q.push(y);
		}
	}
}

int main()
{
	scanf("%d%d",&n,&m);
	for(int i=1; i<=n; i++)
		scanf("%d",&a[i]);
	for(int i=1; i<=m; i++)
	{
		int x,y;
		scanf("%d%d",&x,&y);
		add(x,y);
	}
	
	for(int i=1; i<=n; i++)
		if(!dfn[i])
			tarjan(i);

	for(int i=1; i<=tot; i++)
	{
		int x=from[i],y=ver[i];
		if(c[x]==c[y])
			continue;
		g[c[x]].push_back(c[y]);
		gg[c[y]].push_back(c[x]);
		in_deg[c[y]]++;
	}
	
	topsort();
	
	for(int i=1; i<=cnt; i++)
	{
		int x=ans[i];
		f[x]=val[x];
		
		for(int j=0; j<gg[x].size(); j++)
			f[x]=max(f[x],f[gg[x][j]]+val[x]);
		mx=max(mx,f[x]);
	}
	
	printf("%d",mx);
	
	return 0;
}