c++ 图论-强连通分量 小总结

之前学习了一些强连通分量的知识点,在此做一个小总结。

强连通分量是指是一个极大的子图,图中点两两联通。可以通过强连通分量将一个图转换为有向无环图,进行解决部分问题。

题目

B3609 [图论与代数结构 701] 强连通分量 - 洛谷

给定一张 n 个点 m 条边的有向图,求出其所有的强连通分量。

求法

Tarjan算法

这是比较常用,适用范围较广的一种算法。

DFS生成树

(下面搬运自强连通分量 - OI Wiki,因为本人造不出图,也弄不出来边的种类(╥﹏╥),谢谢啦)

以下面的有向图为例:

oi-wiki.net/graph/images/dfs-tree.svg

有向图的 DFS 生成树主要有 4 种边(不一定全部出现):

  1. 树边(tree edge):示意图中以黑色边表示,每次搜索找到一个还没有访问过的结点的时候就形成了一条树边.
  2. 反祖边(back edge):示意图中以红色边表示(即 7 →1),也被叫做回边,即指向祖先结点的边.
  3. 横叉边(cross edge):示意图中以蓝色边表示(即 9 →7),它主要是在搜索的时候遇到了一个已经访问过的结点,但是这个结点 并不是 当前结点的祖先.
  4. 前向边(forward edge):示意图中以绿色边表示(即 3 →6),它是在搜索的时候遇到子树中的结点的时候形成的.

那么,可以发现:当u是DFS 生成树遇到的第一个属于强连通分量S的节点,那么S的其余节点就一定是u的子树中(具体证明见强连通分量 - OI Wiki),那么,我们就把u称为S的根。

Tarjan

下面,我们正式求解。

首先,我们开一个栈,dfs时将新节点加到栈中。

然后,我们设dfn[i]为i被dfs到的顺序,low[i]为 i 的子树中经过不超过一条边能到达在栈中的节点的dfn最小的值。当我们在遍历u经过一条边能到达的节点v时,会遇到三种情况:

  1. v没有被DFS过,就去DFS v,然后用low[v]更新low[u],因为v的子树包含在u的子树里面,并且v能到达的u肯定也能到达。
  2. V被dfs过,并且在栈中,那么符合low[u]的定义,就用dfn[v]更新low[u]。
  3. V被dfs过,并且不在栈中,就说明它所在的强连通分量已经被处理完了(因为被踢出去了),所以不用处理。

回溯到u后,如果dfn[u]==low[u]时,就说明这个强连通分量没有办法再往上扩展了(因为到不了上面),那么从栈顶到u的都属于同一个强联通分量,就都踢出来即可。

在这里就先不列出代码了,扩展题会涉及到。

Kosaraju算法

如果觉得Tarjan算法有点麻烦的话,不妨试试Kosaraju算法。

这种算法使用2次dfs: 第一次dfs求出后序遍历的顺序,第二次dfs在反图上按后序遍历的顺序从大到小进行,能遍历到的就属于同一个强连通分量。具体就不再证明了。

代码

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
int n,m,idx1,dfn[10050],dfn2[10050],color[10050],idx2;
vector<int>v[10050],v2[10050],color2[10050];
bool vi[10050],vi2[10050];
void dfs1(int x){
	if(vi[x])return;
	vi[x]=1;
	for(int i=0;i<v[x].size();i++){
		dfs1(v[x][i]);
	}
	dfn[x]=++idx1;
	dfn2[idx1]=x;
}
void dfs2(int x){
	if(color[x])return;
	color[x]=idx2;
	color2[idx2].push_back(x);
	for(int i=0;i<v2[x].size();i++){
		dfs2(v2[x][i]);
	}
}
int main(){
	cin>>n>>m;
	for(int i=1;i<=m;i++){
		int u,v3;
		cin>>u>>v3;
		v[u].push_back(v3);
		v2[v3].push_back(u);
	}
	for(int i=1;i<=n;i++){
		if(!vi[i])dfs1(i);
	}
	for(int i=n;i>=1;i--){
		if(!color[dfn2[i]]){
			idx2++;
//			cout<<idx2<<' '<<dfn2[i]<<endl;
			dfs2(dfn2[i]);
		}
	}
	for(int i=1;i<=n;i++){
		if(i>idx2)break;
		sort(color2[i].begin(),color2[i].end());
	}
	cout<<idx2<<endl;
	for(int i=1;i<=n;i++){
		if(vi2[i])continue;
		for(int j=0;j<color2[color[i]].size();j++){
			cout<<color2[color[i]][j]<<' ';
			vi2[color2[color[i]][j]]=1;
		}
		cout<<endl;
	}
	return 0;
}

