Tarjan算法

Junity 发布于 13 天前 296 次阅读 最后更新于 13 天前 5618 字 预计阅读时间: 26 分钟


AI 摘要

"Tarjan算法详解:本文系统介绍了无向图中割点、割边及双联通分量的求法,以及有向图中强连通分量的判定,重点讲解算法原理、实现流程和缩点技巧。"

关于Tarjan算法的理解

本文前置知识:图论基础

一.无向图中的Tarjan

在无向图中,Tarjan可以用来求割点与割边。

1.割点与割边

割点是指这样一类点:无向连通图 中,如果将其中一个点以及所有连接该点的边去掉,图就不再连通,这样的点称为割点。简单来说,就是删去后会让原来联通的图“分裂”成几个子图的点。孤立点不属于割点

割边与之类似,即删去后,会把原图“分裂”成两个子图的点。割边又称“桥”。

e.g.

在这个图中,点4、2是割点,边3、4是割边。例如,点4删去后,原图会变成由点{2,3}和{1,5}构成的两个子图,而边4删去后,原图会变成{2,3}和{1,4,5}构成的两个子图。

相反的,点3和点5就不是割点。因为若删去,原图仍然是联通的。

2.搜索树、DFS序和时间戳

对于一个图来说,我们可以对其进行深度优先遍历,并按照遍历的顺序给每个点x标一个递增的序号,这个序号就叫作这个点的时间戳,记为 dfn[x],而按照遍历的顺序将点排列起来,就得到了 DFS序

而在深度优先遍历时形成的树形结构就称为这个图的一个搜索树。一个图可能有不止一个搜索树

更为具体的,对图进行深度优先遍历,在遍历一个点的所有相邻点时,只选择那些没有被遍历过的点进行下一步遍历,最后遍历过的所有边就构成了这个图的一颗搜索树。搜索树上的边是有向的。

下面的代码求出一个无向图每个点的时间戳和DFS序:

int dfn[N],cnt = 0;		//时间戳
int dfs_idx[N],tot = 0;  //DFS序


void dfs(int x){
    dfn[x] = ++cnt;
    dfs_idx[++tot] = x;
    
    for (int i = h[x]; i != -1; i = ne[i]) {
        int y = e[i];
        if (!dfn[y])
            dfs(y);
    }
}

对于一颗搜索树而言,它的一颗子树对应到DFS序上一定是连续的一段。

3.树边,非树边

对于一个图的一颗搜索树来说,它在搜索树上的边被称为树边,其余边称为非树边。

对于无向图,非树边只存在一种情况,即连接一个祖先节点和一个后代节点,不会出现连接两个不同子树的情况。因为如果连接了两个子树,那在遍历前一颗子树时,由于这条边的存在,会将另一颗子树也一起遍历掉,变成这颗子树的一个新子树(孙树?)。这条性质只在无向图中成立

e.g.(树边用黑色标注,非树边用红色)

原无向图:

原无向图

连接两颗子树的非树边(3,4)存在的假象情况:

假想的情况

实际的遍历情况:

image-20220807114847496

4.用Tarjan来求割点和割边

Tarjan算法的思想是,对于每个点x,找到它沿搜索树向下,或沿一条非树边能到的时间戳最小的点。如果这个点的时间戳大于等于x的时间戳,则说明x子树除中的点除非经过x,否则无法到达到其他的点。显然,此时x就一定是一个割点了。特别的,对于根节点来说,不能这样判定,因为若根节点只有一颗子树,显然不是割点。但如果根节点有两颗以上的子树,它就一定是割点。

对于割边而言,方法也是类似:对于条搜索树上的边(x,y)而言,若y向下走,或经过一条非树边,能走到的时间戳最小的点的时间戳比x大,就说明y及其子树除非经过原图中 (x,y) 的边,否则无法到达其他点。显然,(x,y)就是一条割边了。

具体来说,给每个点x求出一个"追溯值",表示上文中说的"沿搜索树向下,或沿一条非树边能到的时间戳最小的点的时间戳",记为low[x]。若对于一个点xdfn[x]<=low[x],则x是一个割点;若对于一条树边(x,y)dfn[x]<low[y],则原图中对应的边(x,y)是一条割边。注意割点和割边判定时,小于号的取等情况是不同的。

