图论(6) 连通性相关
Tarjan简直是神
强连通分量
强连通定义:有向图中任意两个点联通。
强连通分量:极大的强连通子图
DFS生成树
由于在 Tarjan 算法求解强连通分量时会用到 DFS 生成树,所以这里对 DFS 生成树的概念和性质先行介绍。
以上图为例,在 DFS 生成树中,共有四种不同的边:
- 
树边:示意图中以黑色边表示,每次搜索找到一个还没有访问过的结点的时候就形成了一条树边。 
- 
反祖边:示意图中以红色边表示(即 \(7 \rightarrow 1\)),也被叫做回边,即指向祖先结点的边。 
- 
横叉边:示意图中以蓝色边表示(即 \(9 \rightarrow 7\)),它主要是在搜索的时候遇到了一个已经访问过的结点,但是这个结点并不是当前结点的祖先。 
- 
前向边:示意图中以绿色边表示(即 \(3 \rightarrow 6\)),它是在搜索的时候遇到子树中的结点的时候形成的。 
性质:
- 
祖先后代性:任意非树边两端具有祖先后代关系。 
- 
子树独立性:结点的每个儿子的子树之间没有边(和上一条性质等价)。 
- 
时间戳区间性:子树时间戳为一段区间。 
- 
时间戳单调性:结点的时间戳小于其子树内结点的时间戳。 
Tarjan 算法
在下文中,我们将找强连通分量的操作叫做染色。
准备变量:
\(dfn_i\):存储节点的时间戳,即被访问的次序,一般认为强联通分量中 \(dfn\) 最小的点为根。
\(low_i\):存储节点能到达的的回点(一个走过的点)的最小时间戳。
\(idx\):此时的时间戳。
\(stk_i\):一个栈,存储走过的且没有被染色的点。
\(col_i\):存储节点的颜色。
\(tot\):表示此时的颜色总数。
算法流程:
1)走到一个结点时,记录时间戳,更新 \(dfn_i\) 和 \(low_i\),初始时 \(low_i=dfn_i=++idx\),将自身入栈。
2)向下搜索所有儿子 \(y\),判断这个儿子 \(y\) 是否是回点,如果是回点(即 \(dfn_y\) 不为 \(0\) 且在栈中),就直接用回点的 \(dfn_y\) 更新当前点的 \(low_x= \min (low_x,dfn_y)\),如果不是回点,就回溯地更新 \(low_x=min(low_x,low_y)\)。
3)判断当前点是否是一个强联通分量的根(\(low_i==dfn_i\)),如果是则将栈中所有元素逐个出栈、染色,直到将自己染完色。
关于最后的判断,当 \(low_i==dfn_i\) 时,\(i\) 就为 DFS 生成树中最先遇到的强连通分量中的那个节点,故可对该点进行缩点。
Code.
#include<bits/stdc++.h>
#define endl "\n"
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
//value 
const int N=1e6+5;
vector<int> e[N],scc[N];
int dfn[N],low[N],n,m,tim,top,st[N],col[N],num;
int vis[N];
//function 
void tarjan(int u){
	low[u]=dfn[u]=++tim;
	st[++top]=u;
	for(int i=0;i<e[u].size();i++){
		int v=e[u][i];
		if(!dfn[v]){
			tarjan(v);
			low[u]=min(low[u],low[v]);
		}
		else if(!col[v])low[u]=min(low[u],dfn[v]);
	}
	if(low[u]==dfn[u]){
		num++;
		while(st[top]!=u){
			col[st[top]]=num;
			scc[num].push_back(st[top]);
			top--;
		}
		col[st[top]]=num;
		scc[num].push_back(st[top]);
		top--;
	}
}
 
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0); 
	