Garbow 算法

Garbow 算法是Tarjan算法的另一种实现。Tarjan算法是用一个栈加上low数组进行更新,而Garbow 算法使用2个栈。第一个栈按照DFS的顺序正常插入,而第二个栈则存储可能成为强连通分量的根的节点。 在dfs的过程中,当我们在遍历u经过一条边能到达的节点v时,还是会遇到三种情况:

  1. v没有被DFS过,就去DFS v。
  2. V被dfs过,还未被加入到了一个强连通分量中,就从第二个栈中开始看:只要栈顶的dfn值大于v的dfn值,就踢出去,以此类推。
  3. V被dfs过,并且已经被加入到了一个强连通分量中,就不用处理。

回溯到u时,如果第二个栈的栈顶是u,就说明第一个栈中到u的这些点都属于同一个强连通分量,把他们都加入同一个强联通分量并踢出栈中即可。

这里证明一下第二种情况的正确性(因为我在这里卡了很久):

在DFS搜索树中,如果V被DFS过,那么这条边就是三种边之一:前向边、横叉边、反祖边。

如果是前向边,那么不会影响。

如果是反祖边,那么从v到u这条路径就构成了一个环,那么这个强连通分量的根就是v或者v的祖先,所以就可以踢出去这些dfn值大的节点。

如果是横叉边,并且v还没有被加入到一个强连通分量,就说明从v到u、v的最近公共祖先(不包含最近公共祖先)这条路径的这些节点都不是包含v的强连通分量的根,这个包含v的强连通分量的根只能是u、v的最近公共祖先或者u、v的最近公共祖先的祖先,那么就可以踢出去这些dfn值大的节点。

所以,第二种情况下是正确的。

代码

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
int n,m,idx1,dfn[10050],color[10050],idx2;
vector<int>v[10050],color2[10050];
bool vi[10050],vi2[10050];
stack<int>sk1,sk2; 
void dfs1(int x){
	dfn[x]=++idx1;
	vi[x]=1;
	sk1.push(x);
	sk2.push(x);
	for(int i=0;i<v[x].size();i++){
		int to=v[x][i];
		if(!vi[to])dfs1(to);
		else if(!color[to])
			while(dfn[sk2.top()]>dfn[to])sk2.pop();
	}
	if(sk2.top()==x){
		sk2.pop();
		idx2++;
		while(sk1.top()!=x){
			color[sk1.top()]=idx2;
			color2[idx2].push_back(sk1.top());
			sk1.pop();
		}
		color[x]=idx2;
		color2[idx2].push_back(x);
		sk1.pop();
	}
}
int main(){
	cin>>n>>m;
	for(int i=1;i<=m;i++){
		int u,v3;
		cin>>u>>v3;
		v[u].push_back(v3);
	}
	for(int i=1;i<=n;i++)
		if(!vi[i])dfs1(i);
	for(int i=1;i<=n;i++){
		if(i>idx2)break;
		sort(color2[i].begin(),color2[i].end());
	}
	cout<<idx2<<endl;
	for(int i=1;i<=n;i++){
		if(vi2[i])continue;
		for(int j=0;j<color2[color[i]].size();j++){
			cout<<color2[color[i]][j]<<' ';
			vi2[color2[color[i]][j]]=1;
		}
		cout<<endl;
	}
	return 0;
}

