【算法笔记】Tarjan 算法 · 上

  • 本文总计约 8300 字,阅读大约需要 30 分钟

前言

Tarjan 算法也是一个非常经典的算法了,因为它所涉及的名词实在是太多了,而且算法本身也很抽象,所以我学习的时候也是慢吞吞的。所以各个地方瞎看瞎看着,也算是勉勉强强地学会了。而且看网上大部分的博客,讲得都不甚详细,所以自己也想尽量写一篇更加详细的博客。虽然还是会有诸多不足,但我还是会尽力地把它讲明白的 QwQ。

因为 Tarjan 算法涵盖的内容太多了,包括割点,桥,强连通分量等多个问题,所以我会用两篇文章介绍它。上篇将讲较简单的割点和桥,下篇将介绍相对复杂的强连通分量。

题目引入

G 国的交通系统非常发达,这个国家有 V 个城市,编号为 1,2,,V。并且有 E 条双向通行的公路,每条道路都将两个城市连接起来,且所有的 E 条道路将这些城市连接在一起,即任意两个城市都可以通过公路相互抵达

现在,G 国的敌国与 G 国开战了。他们知道如果炸毁某一个城市,那么与这个城市相连的公路的交通,也会随即切断。所以他们要派遣飞机炸毁其中的一个城市,以达到切断 G 国交通系统的目的。即通过炸毁一个城市之后,有两个城市不能通过公路相互抵达。你是敌国的参谋,请问应该如何选择城市,才能达到目的呢?

形式化地讲述题面:给定一张无向图 GV,E,求图的割点,其中 |V|2×104|E|105

例如:以下这张图的割点即为 3 号点。因为删去 3 号点之后图不再连通。
image

基本定义

  • 割点:在一张无向图中,删去某个点以及与之相邻的所有边后,图不再连通,则称这个点为割点。如上图中,3 号点即为割点;
  • 割边:在一张无向图中,删去某一条边后,图不再连通,则称这条边为割边,又称。如下图,标红的边为割边:
    image
  • 强连通分量:在一张有向图中,如果我们可以找到若干个结点形成点集 X,使这些节点可以相互到达,则称点集 X 为图的一个连通分量;同时,若对于某个连通分量 X 不存在任何一个节点 uX,使得 Xu 依旧形成连通分量,则称 X 为图的一个强连通分量。如下图,X={1,2,3,4},则称 X 为图的一个强连通分量:
    image

暴力求割点的思路及缺陷

依照惯例,我们最先当然是要想:暴力怎么做?

当然可以这么做:枚举删去每一个结点,然后用 DFS 跑一遍整张图,如果这张图的其余所有节点不能跑完,那么这个点就是割点;否则就不是割点。

代码如下:

#include <iostream>
#include <cstring>  //使用 memset 函数
using namespace std;
const int maxN=2000001;

int head[maxN], top, n, m, cnt;
bool vis[maxN], isCut[maxN]; //isCut[i] 代表 i 是不是割点

//链式前向星模板
struct Edge {
	int to;
	int next;
} edge[maxN];

inline void add_edge(int u, int v) {
	edge[++top].to = v;
	edge[top].next = head[u];
	head[u] = top;
}

bool dfs(int cur, int fa) {  //枚举每一个结点进行 DFS,fa 代表是从哪个节点开始搜索的
	int nxt = 0;  //计数器,统计从 fa 结点直接搜索了多少个“子”节点,若 nxt>1,则意味着该节点是割点
	vis[cur] = true;

	for(int ptr = head[cur]; ptr; ptr = edge[ptr].next) {
		int curv = edge[ptr].to;
		if(!vis[curv]) {
			++nxt;
			dfs(curv, fa);
		}
	}

	if(cur == fa && nxt > 1) {
		return true;
	}
	else {
		return false;
	}
}

int main(void) {
	scanf("%d%d", &n, &m);

	for(int i = 1; i <= m; ++i) {
		int ui, vi;
		scanf("%d%d", &ui, &vi);
		add_edge(ui, vi);
		add_edge(vi, ui);
	}

	for(int i = 1; i <= n; ++i) {
		memset(vis, 0, sizeof(vis));  //初始化 vis 数组
		isCut[i] = dfs(i, i);
		if(isCut[i]) {
			++cnt;
		}
	}

	printf("%d\n", cnt);  //输出图中有多少个割点
	for(int i = 1; i <= n; ++i) {
		if(isCut[i]) {
			printf("%d ", i);  //从小到大输出所有割点的编号
		}
	}

	return 0;
}
//by CaO

如此优雅的代码,那么它的性能如何呢?
image

看上去并不大好……