//	int n,m;
	cin>>n>>m;
	for(int i=1;i<=m;i++){
		int u,v;
		cin>>u>>v;
		e[u].push_back(v);
	}
	for(int i=1;i<=n;i++){
		if(!dfn[i])tarjan(i);
	}
	memset(vis,0,sizeof(vis));
    cout<<num<<endl;
	for(int i=1;i<=n;i++){
		if(!vis[col[i]]){
			vis[col[i]]=1;
			sort(scc[col[i]].begin(),scc[col[i]].end());
			for(int j=0;j<scc[col[i]].size();j++){
				cout<<scc[col[i]][j]<<' ';
			}
			cout<<endl;
		}
	}
	
	return 0;
}
Kosaraju 算法
算法简单好写,时间复杂度为 \(O(n+m)\)。
算法过程:
第一次 DFS,进行后序遍历,记录下每个点后序遍历的编号。第二次 DFS 在原图的反图上,对于第一次遍历出的编号从大到小进行 DFS,遍历到的顶点集合即为一个强连通分量。对于未被访问的节点继续遍历,知道该图被完全遍历。
在该过程中,既保证了求出的都是强连通分量(命题1),又保证了所有的极大的强连通分量或被搜索到(命题2),且该算法依次求出的强连通分量已经是拓扑序的(命题3)。
正确性证明:
- 
对于命题 1,若在反图中,有边 \(x \to y\),则说明在原图中存在 \(y \to x\)。但 \(x\) 在后序遍历中编号又比 \(y\) 大,则只存在两种可能:\(y\) 是 \(x\) 的搜索树子树中的节点或 \(x\) 和 \(y\) 没有祖先关系。但又因原图中存在 \(y \to x\),故第二种可能不存在,第一种可能 \(x\) 与 \(y\) 为强连通分量。 
- 
对于命题 2 和命题 3,其实我就已经不会证了,但还是引下链接,是一篇很严谨的证明。 
Code.
#include<bits/stdc++.h>
#define endl "\n"
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
//value 
const int ma=1000005;
vector<int>e1[10005];
vector<int>e2[10005];
vector<int>col[ma];
int color[ma],n,m,k=0;
stack<int>s;
bool vis[ma];
//function 
void dfs1(int u){
	vis[u]=0;
	for(int i=0;i<e1[u].size();i++){
		int v=e1[u][i];
		if(vis[v])dfs1(v);
	}
	s.push(u);
}
void dfs2(int u) {
	col[k].push_back(u);
	color[u]=k;
	vis[u]=0;
	for(int i=0;i<e2[u].size();i++){
		int v=e2[u][i];
		if(vis[v])dfs2(v);
	}
}
 
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0); 
	
//	int n,m;
	cin>>n>>m;
	for(int i=1;i<=m;i++){
		int u,v;
		cin>>u>>v;
		e1[u].push_back(v);
		e2[v].push_back(u);
	}
	memset(vis,1,sizeof(vis));
	for(int i=1;i<=n;i++) if(vis[i])dfs1(i);
	int cnt=0;
	memset(vis,1,sizeof(vis));
	while (!s.empty()) {
		int t=s.top(); 
		s.pop();
		if (vis[t]) {
			++k; 
			dfs2(t);
		}
	}
	memset(vis,1,sizeof(vis));
	for(int i=1;i<=k;i++) sort(col[i].begin(),col[i].end());
	cout<<k<<endl;
	memset(vis,true,sizeof(vis));
	for(int i=1;i<=n;i++){
		if (vis[color[i]]) {
			for(int j=0;j<col[color[i]].size();j++) cout<<col[color[i]][j]<<" ";
			cout<<endl;
			vis[color[i]]=false;
		}
	}
	
	return 0;
}
割点和割边
将该点删掉后原图的连通块变多的点即为割点。
同理,将该边删掉后原图的连通块变多的点即为割边,又称桥。
割点
算法过程:
(学了三次才会,我是煞笔)
在原图上跑出一颗 DFS 生成树,记 \(low_i\) 为点 \(i\) 及其子树中的点通过一条非树边所能达到的时间戳最小的点。对于点 \(x\),思考 \(x\) 为割点的几种情况:
- 
当 \(x\) 为根节点时,由于它为该连通图中 \(low\) 最小的点,由因为 dfs 生成树的子树独立性,故当根节点有多个子树时,\(x\) 为割点。 
- 
当 \(x\) 不为根节点时,若存在以 \(x\) 为端点的边,则对于该边若 \(low_v \geq dfn_u\),则 \(x\) 为割点。 
对于 \(2\) 的人话证明:如果 \(low_v \geq dfn_u\),则证明在删除点 \(x\) 后,\(v\) 所在的连通块与 \(x\) 父节点所在的连通块为两个连通块,\(x\) 符合割点定义。
正确性证明:来自大佬更详细标准的正确性证明
Code.
#include<bits/stdc++.h>
#define endl "\n"
#define pb push_back
#define mkp make_pair
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
//value 
const int inf=2147483647;
const int mod=1e9+7;
int low[200005],dfn[200005],vis[200005],cnt[200005];
int idx,R;
vector<int>g[200005];
vector<int>ans;
//function 
void solve(){
	
	
	
	return;
}
void dfs(int id,int fa){
	//初始化该点
	dfn[id]=low[id]=++idx;
	vis[id]=1;
	int son=0;//统计子树
	for(auto i:g[id]){
//		if(i==fa)continue;
		if(!vis[i]){
			//如果该边为树边
			son++;
			dfs(i,id);
			low[id]=min(low[id],low[i]);
			if(id!=R && low[i]>=dfn[id])cnt[id]=1;
			if(son>=2 && id==R)cnt[id]=1;
		}
		else low[id]=min(low[id],dfn[i]);//非树边
	}
	
	return;
}
 
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0); 
	