扩展

题目

P3387 【模板】缩点 - 洛谷

给定一个 n 个点 m 条边有向图,每个点有一个权值,求一条路径,使路径经过的点权值之和最大。你只需要求出这个权值和。

允许多次经过一条边或者一个点,但是,重复经过的点,权值只计算一次。

(无负权)

思路

因为允许多次经过一条边或者一个点,所以当我们走到一个点时,与这个点同属于一个强连通分量的点都可以被到达,所以我们就可以把每一个强连通分量缩成一个点,这样子就变成了一个有向无环图,可以使用拓扑序等方法来解决。

代码

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
#define ll long long
#define N 100005
ll val1[N],p[N],q[N],dfn[N],low[N],in_st[N],scc[N],scv[N],ru[N],dp[N],sc,timer,maxn,m,n;
vector<ll>v1[N],v2[N];
stack<ll>st;
queue<ll>q1;
void tarjan(ll x){
	dfn[x]=++timer;
	low[x]=dfn[x];
	st.push(x);
	in_st[x]=1;
	for(int i=0;i<v1[x].size();i++){
		ll y=v1[x][i];
		if(!dfn[y]){
			tarjan(y);
			low[x]=min(low[x],low[y]);
		}else if(in_st[y]){
			low[x]=min(low[x],low[y]);
		}
	}
	if(dfn[x]==low[x]){
		scc[x]=++sc;
		while(1){
			ll y=st.top();
			st.pop();
			in_st[y]=0;
			scc[y]=sc;
			scv[sc]+=val1[y];
			if(y==x)break;
		}
	}
}
void topo(){
	for(int i=1;i<=sc;i++){
		if(!ru[i]){
			q1.push(i);
			dp[i]=scv[i];
		}
	}
	while(!q1.empty()){
		ll x=q1.front();
		q1.pop();
		for(int i=0;i<v2[x].size();i++){
			ll y=v2[x][i];
			dp[y]=max(dp[y],dp[x]+scv[y]);
			ru[y]--;
			if(!ru[y])q1.push(y);
		}
	}
}
int main() {
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		cin>>val1[i];
	}
	for(int i=1;i<=m;i++){
		cin>>p[i]>>q[i];
		v1[p[i]].push_back(q[i]);
	}
	for(int i=1;i<=n;i++){
		if(!dfn[i])tarjan(i);
	}
	for(int i=1;i<=m;i++){
		if(scc[p[i]]!=scc[q[i]]){
			v2[scc[p[i]]].push_back(scc[q[i]]);
			ru[scc[q[i]]]++;
		}
	}
	topo();
	for(int i=1;i<=sc;i++){
		maxn=max(maxn,dp[i]);
	}
	cout<<maxn<<endl;
    return 0;
}

如果大家有其他想法的,可以补充。

相关推荐
’长谷深风‘1 小时前
线程函数接口和属性
c语言·开发语言·线程·进程·软件编程
十五年专注C++开发2 小时前
tiny-process-library:一个用 C++ 编写的轻量级、跨平台(支持 Windows、Linux、macOS)的进程管理库
linux·c++·windows·进程管理
啊哈哈哈哈哈啊哈哈2 小时前
AOP笔记
java·开发语言
晔子yy2 小时前
AI编程时代:简单聊聊Agent技术
开发语言·ai
雾岛听蓝2 小时前
C++异常处理
c++·经验分享·笔记
牛马大师兄2 小时前
数据结构复习 | 循环链表
c语言·数据结构·c++·笔记·链表
xyq20242 小时前
Scala 提取器(Extractor)
开发语言
A懿轩A2 小时前
【Java 基础编程】Java 正则表达式实战:Pattern/Matcher、元字符与常用正则,验证与提取必备
java·开发语言·正则表达式
zh_xuan2 小时前
kotlin with函数
开发语言·kotlin