综上,我们可以梳理出一个求割点和割边的流程:

  1. 通过DFS求出原图的一颗生成树,并标出时间戳 dfn[x]
  2. 对于生成树上的每个节点,求出其追溯值 low[x]
  3. 扫描所有点/边,根据上面的判定法则判定其是否是割点/割边。

在实现时,并不需要真的做3次DFS,因为在1次DFS中我们就可以完成所有流程。在求出所有子节点的追溯值后,该节点的追溯就可以通过求子节点追溯值的最小值求出。

模板:(以求割边为例)

int dfn[N],low[N],cnt; 					//cnt记录时间戳
vector<int> cut_edges;

void tarjan(int x,int in_edge){
    dfn[x]=low[x]=++cnt;
    for(int i=h[x];i!=-1;i=ne[i]){		//链式前向星存图
        int y=e[i];						//y就是一个子节点
        if(!dfn[y]){
            tarjan(y);					//dfn[y]为0表示y没有被遍历过
            low[x]=min(low[x],low[y]);
            
            if(dfn[x]<low[y])			//割边判定
                	cut_edges.push_back(i);
        }
        else if(i!=(in_edge^1))			//判断是否是从父节点下来的那条边,如果不是就代表是非树边。图中可能存在重边,
        	low[x]=min(low[x],dfn[y]);	//不能直接判断y是否是父亲,故这里通过加边时无向图会加两次,编号只有二进制
        								//中最后一位不同来判断。
    }
}

5.双联通分量 (DCC)

在无向图中,若一个图中不存在割点,则称为点双联通图;若不存在割边,则称为边双联通图。特别的,仅有一个点的图既属于点双联通图,又属于边双联通图;

一个图中的极大点双联通子图称为这个图的一个点双联通分量(v-DCC);类似的,一个图的极大边双联通子图称为这个图的一个边双联通分量(e-DCC)。e-DCCv-DCC统称DCC。

需要注意的是,这里的“极大”指的并不是“最大”,而是说“不能更大”,即添加任何原图中的点/边后,都不能使得添加后的子图仍然是一个点/边双联通图。一个图可以有多个双联通分量。

在一个图中,任何一个点都属于且仅属于一个e-DCC,原因很简单:

  1. 一个点自身组成一个e-DCC,说明每个点属于至少一个e-DCC
  2. 若一个点同时属于多个e-DCC,那么由于没有割边,故这些e-DCC可以合并为一个更大的边双联通图,与e-DCC定义中的"极大"相矛盾。

同样的,任何一个点都至少属于一个v-DCC,这与e-DCC的情况不同,如下图中:

image-20220807083953594

1,2,3构成一个v-DCC,而点3,4,5也构成一个v-DCC,点3同时属于这两个v-DCC

6.e-DCC的求法和缩点

e-DCC看成一个个的点,把割边看成这些点间连接的边,这样会形成一张新图。这种方法叫做缩点。

缩点后的图一定是一颗树或森林(如果原图不联通)。可以用反证法:若不是,则图中一定存在环;但存在环的话,这个环上的边就一定不是割边了,而我们的新图中的边全是由割边组成,故显然不成立。

e-DCC,最简单的方法就是把割边全部删掉,然后找出所有的联通块了。在实现中,可以通过判断边是否是割边来实现,不需要真的删边。

缩点时,可以针对每一条原边,若边两端的点属于不同的DCC,则在这两个DCC中连一条边。

下面的代码在Tarjan求出割边后求出v-DCC并缩点。

int id[N];								//保存每个点属于的 v-DCC 编号
int dcc_cnt;							//保存 v-DCC 个数

void dfs(int x,int id){
    id[x]=id;
    for(int i=h[x];i!=-1;i=ne[i]){
        int y=e[i];
        if(!id[y] || is_bridge[i])
            dfs(y,id);
    }
}

//主函数中

//找出 v-DCC
for(int i=1;i<=n;i++)					//遍历所有点
    if(!id[i])							//判断是否遍历过
    	dfs(i,++dcc_cnt);

//缩点
for(int x=1;x<=n;x++)					//遍历所有点
	for(int i=h[x];i!=-1;i=ne[i]){		//遍历该点所有边
        int y=e[i];
        if(id[x]!=id[y])
            add_c(id[x],id[y]);			//add_c 意为在新图中连边
    }

