之前学习了一些强连通分量的知识点,在此做一个小总结。
强连通分量是指是一个极大的子图,图中点两两联通。可以通过强连通分量将一个图转换为有向无环图,进行解决部分问题。
题目
B3609 [图论与代数结构 701] 强连通分量 - 洛谷
给定一张 n 个点 m 条边的有向图,求出其所有的强连通分量。
求法
Tarjan算法
这是比较常用,适用范围较广的一种算法。
DFS生成树
(下面搬运自强连通分量 - OI Wiki,因为本人造不出图,也弄不出来边的种类(╥﹏╥),谢谢啦)
以下面的有向图为例:
oi-wiki.net/graph/images/dfs-tree.svg
有向图的 DFS 生成树主要有 4 种边(不一定全部出现):
- 树边(tree edge):示意图中以黑色边表示,每次搜索找到一个还没有访问过的结点的时候就形成了一条树边.
- 反祖边(back edge):示意图中以红色边表示(即 7 →1
),也被叫做回边,即指向祖先结点的边. - 横叉边(cross edge):示意图中以蓝色边表示(即 9 →7
),它主要是在搜索的时候遇到了一个已经访问过的结点,但是这个结点 并不是 当前结点的祖先. - 前向边(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时,会遇到三种情况:
- v没有被DFS过,就去DFS v,然后用low[v]更新low[u],因为v的子树包含在u的子树里面,并且v能到达的u肯定也能到达。
- V被dfs过,并且在栈中,那么符合low[u]的定义,就用dfn[v]更新low[u]。
- 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时,还是会遇到三种情况:
- v没有被DFS过,就去DFS v。
- V被dfs过,还未被加入到了一个强连通分量中,就从第二个栈中开始看:只要栈顶的dfn值大于v的dfn值,就踢出去,以此类推。
- 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;
}
扩展
题目
给定一个 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;
}
如果大家有其他想法的,可以补充。