前言
“Tarjan 陪伴强连通分量
生成树完成后思路才闪光
欧拉跑过的七桥古塘
让你 心驰神往”
——《膜你抄》
引入
什么是强联通分量(SCC)呢?
有向图强连通分量:在有向图 G G G 中,如果两个顶点 v i , v j v_i,v_j vi,vj 间( v i > v j v_i>v_j vi>vj)有一条从 v i v_i vi 到 v j v_j vj 的有向路径,同时还有一条从 v j v_j vj 到 v i v_i vi 的有向路径,则称两个顶点强连通(strongly connected)。如果有向图 G G G 的每两个顶点都强连通,称 G G G 是一个强连通图。有向图的极大强连通子图,称为强连通分量(strongly connected components)。
看下面这个有向图:
它有
3
3
3 个强联通分量,如图:
显然,任何一个强连通分量之中的所有点之间均可到达。
那我们怎么求出一个图中的强连通分量呢?
直接DFS?显然会超时。
对于这个问题,美国计算机科学家Robert Tarjan(罗伯特·塔扬)提出了著名的Tarjan算法。
【题外话】
CSP之前膜拜大佬增加RP:
Tarjan算法
首先,我们应该了解一下DFS生成树,即按照DFS次序形成的树。
以下选自OI Wiki
有向图的 DFS 生成树主要有 4 种边(不一定全部出现):
- 树边(tree edge):示意图中以黑色边表示,每次搜索找到一个还没有访问过的结点的时候就形成了一条树边。
- 反祖边(back edge):示意图中以红色边表示(即 7 → 1 7\rightarrow1 7→1),也被叫做回边,即指向祖先结点的边。
- 横叉边(cross edge):示意图中以蓝色边表示(即 9 → 7 9\rightarrow7 9→7),它主要是在搜索的时候遇到了一个已经访问过的结点,但是这个结点 并不是 当前结点的祖先时形成的。
- 前向边(forward edge):示意图中以绿色边表示(即 3 → 6 3\rightarrow6 3→6),它是在搜索的时候遇到子树中的结点的时候形成的。
以上选自OI Wiki
对于文章开头的那张图,我们尝试对它进行DFS,组成DFS树:
我们发现,那条反祖边(红边)指向了DFS树的根,形成了一个环,环是强连通的。
但是,我们怎么知道这个环是不是强联通分量呢?
很显然,我们只需要找到一条终点是DFS树的根,起点尽量靠下的反祖边,这条反祖边形成的一定是SCC。
注意,这里说的DFS树不一定是整个图的DFS树,也可以是子图的DFS树。
然而,我们又怎么确定哪些点是在SCC中呢?
如果结点 u u u 是某个强连通分量在搜索树中遇到的第一个结点,那么这个强连通分量的其余结点肯定是在搜索树中以 u u u 为根的子树中。 ——OI Wiki
为什么呢?
即得易见平凡,仿照上例显然,留作习题答案略,读者自证不难。反之亦然同理,推论自然成立,略去过程QED ,由上可知证毕。
——《西江月·证明》
我们假设一个点 x x x 在SCC中却不在以 u u u 为根的子树中,那么从 u u u 到 x x x 的过程中一定有一个反祖边或横叉边使其在子树外,而根据定义,这两种边都在 u u u 之前被搜索到,与“结点 u u u 是某个强连通分量在搜索树中遇到的第一个结点”矛盾,得证。
我们只需要开一个栈,从树根开始,把遍历到的点都加到栈里。当我们发现一个SCC存在时,就不断地弹出栈,直到弹出DFS树的树根为止。这样,弹出的节点就是SCC中的节点。
那么,算法的基本原理已经了解了,我们来具体地实现一下。
首先,我们要记录每个节点是第几个被搜索到的(即时间戳),记作 d f n [ x ] dfn[x] dfn[x]。
然后,我们要记录每个节点所能到达的 d f n dfn dfn 最小的节点的 d f n dfn dfn ,记作 l o w [ x ] low[x] low[x]。
我们还要开一个栈,将遍历到的所有点加入栈中。
这样一来,如果我们跑完DFS,发现根节点的 d f n dfn dfn 等于 l o w low low,则说明根节点又回到了自身。这时我们就可以依次弹出栈中的节点,加入这个强连通分量,知道弹出根为止。
我们模拟一下下图:
从
1
1
1 开始遍历,
d
f
n
[
1
]
=
l
o
w
[
1
]
=
1
dfn[1]=low[1]=1
dfn[1]=low[1]=1,
1
1
1 入栈;
1
→
2
1\rightarrow2
1→2,
d
f
n
[
2
]
=
l
o
w
[
2
]
=
2
dfn[2]=low[2]=2
dfn[2]=low[2]=2,
2
2
2 入栈;
2
→
3
2\rightarrow3
2→3,
d
f
n
[
3
]
=
l
o
w
[
3
]
=
3
dfn[3]=low[3]=3
dfn[3]=low[3]=3,
3
3
3 入栈;
3
→
4
3\rightarrow4
3→4,
d
f
n
[
4
]
=
l
o
w
[
4
]
=
4
dfn[4]=low[4]=4
dfn[4]=low[4]=4,
4
4
4 入栈;
4
4
4 的子树遍历完了,发现
d
f
n
[
4
]
=
l
o
w
[
4
]
dfn[4]=low[4]
dfn[4]=low[4],
4
4
4 出栈,
4
4
4 自己为一个SCC;
3
3
3 的子树遍历完了,发现
d
f
n
[
3
]
=
l
o
w
[
3
]
dfn[3]=low[3]
dfn[3]=low[3],
3
3
3 出栈,
3
3
3 自己为一个SCC;
2
→
5
2\rightarrow5
2→5,
d
f
n
[
5
]
=
l
o
w
[
5
]
=
5
dfn[5]=low[5]=5
dfn[5]=low[5]=5,
5
5
5 入栈;
5
→
1
5\rightarrow1
5→1,
l
o
w
[
5
]
=
1
low[5]=1
low[5]=1,向前转移,
l
o
w
[
2
]
=
1
low[2]=1
low[2]=1;
2
2
2 的子树遍历完了,发现
d
f
n
[
2
]
≠
l
o
w
[
2
]
dfn[2]\ne low[2]
dfn[2]=low[2],不操作;
1
1
1 的子树遍历完了,发现
d
f
n
[
1
]
=
l
o
w
[
1
]
dfn[1]=low[1]
dfn[1]=low[1],
5
5
5 出栈,
2
2
2 出栈,
1
1
1 出栈,
1
,
2
,
5
1,2,5
1,2,5 为一个SCC,结束。
具体代码实现呢?
void tarjan(int x)
{
dfn[x]=low[x]=++times;//时间戳+1并记录,low先设为times
vis[x]=1;//标记为已入栈
sta.push(x);
for(int i=head[x];~i;i=edge[i].nxt)
{
if(!dfn[edge[i].to])//如果没搜过
{
tarjan(edge[i].to);//搜它
low[x]=min(low[x],low[edge[i].to]);//更新
}
else if(vis[edge[i].to])//如果在栈中
low[x]=min(low[x],dfn[edge[i].to]);//更新
}//如果这个节点被搜过却没在栈中,说明它已经在别的SCC中了,不用处理
if(dfn[x]==low[x])//找到SCC
{
tot++;//SCC总数+1
while(1)
{
int t=sta.top();
sta.pop();
vis[t]=0;
scc[t]=tot;
if(t==x)
break;
}
}
}
但是我们不确定给定的图是否是联通的,所以通常我们将所有的点循环作为DFS的根,如下:
for(int i=1;i<=n;i++)
if(!dfn[i])
tarjan(i);
还有不理解的地方可以看后面例题代码。
例题
给定一个 n n n 个点 m m m 条边有向图,每个点有一个权值,求一条路径,使路径经过的点权值之和最大。你只需要求出这个权值和。
允许多次经过一条边或者一个点,但是,重复经过的点,权值只计算一次。
显然,一个SCC中的点都可以到达,那么我们可以将一个SCC缩为一个点,点权为SCC中的点的点权之和。同时将缩成的点重新连边,这时形成的新图一定是一个DAG(有向无环图)。我们在DAG上跑一遍拓扑排序,按照拓扑序进行DP即可。
代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include<stack>
#include<queue>
#define MAXN 10010
#define MAXM 100010
using namespace std;
struct Edge
{
int from;
int to;
int nxt;
}
edge[MAXM];
int head[MAXN],size;
void add(int from,int to)
{
edge[++size].nxt=head[from];
edge[size].from=from;
edge[size].to=to;
head[from]=size;
}
struct Edge_//新边
{
int to;
int nxt;
}
edge_[MAXM];
int head_[MAXN],size_;
void add_(int from,int to)
{
edge_[++size_].nxt=head_[from];
edge_[size_].to=to;
head_[from]=size_;
}
void init()
{
memset(edge,-1,sizeof(edge));
memset(head,-1,sizeof(head));
memset(edge_,-1,sizeof(edge_));
memset(head_,-1,sizeof(head_));
}
int n,m;
int a[MAXN];
int u,v;
int dfn[MAXN],low[MAXN];
bool vis[MAXN];
int dis[MAXN];//新点权
int scc[MAXN],tot;
int times;
stack<int> sta;
void tarjan(int x)
{
dfn[x]=low[x]=++times;
vis[x]=1;
sta.push(x);
for(int i=head[x];~i;i=edge[i].nxt)
{
if(!dfn[edge[i].to])
{
tarjan(edge[i].to);
low[x]=min(low[x],low[edge[i].to]);
}
else if(vis[edge[i].to])
low[x]=min(low[x],dfn[edge[i].to]);
}
if(dfn[x]==low[x])
{
tot++;
while(1)
{
int t=sta.top();
sta.pop();
vis[t]=0;
scc[t]=tot;
dis[tot]+=a[t];//处理点权
if(t==x)
break;
}
}
}
int in[MAXN];
int top[MAXN],cnt;
void topsort()//拓扑排序,便于DP
{
queue<int> q;
for(int i=1;i<=tot;i++)
if(in[i]==0)
q.push(i);
while(!q.empty())
{
u=q.front();
q.pop();
top[++cnt]=u;
for(int i=head_[u];~i;i=edge_[i].nxt)
{
in[edge_[i].to]--;
if(in[edge_[i].to]==0)
q.push(edge_[i].to);
}
}
}
int f[MAXN];
int ans;
int main()
{
init();
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)
scanf("%d",&a[i]);
for(int i=1;i<=m;i++)
{
scanf("%d%d",&u,&v);
add(u,v);
}
for(int i=1;i<=n;i++)
if(!dfn[i])
tarjan(i);
for(int i=1;i<=size;i++)//缩点,连边
{
u=edge[i].from;
v=edge[i].to;
if(scc[u]==scc[v])
continue;
add_(scc[u],scc[v]);
in[scc[v]]++;
}
topsort();
for(int i=1;i<=tot;i++)
f[i]=dis[i];
for(int i=1;i<=cnt;i++)//DP
for(int j=head_[top[i]];~j;j=edge_[j].nxt)
if(top[i]!=edge_[j].to)
f[edge_[j].to]=max(f[edge_[j].to],f[top[i]]+dis[edge_[j].to]);
for(int i=1;i<=tot;i++)
ans=max(ans,f[i]);
printf("%d",ans);
return 0;
}
后记
在写这篇文章的时候思路很清晰,却不知从何讲起,查阅了很多资料才完成了这篇文章。事实上,我在文章中引用了很多OI Wiki中的片段,因为我觉得OI Wiki中的讲解更加的严谨。
在这里,我将我所查阅的几份主要资料放上:
谢谢大家的阅读!