//	freopen(".in","r",stdin);
//	freopen(".out","w",stdout);
	
	//输入
	int n,m;
	cin>>n>>m;
	for(int i=1;i<=m;i++){
		int u,v;
		cin>>u>>v;
		g[u].push_back(v);
		g[v].push_back(u);
	}
	//tarjan
	for(int i=1;i<=n;i++){
		if(!vis[i]){
			R=i;
			dfs(R,0);
		}
	}
	//统计答案
	int ans=0;
	for(int i=1;i<=n;i++){
		if(cnt[i]==1)ans++;
	}
	cout<<ans<<endl;
	for(int i=1;i<=n;i++){
		if(cnt[i]==1)cout<<i<<' ';
	}
	
	return 0;
}
简单解释下代码中一些点:
- 
由于原图不一定联通,故需要循环扫遍每一个连通块。 
- 
在统计过程中,有些点可能被重复统计(尚不知原因),故需在扫完后再统计 \(ans\)。 
- 
对于非树边的更新,证明可参考大佬的难点证明。 

割边
和割点差不多,只要改一处:\(low_v > dfn_u\) 就可以了,而且不需要考虑根节点的问题。
割边是和是不是根节点没关系的,原来我们求割点的时候是指点 \(v\) 是不可能不经过父节点 \(u\) 为回到祖先节点(包括父节点),所以顶点 \(u\) 是割点。如果 \(low_v = dfn_u\) 表示还可以回到父节点,如果顶点 \(v\) 不能回到祖先也没有另外一条回到父亲的路,那么 \(u-v\) 这条边就是割边。
双连通分量
相关定义:
在一张连通的无向图中,对于两个点 \(u\) 和 \(v\),如果无论删去哪条边(只能删去一条)都不能使它们不连通,我们就说 \(u\) 和 \(v\) 边双连通。
在一张连通的无向图中,对于两个点 \(u\) 和 \(v\),如果无论删去哪个点(只能删去一个,且不能删 \(u\) 和 \(v\) 自己)都不能使它们不连通,我们就说 \(u\) 和 \(v\) 点双连通。
注意到,边双联通具有传递性,但点双联通不具有传递性。
点双连通分量
(感觉点双不如边双)
算法过程:
- 
在遍历的时候不断的将点加入栈,并更新 \(low_u\)。 
- 
令割点为 \(u\),当在枚举 \(u \to v\) 时发现 \(u\) 是一个割点,将栈内弹出元素直到弹出 \(v\)。 
- 
将 \(u\) 加入该点双连通分量,但不弹出 \(u\)。 
Code.
#include<bits/stdc++.h>
#define endl "\n"
#define pb push_back
#define mkp make_pair
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
//value 
const int inf=2147483647;
const int mod=1e9+7;
const int N=5e5+5;
int low[N],dfn[N],sta[N];
vector<int>g[N],dcc[N];
int num,cnt[N],tot,top;
//function 
void solve(){
	
	
	
	return;
}
void tarjan(int u,int fa){
	low[u]=dfn[u]=++tot;
	sta[++top]=u;
	if(!cnt[u])dcc[++num].push_back(u);
	for(auto v:g[u]){
		if(!dfn[v]){
			int tmp=top;
			tarjan(v,u);
			low[u]=min(low[u],low[v]);
			if(low[v]>=dfn[u]){
				num++;
				while(top>tmp){
					dcc[num].push_back(sta[top]);
					top--;
				}
				dcc[num].push_back(u);
			}
		}
		else low[u]=min(low[u],dfn[v]);
	}
	/*
	if (child==0 && u==fa) {
        num++;
        dcc[num].pb(u);
        top--;
    }
    */
}
 
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0); 
	
//	freopen(".in","r",stdin);
//	freopen(".out","w",stdout);
	
	int n,m;
	cin>>n>>m;
	while(m--){
		int u,v;
		cin>>u>>v;
		if(u==v)continue;
		g[u].push_back(v);
		g[v].push_back(u);
		cnt[u]++;
		cnt[v]++;
	}
	for(int i=1;i<=n;i++){
		top=0;
		if(!dfn[i])tarjan(i,i);
		//fa为i防自环
	}
	
	cout<<num<<endl;
