[笔记]Tarjan算法
定义
DFS树相关
我们对一个有向连通图进行DFS遍历,会得到一棵DFS树。
DFS树的形态是根据我们DFS的顺序来决定的,因此对一个有向连通图来说,它的DFS树可能有多个。我们把这棵树的边称作树边。
其他边我们分为\(3\)类:
- 前向边:从\(u\)到它dfs树上的祖先的边。
- 后向边:从\(u\)到它dfs树上的后代的边。
- 交叉边:除了前向边、后向边,剩下就是交叉边了。
容易发现,两颗子树之间的交叉边是单向的——如果子树\(A\)比子树\(B\)先遍历,那么一定不存在\(A\)到\(B\)的交叉边。
强连通分量
称一个有向图是强连通的,当且仅当任意两个节点可以互相到达。
强连通分量就是一个有向图的强连通子图。
割点&割边
对于一个无向图,如果删除该点会增加该图的连通分量,则称该点为该无向图的割点/割顶。
相应地,如果删除一条边会增加图的连通分量,则称该边为该无向图的割边/桥。
显然,割点和割边都不一定是唯一的。
Tarjan算法求强连通分量
Tarjan算法用一次DFS完成,用\(1\)个栈来存储当前遍历过的节点中,还没找到强连通分量的节点。
我们为节点\(i\)定义\(2\)个属性:
- \(dfn[i]\):节点\(i\)的时间戳,即\(i\)是第几个被搜索到的节点。
- \(low[i]\):节点\(i\)通过至多\(1\)条非树边,能到达的在栈中的节点的最小时间戳。
接下来任意选定\(1\)个节点开始DFS,遍历到的每个节点入栈,并初始化\(dfn[i]=low[i]\),都为该节点被遍历的次序\(tim\)(也称作时间戳)。对于节点\(u\),枚举它能直接到达的节点\(i\):
- 如果\(i\)未被访问:那么对\(i\)进行搜索;由于\(u\)能直接到达\(i\),所以根据定义,\(low[u]=\min(low[u],low[i])\)。
- 如果\(i\)被访问过:说明\(u\)通过一条非树边到达\(i\)。
- 如果\(i\)在栈中:根据定义,\(low[u]=\min(low[u],dfn[i])\)。
- 如果\(i\)不在栈中:说明\(i\)所在的强连通分量已经被找到,所以什么也不用做。
如果完成节点\(u\)的搜索,发现\(dfn[u]=low[u]\),则说明\(u\)是一个强连通分量中时间戳最小的,因此不断出栈直到\(u\)(包括\(u\))为止,此次出栈的所有节点属于一个强连通分量。
代码:
int low[N],dfn[N],tim;//tim是不断增加的时间戳
bool vis[N];//vis[i]表示i是否在栈中
stack<int> st;
void tarjan(int x){
low[x]=dfn[x]=++tim;
st.push(x);
vis[x]=1;
for(auto i:G[x]){
if(!dfn[i]){//没访问过
tarjan(i);
low[x]=min(low[x],low[i]);
}else if(vis[i]){//访问过,且在栈中
low[x]=min(low[x],dfn[i]);
}
}
if(dfn[x]==low[x]){
while(1){
int t=st.top();
st.pop();
vis[t]=0;
if(t==x) break;
}
}
}
然而只选定\(1\)个节点不一定能处理到所有节点,因此主函数中我们要这么写:
for(int i=1;i<=n;i++) if(!dfn[i]) tarjan(i);
一些问题&思考
\(\textbf{1.}\)主函数中循环的写法为什么是正确的?
首先可以发现,主函数中每完成一次搜索,栈都是空的,因为搜索过程中,如果\(i\)已经处理过了就什么都不做(情况2.2),所以强连通分量之间互不干扰,已经搜出的强连通分量,可以直接看作从图上被删掉。自然根节点的\(low\)不会得到更新,搜索结束栈会被清空,所以不会出现“处理了一半的强连通分量”。
\(\textbf{2.}\)为什么\(low\)的定义必须限制“最多经过\(1\)条非树边”,为什么是正确的?不加限制会影响正确性吗?
实际上如果不加限制的话,是没法递推的。如下图:
我们从节点\(1\)开始搜索,现在正在搜索节点\(3\)。\(3\)有一条连接\(2\)的边,如果\(low\)的定义不加限制,\(low[3]\)应该和\(low[2]\)取最小值。可是\(low[2]\)还没有求出来,它的值可能是\(1\)也可能是\(2\),取决于搜索的顺序。这样子就有后效性了,是无法递推的。
接下来证明Tarjan算法的正确性,即“按顺序搜索,如果遇到\(low[i]=dfn[i]\)则不断出栈”是可行的。以下内容部分整理自北京大学暑期课《ACM/ICPC竞赛训练》和此AcWing博客。
假设现在完成了对子树\(u\)的搜索,现在有\(low[u]=dfn[u]\),我们可以把除\(u\)以外的所有点分成\(5\)类:
- 没访问过的。
- 访问过,但现在不在栈中。
- 访问过,现在在栈中,\(u\)的上方。
- 访问过,现在在栈中,\(u\)的下方。
我们要证明的是“\(u\)和第\(3\)类节点构成一个强连通分量”。
即:证明\(u\)和第\(3\)类节点互相可达,而且与其他类型的节点不互相可达。
-
\(\bf{Type 1}\):没访问过的:
我们需要证明此类节点和\(u\)不能互相到达。- 既然\(u\)的子树已经搜索完了,那么还没有访问到的节点,就是从\(u\)走不到的节点了。
-
\(\bf{Type 2}\):访问过,但现在不在栈中:
如果我们用“此类节点属于其他强连通分量”来解释,就算是循环论证了。我们仍然需要证明此类节点和\(u\)不能互相到达。- 设该节点为\(v\),则分类讨论一下:
- 如果\(dfn[v]<dfn[u]\),那么从\(v\)出发一定到不了\(u\)。我们假设\(v\)能到达\(u\),又因为\(v\)比\(u\)先遍历到,所以\(v\)一定是\(u\)的祖先节点(思考一下,如果子树\(A\)比子树\(B\)先遍历到,那么一定不存在\(A\)到\(B\)的交叉边),自然应该在栈中(\(u\)的下方),产生矛盾。
- 如果\(dfn[v]>dfn[u]\),那么从\(v\)出发一定到不了\(u\)。因为我们刚遍历完\(u\)的子树,所以时间戳比\(u\)大的都是\(u\)的后代,自然\(v\)也是。我们设\(v\)能到达的最早节点为\(w\)(留心定义,和\(low\)不太一样):
- 如果\(dfn[w]<dfn[u]\),那么显然有\(low[u]<dfn[u]\),与\(low[u]=dfn[u]\)矛盾。
如果有点蒙,你可以想象\(v\)沿着\(low[v]\)一直往上跳,直到\(low[v]<dfn[u]\)为止,此时的\(v\)仍然是\(u\)的后代,而\(u\)又是\(low[v]\)所代表节点的后代。自然\(low[u]\)会更新为\(low[v]\)。 - 如果\(dfn[w]>dfn[u]\),那么\(v\)最远只能到\(u\)的后代\(w\),所以到达不了\(u\)。
- 如果\(dfn[w]<dfn[u]\),那么显然有\(low[u]<dfn[u]\),与\(low[u]=dfn[u]\)矛盾。
- 设该节点为\(v\),则分类讨论一下:
-
\(\bf{Type 3}\):访问过,现在在栈中,\(\bf{u}\)的上方:
证明此类节点和\(u\)能互相到达。-
这类节点因为是\(u\)的后代,所以\(u\)可以到达它们;反过来,这些节点可以到达\(u\)吗?是可以的。如果此类节点\(v\)能到达的最早节点是\(w\):
- \(dfn[w]<dfn[u]\),和\(\bf{2.2.1}\)情况类似,会出现矛盾。
- \(dfn[w]>dfn[u]\),和\(\bf{2.2.2}\)类似,那一定在\(u\)搜索完之前被出栈出去了,与“在栈中”矛盾。
上面两种情况都不存在,因此所有\(v\)都能到达\(u\)。
-
-
\(\bf{Type 4}\):访问过,现在在栈中,\(\bf{u}\)的下方:
证明此类节点不能和\(u\)互相到达。这些点时间戳一定比\(u\)小,如果\(u\)能到达它们之中任何一个,就有\(low[u]<dfn[u]\),与\(low[u]=dfn[u]\)矛盾。
综上\(u\)只和第\(3\)类节点互相连通,故可行性得证。
\(\textbf{3.}\)似乎“访问过,在栈中”这种情况,用\(low[i]\)更新\(low[x]\)也是可行的?
对于求强连通分量和割边,这样写是可行的。但是对于割点,是不可行的,这个后面会说到。
无论如何,这种写法是不符合\(low\)的定义的,所以不建议这么写。
Tarjan算法求割点
- 对于非根节点\(u\),如果它存在一个子节点\(i\),使得\(low[i]\ge dfn[u]\),则\(u\)是割点。
- 对于根节点\(u\),如果它的子节点个数\(\ge 2\),则\(u\)是割点。
void tarjan(int u){
dfn[u]=low[u]=++tim;
int ch=0;
for(int i:G[u]){
if(!dfn[i]){
ch++;
tarjan(i);
low[u]=min(low[u],low[i]);
if(u!=root&&low[i]>=dfn[u]) is[u]=1;
}else low[u]=min(low[u],dfn[i]);
}
if(u==root&&ch>=2) is[u]=1;
}
Tarjan算法求割边
和求割点非常相似,但不用考虑根节点的问题,对于节点\(u\),如果子节点\(i\)满足\(low[i]>dfn[u]\)(注意不是\(\ge\)),那么\(u\)到\(i\)这条边是割边。
还需要注意的是,指向父节点的边不能考虑,因为要判断的就是这条边是否为割边。
void tarjan(int u,int fa){
dfn[u]=low[u]=++tim;
for(int i:G[u]){
if(i==fa) continue;
if(!dfn[i]){
tarjan(i,u);
low[u]=min(low[u],low[i]);
if(low[i]>dfn[u]) cout<<u<<" <-> "<<i<<" 是割边\n";
}else low[u]=min(low[u],dfn[i]);
}
}
用\(low\)和\(dfn\)更新的说明
当\(u\)的子节点\(i\)已经遍历过,就用\(dfn[i]\)去更新\(low[u]\)。如果这一步换成\(low[i]\)去更新,在强连通分量和割边是不会出问题的,而割点会出问题。
对于强连通分量来说,我们关注的是一个节点的\(dfn\)和\(low\)是否相等。当一个节点可以到达一个\(dfn\)更小的节点时,就注定了它不能成为那个出栈的节点。所以用\(low\)去更新只会让\(low[u]\)更小,而不会使\(low[u]=dfn[u]\)。
对于割边,由于是无向图,所以我们可以得知,如果\(u\)的子树\(A\)比子树\(B\)先遍历,那么\(A\)和\(B\)之间一定不存在边。换句话说无向图的DFS树没有交叉边。所以\(u\)的子节点\(i\)能到达的比\(u\)时间戳更小的节点只能是自己的祖先节点,而我们已经规定不能经过到父节点的边,所以这个节点一定也是\(u\)的祖先节点。如果这个节点存在,那么\(low[i]<dfn[u]\),无论是用\(low\)还是\(dfn\)更新\(low[i]\)只可能让它更小,不影响结果;反之,如果该节点不存在,那么两种写法都是\(low[i]\ge dfn[u]\),此时\(u\leftrightarrow i\)不是割边。
对于割点,修改成用\(low\)更新后,\(low[i]<u\)可能会表示节点\(i\)经过\(u\)才到达其他节点,所以不能确定\(i\)在不经过点\(u\)的情况下,还能不能到达时间戳更小的节点,因此会存在割点没有被找出的情况(可以把上面的那张图看作无向图理解一下,错误的做法可能求不出割点)。