这道题是洛谷 P3388【模板】割点(割顶),求割点的板子题。然而即使是开了 O2 优化,也拿到了 12 个点超时了 11 个点的好成绩。

事实上,其时间复杂度为 O(|V|(|V|+|E|)),这道题中 |V|2×104|E|105,当然是妥妥的超时了 QwQ。

但是我们发现,对每一个结点都跑一遍 DFS,实在是有些太浪费时间了,如果有一种算法,能够在一遍 DFS 后就能找到所有的割点就好了,这样的算法就可以将时间复杂度降到 O(|V|+|E|)

而这,就是我们接下来要学习的 Tarjan 算法。

Tarjan 算法

Tarjan 算法的引入及介绍

Tarjan 算法,顾名思义,是由计算机科学家 Robert Tarjan 发明的算法。而为了介绍这个算法,我们需要先介绍一些名词:

  • DFS 搜索树:我们通过在图上深度优先搜索,保留其中所有在遍历过程中经过的边,将这些边连起来,就会形成一棵树。例如下图:从 1 号节点出发,跑 DFS 的顺序为 1->2->3->4->3(回溯)->5,故其 DFS 搜索树如下右图红边所示,其根节点为 1
    image

同时,我们称其中的红边,即直接连接搜索树上的父子两点的边为出边,类似 1315 的两条边,它们并不在 DFS 搜索树中被经过,而这种从某个节点回到其祖先节点的边为回边

  • 时间戳:在图的 DFS 中,u 号节点被访问到的排名,被称为其时间戳,以下记作 dfn[u],例如上图中,1 号节点是第一个被访问的,2 号节点是第二个被访问的……以此类推。所以有 dfn[1]=1dfn[2]=2dfn[3]=3dfn[4]=4dfn[5]=5

  • 追溯值:在图被 DFS 后,生成了一个 DFS 搜索树。u 号节点通过绕过其父结点能够回到的时间戳最小的节点的时间戳,称为其追溯值,以下记作 low[u]。注意,这里说的绕过父结点,既可以是通过回边,也可以是通过其孩子节点回到某个节点(这句话依旧非常拗口 QwQ,既然概念非常难懂,请读者多读几遍)。
    例如上图,2 号节点是 3 号节点的父结点,但 3 号节点能够通过回边 13 回到 1 号节点,故 low[3]=dfn[1]=1,同理 low[5]=1
    2 号节点虽然本身不能回到 1 号节点,但它可以通过路径 231 回到 1 号节点,故 low[2]=1
    然而,4 号节点不能绕过 3 号节点回到任何节点,故它只能追溯到其本身,故有 low[4]=dfn[4]=4

Tarjan 算法的 DFS 过程

我们为什么要大费周章地介绍上面的三个概念呢?因为接下来生成一个 Tarjan 图(笔者喜欢这样称呼它 QwQ,不要介意)就需要我们知道,如何计算每个节点的 low 值和 dfn 值。

算法过程大致如下:从根节点开始搜索。每次搜索到一个新的节点 u,我们就可以很容易地得到该点 dfn 值,同时,令该点的 low=dfn。接下来,从这个点继续搜索,如果搜索到一个比该点时间戳小的结点 v,那么就令 low[u]=dfn[v];如果搜索到一个新的节点 u,那么就对 u 重复上述操作,并在回溯时,令 low[u]=min(low[u],low[u])

当然,直接描述看起来很抽象。所以我们以下面这张图为例,我们来计算一下每个点的 dfnlow
image

  1. 我们既然是从 1 号节点开始搜索,那么一定有 dfn[1]=low[1]=1
  2. 第二个搜到的是 2 号节点,则 dfn[2]=low[2]=2;第三个是 3 号节点,dfn[3]=low[3]=3,如下图:
    image
  3. 然而,从 3 号节点,我们可以回到 1 号节点,所以我们要更新 low[3]=dfn[1]=1
    image
  4. 第四个搜到的是 4 号节点,dfn[4]=4,同时,因为 4 号节点能够回到 1 号节点,也有 low[4]=1
    image
  5. 第五个搜到的是 5 号节点,5 号节点可以回到 3 号节点,故 low[5]=dfn[3]=3。第六个搜到了 6 号节点,这个节点不能回到任何一个结点,故 low[6]=dfn[6]=6
    image
  6. 一直回溯,回溯到二号节点时,发现其孩子结点 3 号的 low[3]=1,故 low[2]=min(low[2],low[3])=1
    image

通过上述步骤,我们得到了每个节点的 dfnlow 值。

代码就模拟上述步骤实现就可以:

#include <iostream>
using namespace std;
const int maxN=2000001;

int head[maxN], top, n, m, cnt;
int dfn[maxN], low[maxN];

struct Edge {
	int to;
	int next;
} edge[maxN];