//	for(int i=1;i<=num;i++)sort(dcc[i].begin(),dcc[i].end());
	for(int i=1;i<=num;i++){
		cout<<dcc[i].size()<<' ';
		for(auto j:dcc[i])cout<<j<<' ';
		cout<<endl;
	}
	
	
	
	
	return 0;
}
记得注意细节实现。
边双连通分量
(竟然一遍过了吗)
算法过程:
- 
在遍历的时候不断将点加入栈,并更新 \(low_u\)。 
- 
判断该点是否是该边双连通分量在 DFS 生成树上的子树根。 
- 
将该点及其子树加入同一个边双连通分量。 
Code.
#include<bits/stdc++.h>
#define endl "\n"
#define pb push_back
#define mkp make_pair
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
//value 
const int inf=2147483647;
const int mod=1e9+7;
const int N=5e5+5;
int low[N],dfn[N],sta[N];
vector<pair<int,int> >g[N];
vector<int>dcc[N];
int top,tot,num;
//function 
void solve(){
	
	
	
	return;
}
void tarjan(int u,int las){
	low[u]=dfn[u]=++tot;
	sta[++top]=u;
	for(auto v:g[u]){
		if(v.second==las)continue;
		if(!dfn[v.first]){
			tarjan(v.first,v.second);
			low[u]=min(low[u],low[v.first]);
		}
		else low[u]=min(low[u],dfn[v.first]);
	}
	if(dfn[u]==low[u]){
		num++;
		while(sta[top]!=u){
			dcc[num].push_back(sta[top--]);
		}
		dcc[num].push_back(u);
		top--;
	}
}
 
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0); 
	
//	freopen(".in","r",stdin);
//	freopen(".out","w",stdout);
	
	int n,m;
	cin>>n>>m;
	for(int i=1;i<=m;i++){
		int u,v;
		cin>>u>>v;
		g[u].push_back(mkp(v,i));
		g[v].push_back(mkp(u,i));
	}
	
	for(int i=1;i<=n;i++){
		if(!dfn[i])tarjan(i,0);
	}
	
	cout<<num<<endl;
	for(int i=1;i<=num;i++){
		cout<<dcc[i].size()<<' ';
		for(auto i:dcc[i])cout<<i<<' ';
		cout<<endl;
	}
	
	
	return 0;
}
后记
学了这么多肯定是要应用的,最常见且最常用的用途是用于缩点。
Code.
#include<bits/stdc++.h>
#define endl "\n"
#define pb push_back
#define mkp make_pair
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
//value 
const int inf=2147483647;
const int mod=1e9+7;
const int N=10005;
int low[N],sta[N],dfn[N],col[N],dp[N];
int top,tot,num;
vector<int>g[N],g1[N];
int a[N],a1[N],vis[N];
//function 
void solve(){
	
	
	
	return;
}
void tarjan(int u){
	low[u]=dfn[u]=++tot;
	sta[++top]=u;
	for(auto v:g[u]){
		if(!dfn[v]){
			tarjan(v);
			low[u]=min(low[u],low[v]);
		}
		else if(!col[v])low[u]=min(low[u],dfn[v]);
	}
	if(low[u]==dfn[u]){
		num++;
		while(sta[top]!=u){
			col[sta[top]]=num;
			top--;
		}
		col[sta[top]]=num;
		top--;
	}
}
void build(int n){
	for(int i=1;i<=n;i++){
		for(auto v:g[i]){
			if(col[i]==col[v])continue;
			g1[col[i]].push_back(col[v]);
		}
		a1[col[i]]+=a[i];
	}
}
void dfs(int u){
	vis[u]=1;
	for(auto v:g1[u]){
		if(vis[v]==0)dfs(v);
		dp[u]=max(dp[u],dp[v]);
	}
	dp[u]+=a1[u];
}
 
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0); 
	
//	freopen(".in","r",stdin);
//	freopen(".out","w",stdout);
	
	int n,m;
	cin>>n>>m;
	for(int i=1;i<=n;i++)cin>>a[i];
	for(int i=1;i<=m;i++){
		int u,v;
		cin>>u>>v;
		g[u].push_back(v);
	}
	for(int i=1;i<=n;i++){
		if(!dfn[i])tarjan(i);
	}
	build(n);
	
	for(int i=1;i<=n;i++){
		if(vis[i]==0)dfs(i);
	}
	
	int ans=0;
	for(int i=1;i<=num;i++)ans=max(ans,dp[i]);
	cout<<ans<<endl;
	
	
	
	
	return 0;
}

 
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号