7.v-DCC的求法和缩点

求法

v-DCC的求法比e-DCC要复杂,原因就是一个点会属于不同的v-DCC,我们不能直接把所有的割点删除后计算联通块。

可以发现,只有割点会属于多个v-DCC,证明如下:

设该点为 x。若一个点同时属于两个v-DCC,且 x 不是割点,则删去 x 后,这些v-DCC仍然联通,故可组成一个更大的v-DCC,与v-DCC的“极大”矛盾,故不成立。

那么我们可以在找到割点时,同时找出其属于的所有v-DCC

具体来说,我们可以维护一个栈,当 Tarjan 进入到一个点 x 时将其入栈,当判定法则成立时,设此时的子节点是 y ,栈顶节点到 y 这一段及 x 就构成一个v-DCC,此时将它们出栈并记录即可(x 不出栈)。代码如下:

int stack[N], top, dfn[N], low[N], cnt, id[N];
int cnt[N];

void tarjan(int x) {
    dfn[x] = low[x] = ++num;
    stack[++top] = x;
    if (x == root && head[x] == 0) {  // 孤立点
        dcc[++cnt].push_back(x);
        return;
    }

    int flag = 0;					// flag记录子树个数

    for (int i = head[x]; i; i = Next[i]) {
        int y = ver[i];
        if (!dfn[y]) {
            tarjan(y);
            low[x] = min(low[x], low[y]);
            if (low[y] >= dfn[x]) {
                flag++;
                if (x != root || flag > 1)
                    cut[x] = true;
                cnt++;
                int z;
                do {
                    z = stack[top--];
                    dcc[cnt].push_back(z);
                } while (z != y);
                dcc[cnt].push_back(x);
            }
        }
        else
            low[x] = min(low[x], dfn[y]);
    }
}

PS:其实这段代码来自蓝书 Github链接

下面来证明一下这个做法的正确性:

首先,由于节点按照DFS的顺序入栈,故栈中在上方的点的时间戳要比下方的时间戳大。因此,对于一个割点 x ,按照上面的流程出栈时,出栈的节点只会是它的后代,而把 栈顶到 y 这一段出栈保证了操作后, y 这颗子树中的节点被全部出栈。

然后来证明,当一个割点 x 时,栈中在 x 上方的点和 x 都是属于同一个v-DCC,且组成整个完整的v-DCC的。先证明他们属于同一个v-DCC:

  • 若不属于同一个v-DCC,则其中肯定存在割点,和割点连接的另一个v-DCC中的点。
  • 然而,由于我们在找到割点时就将它的子树里属于其他v-DCC的节点全部出栈了,因为显然割点属于的 v-DCC 要么在子树中,要么是祖先节点那边的,故不可能有其他 v-DCC 中的点了。

接着证明里面包含了这个v-DCC全部的点:

  • 根据定义,如果找不到其他点可以加到这个v-DCC中,并保持它双联通的性质不变,那么这些点就构成了完整的v-DCC
  • 而在栈中的点,分成三种情况:
    1. 叶子节点,这些点不能再延伸出其他点了。
    2. 割点,它的子节点要么因为判定法则成立、被出栈,这些点添加进来会破坏v-DCC的性质;要么就是在栈中的点。
    3. 其他点,这些点连的每条边都延伸出去了,没有点可以再延伸了。
  • 因此,栈中的点一定无法再延伸出其他点了。

综上,当判定法则成立时,栈中在 x 上方的点与 x 组成一个完整的v-DCC

缩点

v-DCC的缩点也要复杂一点。假设求出了nv-DCC,有m个割点,由于割点可能属于多个v-DCC,因此缩点后,新图中的节点数为n+m

e.g.

这是缩点前的图,其中3号点是一个割点:

缩点

缩点后:

缩点

其中,3号点代表原图中1,2,3三个点组成的v-DCC,1号点代表原图中3,4,5组成的v-DCC,而2号点代表原图中的割点3号点。

可以发现,缩点后的每个v-DCC实际上都是通过割点相连接的,每两个v-DCC缩点后至少隔了一个割点。