inline void add_edge(int u, int v) {
	edge[++top].to = v;
	edge[top].next = head[u];
	head[u] = top;
}

void tarjan(int cur, int fa) {
	dfn[cur] = low[cur] = ++cnt;

	for(int ptr = head[cur]; ptr; ptr = edge[ptr].next) {
		int curv = edge[ptr].to;
		if(!dfn[curv]) {
			tarjan(curv, fa);  //对未访问的结点进行深搜 
			low[cur] = min(low[cur], low[curv]);  //在回溯时更新 low 值 
		}

		low[cur] = min(dfn[cur], low[curv]);  //通过回边更新 low 值 
	}
}

int main(void) {
	scanf("%d%d", &n, &m);

	for(int i = 1; i <= m; ++i) {
		int ui, vi;
		scanf("%d%d", &ui, &vi);
		add_edge(ui, vi);
		add_edge(vi, ui);
	}

	for(int i = 1; i <= n; ++i) {
		printf("%d ", dfn[i]);  //依次输出每个点的 dfn 值 
	}
	putchar('\n');
	for(int i = 1; i <= n; ++i) {
		printf("%d ", low[i]);  //依次输出每个点的 low 值 
	}

	return 0;
}
//by CaO

Tarjan 求割点和桥

Tarjan 求割点的做法很简单,对于一张图的 DFS 搜索树,如果这棵树上某个非根节点 u,存在它的一个一级孩子结点 v,满足 low[v]dfn[u],那么就有 u 是一个割点。

证明也很显然,如果 low[v]dfn[u],就意味着在 v 在不回到其父亲节点的情况下,哪里也去不了。
如果是 u根节点呢?也很简单,统计它的子树数量 child,如果 child>1,就说明根节点的子树们在不经过根节点的情况下不能相互抵达。

上代码:

#include <iostream>
#define reg register
using namespace std;
const int maxN=200001;

int n, m, head[maxN], dfn[maxN], low[maxN];
int cnt, top, tot;
bool isCut[maxN];
struct Edge{
	int to;
	int next;
} edge[200001];

inline void add_edge(int u, int v) {
	edge[++top].to=v;
	edge[top].next=head[u];
	head[u]=top;
}

void tarjan(int u, int fa) {  //Tarjan 算法求割点
	int child=0;  //统计以当前节点为根的子树个数
	low[u]=dfn[u]=++cnt;

	for(reg int ptr=head[u]; edge[ptr].to; ptr=edge[ptr].next) {
		int cur=edge[ptr].to;
		if(!dfn[cur]) {
			tarjan(cur, u);  //对未访问的结点进行深搜
			low[u]=min(low[u], low[cur]);  //在回溯时更新 low 值

			if(u==fa) {
				++child;
			}

			if(low[cur]>=dfn[u] && u!=fa) {
				isCut[u]=true;
			}
		}

		low[u]=min(low[u], dfn[cur]);  //通过回边更新 low 值
	}

	if(child>1 && u==fa) {
		isCut[u]=true;  //如果该根节点的子树多于一棵,则说明根节点是割点
	}
}

int main(void) {
	scanf("%d%d", &n, &m);

	for(reg int i(1); i<=m; ++i) {
		int ui, vi;
		scanf("%d%d", &ui, &vi);
		add_edge(ui, vi);
		add_edge(vi, ui);
	}

	for(reg int i(1); i<=n; ++i) {
		if(!dfn[i]) {
			tarjan(i, i);  //注意图不一定连通
		}
	}

	for(reg int i(1); i<=n; ++i) {
		if(isCut[i]) {
			++tot;
		}
	}
	printf("%d\n", tot);  //输出割点的个数

	for(reg int i(1); i<=n; ++i) {
		if(isCut[i]) {
			printf("%d ", i);  //输出所有割点的编号
		}
	}
	return 0;
}
//by CaO

Tarjan 算法求割边

对于图中的每一条边,若它所连接的两个结点 uv 满足 low[v]>dfn[u],则意味着这条边是割边。因为 low[v]>dfn[u],就意味着 v 不能通过这条边到达 u。代码留给读者作为练习。

时间复杂度分析

Tarjan 算法只需要通过一遍 DFS 就能求出所有的割点和桥,以及强连通分量(下回将会提出如何求图的强连通分量)。所以它的时间复杂度即为 O(|V|+|E|)。这样的时间复杂度,相对于暴力枚举的 O(|V|(|V|+|E|)) 的时间复杂度就优秀得很多了。

例题

本题目列表会持续更新。

posted @   CaO氧化钙  阅读(570)  评论(2编辑  收藏  举报
相关博文:
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
点击右上角即可分享
微信分享提示