借鉴文献
感谢这篇文章对我的启发,本文部分内容摘自这篇文章。
同时OI wiki上的介绍也写得不错。
前置内容
T a r j a n Tarjan Tarjan本质上是一个 D F S DFS DFS的过程,所以我们需要先了解一下 D F S DFS DFS。
bool vis[MAXN];
void dfs(int u)
{
vis[u] = true;
for(int e = first[u]; e; e = nxt[e])
{
// 遍历连接u的每条边
int v = go[e];
if(!vis[v]) dfs(v);
// 如果没有访问过就往下继续搜
}
}
而对于
D
F
S
DFS
DFS到的所有的点,其访问的先后顺序有所不同。我们记录访问每个点的次序到一个数组dfn
当中。
int dfn[MAXN], tot = 0;
void dfs(int u)
{
dfn[u] = ++tot; // 时间戳,代表点u是第tot个被访问的节点
for(int e = first[u]; e; e = nxt[e])
{
// 遍历连接u的每条边
int v = go[e];
if(!dfn[v]) dfs(v);
// 如果没有访问过就往下继续搜
}
}
dfn
的值是从1开始的,并初始化为0。因此,如果dfn[i]==0
,那么i
还没有被访问到。
在一个图中做 D F S DFS DFS时,我们往往是将无向图的边转换为两条弧(也即“有向边”)。也就是说,我们可以把所有的图看作是有向图。而对于一次 D F S DFS DFS过程中有向图的边,我们做以下分类:
1. 树边
D F S DFS DFS的过程产生的是一棵树(深度优先遍历树1)。我们将这棵树上的边称为树边。
2. 前向边
如果一条边从某个节点 u u u出发到 u u u在深度优先遍历树上的某个子孙节点 v v v( v v v的深度严格大于 u u u),那么称这条边为前向边。前向边在被 D F S DFS DFS遍历到时一定有如下性质:
dfn[v] && dfn[v] > dfn[u]
dfn[v](!=0)
是因为如果!dfn[v]
,那么
D
F
S
DFS
DFS遍历到这条前向边时一定会继续访问点
v
v
v,那么这条边就是树边了。因此在遍历这条前向边前一定已经访问了点
v
v
v。由于dfn
记录访问顺序,而访问某个节点子孙节点的次序一定严格大于访问该节点的次序,故dfn[v] > dfn[u]
3. 返祖边
和前向边恰恰相反,如果一条边从某个节点 u u u出发到 u u u在深度优先遍历树上的某个祖先节点 v v v(祖先节点包括这个点自己),那么称这条边为返祖边。返祖边在被 D F S DFS DFS遍历到时一定有如下性质:
dfn[v] && dfn[v]<=dfn[u]
dfn[v](!=0)
和前向边是同样的原理。由于祖先节点一定先被访问到(或“同时”被访问到),所以dfn[v]<=dfn[u]
。
4. 横叉边
除了上述三种边之外的所有边我们称之为横叉边。
下面这幅图,如果从 1 1 1开始遍历,得到四种边的分类如图所示:
横叉边有如下性质:
性质 横叉边 u → v u\rightarrow v u→v满足
dfn[u]>dfn[v]
。证明 若
dfn[u]==dfn[v]
,则这条边是返祖边,矛盾。若dfn[u]<dfn[v]
,则节点 u u u先被访问,也就是说访问到节点 u u u时节点 v v v还没有被访问。如果是这样,根据深度优先遍历的策略,访问节点 u u u后就会访问节点 v v v,那么 u → v u\rightarrow v u→v要么是前向边(因为 u → v u\rightarrow v u→v可能存在多条边),要么是树边。产生矛盾。因此,dfn[u]>dfn[v]
。
强连通分量
定义 在有向图 G G G中,如果两个顶点 u u u, v v v间有一条从 u u u到 v v v的有向路径,同时还有一条从 v v v到 u u u的有向路径,则称两个顶点强连通。如果有向图 G G G的每两个顶点都强连通,称 G G G是一个强连通图。有向非强连通图的极大强连通子图,称为强连通分量( S t r o n g l y C o n n e c t e d C o m p o n e n t s , S C C Strongly~Connected~Components,SCC Strongly Connected Components,SCC)。
我们对于极大强连通子图的定义如下:图 G 0 G_0 G0是图 G G G的强连通子图,且不存在图 G G G的强连通子图 G 1 s . t . G 0 ⊂ G 1 G_1~s.t.~G_0\subset G_1 G1 s.t. G0⊂G1。也就是说,对于 G 0 G_0 G0外的任意一点 u u u和 G 0 G_0 G0内的任意一点 v v v,都有 u u u、 v v v不强连通。
我们可以得知,一个环上的所有的点一定属于同一个强连通分量,因为环上的任意两点都可以互相到达。于是我们可以得出以下关于后向边的性质:
性质 若存在返祖边 u → v u\rightarrow v u→v,那么深度优先遍历树上 v v v、 u u u两点(包含)之间路径上的所有点一定在同一个强连通分量中。
证明 如果 u u u和 v v v是同一个点,也即返祖边 u → v u\rightarrow v u→v是自环,那么该结论成立。否则, v v v必然是 u u u在深度优先遍历树上的祖先节点,那么存在从点 v v v到 u u u的有向路径,这条路径在到达 u u u后又可以通过返祖边回到 v v v,于是 v v v到 u u u(包含)之间的路径上的所有点形成一个环,于是它们必然在同一个强连通分量中。
T a r j a n Tarjan Tarjan算法
在
T
a
r
j
a
n
Tarjan
Tarjan算法中,我们构建一个low
数组,并对它定义如下:对于一个节点
u
u
u,它能够通过若干(可以为0)条树边和最多一条返祖边或横叉边(二选一)(并且横叉边指向的点在那时2不能属于任何强连通分量)到达的dfn[v]
值最小的
v
v
v点的dfn
值即为low[u]
。我们再使用一个布尔数组inStack
来记录一个点是否在当前的递归栈中。我们创建一个栈,并创建一个数组co
和计数器col
。每当
D
F
S
DFS
DFS到一个点的时候就将这个点入栈。当访问完这个点周围的节点后(同时也更新好了low
值),判断这个点的low
值是否等于它的dfn
值。如果是这样,那么将col
的值加一,并将这个点和栈中它上面的所有的点弹出,同时将它们的co
值都设为col
。
low
和inStack
数组以及栈的实际意义我们先抛开不谈,我们来想想如何求得low[u]
的值。
low[u]
的初始值就是dfn[u]
,因为点
u
u
u不通过任何一条边到达的点就是它自己,而实际能到达的最小值(low[u]
)一定是小于等于这个值(dfn[u]
)的。
我们思考一下
D
F
S
DFS
DFS的过程。假如说当前
D
F
S
DFS
DFS到的节点就是
u
u
u,程序将会遍历从
u
u
u出发的所有有向边。当枚举到边
e
e
e、到达的点为
v
v
v时,如果点
v
v
v已经被访问过,那么
e
e
e可能是返祖边、前向边或者横叉边。但是如果
v
v
v在递归栈中,也即如果inStack[v](==true)
,那么这条边就是返祖边或符合要求的横叉边,这时就要用dfn[v]
来更新low[u]
的(最小)值。否则,这条边就是前向边,那么我们就不考虑更新。如果点
v
v
v没有被访问过,那么边
e
e
e就是树边,此时我们递归解决这个问题:因为如果从点
u
u
u先经过若干条树边,再经过至多一条返祖边或符合要求的横叉边(实际上是走且仅走一条,否则到达点的dfn
值一定大于
u
u
u点的dfn
值)到达能到达的dfn
最小的点,一定会经过点
v
v
v,此时问题就转化为了从
v
v
v出发直接经过一条返祖边或符合要求的横叉边、不经过任何边和先经过若干条树边,再经过至多一条返祖边或一条符合要求的横叉边到达能到达的dfn
最小的点(实质上就是求点
v
v
v的low
值),而这就变成了一个递归的过程(有点像
D
P
DP
DP)。
于是,就有了如下代码:
#include <stack>
int dfn[MAXN], tot = 0;
bool inStack[MAXN];
int low[MAXN];
int co[MAXN], col = 0;
std::stack<int> stk;
void Tarjan(int u)
{
dfn[u] = ++tot;
low[u] = dfn[u]; // 一开始low[u]是自己
stk.push(u);
inStack[u] = true;
for(int e = first[u]; e; e = nxt[e])
{
int v = go[e];
if(!dfn[v])
{
Tarjan(v);
low[u] = min(low[u], low[v]); // 子节点更新了,我也要更新
}
else if(inStack[v]) // v访问过且在栈中
{
low[u] = min(low[u], dfn[v]);
}
}
if(low[u] == dfn[u])
{
co[u] = ++col;
while(stk.top() != u) co[stk.top()] = col, inStack[stk.top()] = false, stk.pop();
// 标号(“染色”),弹栈
inStack[u] = false;
stk.pop(); // 最后把u弹出去
}
}
实际上,这些被弹出的所有的点构成一个强连通分量,而col
就是这个强连通分量的编号。
关于这个方法,证明如下:
我们采用数学归纳法。先证明第一次出现low
和dfn
相等的情况(记这个点为点
u
u
u)时,弹出的所有点构成一个强连通分量。(这时,每一条横叉边都是“符合条件”的)首先,当遍历完从
u
u
u出发的所有边之后回溯到点
u
u
u时,才判断low
和dfn
是否相等,也就是说此时栈中
u
u
u上方的节点都是
u
u
u的子孙节点3。而它们都没有被弹出(因为
u
u
u是算法遇到的第一个low
和dfn
相等的点),也就是说它们都在栈中。换言之,
u
u
u的所有子孙节点都在
u
u
u的上方,并且
u
u
u的上方没有别的节点。并且,
u
u
u所有的子节点
v
v
v都满足low[v]<dfn[v]
4。那么,实际上点
u
u
u的每个子孙节点都可以通过若干条树边和至多一条返祖边(实际上就是一条)到达某个dfn
值严格小于这个子孙节点的节点,而它可以到达的这个节点又可以通过同样的方式继续往上走(因为它的low
也小于dfn
),直到走到点
u
u
u为止。每一个子孙结点都可以像这样到达点
u
u
u,而点
u
u
u又可以通过树边到达任何子孙节点。这样,点
u
u
u的每个子孙节点都和点
u
u
u强连通,也就是说
u
u
u子树上的所有的点都在一个强连通分量上,也即栈中
u
u
u及其上方所有的点都属于同一个强连通分量。我们再来证明它是“极大”的。因为low[u]==dfn[u]
,所以如果点
u
u
u的出边有返祖边,那一定是自环。点
u
u
u的出边不可能有横叉边。并且,它的子孙节点的返祖边和符合要求的横叉边也最多到达点
u
u
u,不可能到达dfn
更小的节点。那么,点
u
u
u一定不与dfn
小于它的点强连通,也即点
u
u
u不与以它为根的子树外的任何一个点强连通。那么,以
u
u
u为跟的子树上所有的点构成一个强连通分量。
接下来归纳证明,假设此前弹出的所有的点都构成各自的强连通分量,那么如果出现了low
和dfn
相等的情况(还是记这个点为点
u
u
u),在栈中它上方的点仍然都是它的子孙节点,并且还是和点
u
u
u强连通(证明过程于“第一次”类似)。但是可能缺失了一部分已经被弹出的、构成强连通分量的点。但是,此时
u
u
u和
u
u
u上方所有的点也一定构成极大强连通子图,因为
u
u
u子树上已经弹出的点必然不与子树上剩余的点强连通,而除子树外的所有点又与子树上的所有点不强连通。因此,在这种情况下,栈中
u
u
u及
u
u
u上方的所有的点构成强连通分量。到此,我们证明了这个方法是正确的。
**注意:**Tarjan本质上是dfs,对于不连通的图要用这样的循环:
for(int i = 1; i <= n; ++i)
if(!dfn[i])
{
Tarjan(i);
}
以确保所有节点都被访问过。
最后,第
23
23
23行的代码else if(inStack[v])
可以写成else if (!co[v])
,inStack
数组可以不要。因为如果一个节点被访问过却没被“染色”,那它一定在栈中。
2023_10_3更正
由于如果横叉边存在,那么遍历到这条横叉边时其指向的节点一定已经被染色了。否则用假设法,一直跳low
,最后一定跳到一个low
等于dfn
的点,这个点一定已经被染色。所以实际上low
指的是通过返祖边回溯的节点。