于是可以得到以下缩点步骤:

  • 对于每个v-DCC,建立一个点,并对每个割点也建立一个点。

  • 遍历每个v-DCC,对于其中的每个割点,连一条该v-DCC到割点的边。

代码如下:

int id[N],cnt;
int cut[N];
vector<int> dcc;
int n;					//n为点数

void add_c(int a,int b);  // 自己定义的在新图里面加边的函数

...						//使用tarjan求出了v-DCC,并标出了每个点对应的v-dcc编号存在id[x]里面。
    					//cut里面存每个点是不是割点。
    					//dcc里面存每个dcc里面的点。
/* 以上变量应已经求出 */
int new_id[N],num;		 //new_id 保存每个割点对应新点的编号
num = cnt;			   	 //方便起见,编号从cnt开始,就不用再给每个v-dcc也赋新编号了

for(int i=1;i<=n;i++)	  //给每个割点取新编号
    if(cut[i])
        new_id[i] = num++;

for(int i=1;i<=cnt;i++){   //遍历每个v-DCC
    for(int j:dcc[i]){	   //遍历这个v-DCC里面的所有点,使用C++11的语法糖。
        if(cut[j]){
            add_c(new_id[j],i);
            add_c(i,new_id[j]);
        }
    }
}

二.有向图中的 Tarjan

1.强连通分量 (SCC)

有向图中,若一个图中任意两个点 xy ,都存在路径使得 x 可以到达 yy也可以到达x,则称为强联通图,一个孤立的点也称为强联通图。一个有向图的极大强联通子图称为一个强连通分量。

简单来说,强联通图就是任意两个点可以互相到达的图,强连通分量就是有向图的一个极大强联通子图。

e.g.

强连通分量示例

这张图中,点1,2,3构成一个强联通分量,而3,4,5不构成强连通分量,因为点5无法到达点4。

一个点只能属于一个SCC,因为如果同时属于两个SCC,设其为 x ,那么在这两个SCC中分别任取一个点,都可以先到 x,在到另一个点,故这两个SCC可以合并为一个。

2.搜索树、DFS序和时间戳 in 有向图

与无向图类似,有向图也有搜索树、时间戳的概念,这里不在赘述。同样的,记点x的时间戳为dfn[x]

3.前向边,后向边,树枝边,横叉边

对于一个有向图,我们将其上的有向边 (x,y) 分为下面几类:

  1. 树枝边:在搜索树上的边,即 xy 的父节点。
  2. 前向边:yx 的后代节点。
  3. 后向边:yx 的祖先节点。
  4. 横叉边:xy 不存在祖先后代关系(比如你和你堂弟?)。此时一定有dfn[x]>dfn[y],理由和无向图非树边那的证明差不多。

4.SCC的判定

考虑什么情况下几个点会组成SCC。显然,成环时,环上的点一定是强联通的:只要走不超过一圈,必然可以到达其他所有的点。

而如果一个SCC由多个点组成,那么其中的任意一点x一定在至少一个环上:任取剩下的一点y,则 xyyx 的路径就构成一个环;若这两条路径存在交点,那么取y为交点就可以了。

因此,我们只需要找环就可以了。

再考虑什么时候会存在环:显然,在所有边中,后向边一定代表了一个环。而横叉边有可能能成环,这取决于另外一个点。

综上,我们建立一个栈,在DFS遍历到 x 时,维护可能与 x​ 成环的所有点。

容易发现,因为有后向边的存在,x 的祖先节点都可能与 x 成环;而由于横叉边的存在,如果一个点可以走到 x 的祖先节点,那么这个点也可能与 x 成环。因此,栈中要保存的就是以下两种:

  • x 的祖先节点
  • 通过后向边可以到 x 的祖先的点。

同时引入追溯值的概念:节点 x 的追溯值就是它能走到的时间戳最小的祖先节点。

我们这样维护栈:

  1. 当一个节点被DFS遍历到时,把它入栈
  2. 当一个节点满足 low[x]=dfn[x] 时,它和栈中它上方的点组成一个强连通分量,此时把它与它上面的点出栈并记录。

同时这也是强联通分量的判定法则:当节点满足 low[x]=dfn[x],它与栈中它上方的所有节点组成一个强连通分量。

先看下代码吧:

int dfn[N],low[N],num;							//注意这里的low与上文说的low有一定出入,下文会说
int stack[N],top,ins;							//ins代表节点是否在栈中
int c[N];									   //c[x]表示x对应的SCC的编号

vector<int> scc;								//保存每个scc中的点
int cnt;									    //保存scc编号分到第几个了

void tarjan(int x) {
	dfn[x] = low[x] = ++num;
	stack[++top] = x, ins[x] = 1;
	for (int i = head[x]; i; i = Next[i])
		if (!dfn[ver[i]]) {
			tarjan(ver[i]);
			low[x] = min(low[x], low[ver[i]]);
		}
		else if (ins[ver[i]])
			low[x] = min(low[x], dfn[ver[i]]);
	if (dfn[x] == low[x]) {
		cnt++; int y;
		do {
			y = stack[top--], ins[y] = 0;
			c[y] = cnt, scc[cnt].push_back(y);
		} while (x != y);
	}
}

PS:同样来自蓝书,改了一点点。

现在来说明为什么是正确的。

先不考虑维护 low 的过程。

证明:low[x]=dfn[x],表示 x 的子节点中所有没有出栈的节点,无论如何都走不到时间戳更小的点,并且都能走到 x

如果栈中有点不能走到 x ,那设这个点为 y ,它最多能走到 z 点,有 low[y]=dfn[z]。分两种情况:

  1. z 的后代中没有可以走到比 z 更上面的点了,此时 low[z]=dfn[z],这些点被弹出了。
  2. z 的后代中有可以走到更上面的点,那么 y 可以先到 z ,再到更上面的点,故 low[y]dfn[z],与原设矛盾。

因此,这些点都可以走到 x ,故这些点与 x 属于一个 SCC

再证明这个SCC里面的点都在栈中。

如果有其他点属于这个SCC,设这个点为 y,那么因为属于一个SCCy 一定可以到 x ,即 low[y]=dfn[x],故 yx 之前不会被弹出,一定在栈中。

综上,如果 low 可以得到很好的维护,那么当判定法则成立时,栈中 x 上方的点与 x 构成一个SCC

再考虑代码中的维护过程:

实际上,Tarjan算法忽略了一个无关的问题,即在:

z 的后代中有可以走到更上面的点,那么 y 可以先到 z ,再到更上面的点,故 low[y]dfn[z],与原设矛盾。

这种情况下没有更新 low[y] 。也就是说,这种情况下,就算 y 可以到 x ,也有 low[y]!=dfn[x]

为什么说无关呢?

回到上面的推导,我们要保证的是栈中的元素都可以到 x 。这种情况下 y 可以到达 x,因此我们只需要让它不被弹出就可以了。

对于 zy 这段路径,显然 y 是不会被出栈的;而 xz 这段路径,因为 z 在取子节点最小追溯值的时候,low[y] 不是最小的,因此相当于 low[y] 的值被忽略,故不会对这段产生影响。换言之,low[y] 有没有得到很好的维护,在 xz 这一段上的效果都是一样的。

因此,只要 y 可以到 x,那么在 x 之前 y 就不会被弹出

如果有其他点属于这个SCC,设这个点为 y,那么因为属于一个SCCy 一定可以到 x ,即 low[y]=dfn[x],故 yx 之前不会被弹出,一定在栈中。

low[y] 是否很好维护的影响,在这里也是一样的:引用 low[y]=dfn[x] 的目的就是证明 yx 前不会被弹出。

综上,代码中的维护方式不会对结果产生影响。

5.SCC的缩点

SCC 的缩点与DCC类似,但不同的是 SCC 缩点后会形成 有向无环图。

缩点的方式与e-DCC的缩点类似,只需要将原图中连接两个不同 SCC 的边加进去就可以了。

代码:

int id[N];					//原图中每个点对应SCC的编号
void add_c(int a,int b);	 //自己定义的在新图中加边的函数

for(int x=1;x<=n;x++){		 //遍历所有点
    for(int i=h[x];i!=-1;i=ne[i]){
        int y=e[i];
        if(id[x]!=id[y])
            add_c(id[x],id[y]);
    }
}

** 全文完 **

此作者没有提供个人介绍。
最后更新于 2025-12-25