tarjan学习笔记

Tarjan算法学习笔记

前言

注:本笔记经过ai修改

先膜拜tarjan爷爷 orz

tarjan爷爷

Tarjan算法是图论中的经典算法,由Robert Tarjan提出,具有多种核心应用,主要包括:求强连通分量(SCC)、缩点操作、求割点、求割边(桥)、求边双连通分量、求点双连通分量,核心思想基于深度优先搜索(DFS),通过维护时间戳和回溯信息高效求解各类图论问题。

一、Tarjan缩点/强连通分量(SCC)

1.1 DFS生成树

要理解Tarjan算法,首先需掌握DFS生成树的概念:

深度优先搜索(DFS)是一种用于遍历或搜索树或图的算法。在执行深度优先搜索时,会优先沿着一条路径深入到不能再深入为止,然后回溯到最近的分叉点继续搜索。这种方式下,遍历过程中经历的顶点和边的组合就构成了深度优先生成树(dfs生成树)。

边的示意图

有向图的DFS生成树中,主要包含四种边:

  1. 树边(普通边):DFS中,通过此边向下递归找到未访问过节点的边,如图中黑色边。

  2. 横插边(横向连接边):DFS中,通过此边访问到已访问过的节点,且该节点不是当前节点的祖先节点,如图中蓝色边。

  3. 返祖边(跳回祖宗节点的边):DFS中,通过此边访问到已访问过的节点,且该节点是当前节点的祖先节点,如图中红色边。

  4. 前向边(超前边):DFS时,遇到指向子树中已访问节点的边,如图中绿色边。

1.2 缩点核心原理

Tarjan求强连通分量(SCC)的本质是一种优化的DFS,核心是维护三个关键数据结构/数组:

  • 栈 stk_t:存储搜索树中尚未处理完毕的节点;

  • dfn[u]:记录节点u在DFS过程中被访问的次序(时间戳);

  • low[u]:记录节点u及其子树中能够回溯到的、最早进入栈的节点的时间戳。

遍历节点u的相邻节点j时,分三种情况处理:

  1. j未被访问过:递归遍历j节点,回溯时用 low[j] 更新 low[u](子树的回溯能力继承给父节点);

  2. j已被访问过,且在栈中:用 dfn[j] 更新 low[u](j是u的祖先,可通过返祖边回溯);

  3. j已被访问过,且不在栈中:无需操作(j属于已处理完毕的强连通分量,与当前分量无关)。

关键判定:对于一个强连通分量,有且仅有一个节点u满足 dfn[u] == low[u],该节点是这个强连通分量中第一个被访问的节点(根节点)。当回溯到该节点时,说明找到了一个完整的强连通分量,将栈中该节点及其上方的所有节点弹出,缩为一个新的分量。

缩点完成后,通常会对缩点后的DAG图跑拓扑排序,用于解决后续的路径、最值等问题。

1.3 参考代码

int n, m;
vector<int> e[N];

int dfn[N], low[N], tot;
int stk[N], tt = 0;
bool st[N];

int cnt, g[N], id[N]; // cnt:强连通分量个数;g[cnt]:第cnt个分量的节点数;id[u]:节点u所属的分量编号

inline void tarjan(int u){
	
	dfn[u] = low[u] = ++ tot;
	stk[ ++ tt] = u;
	st[u] = true;
	
	for(register int j : e[u]){
		if(!dfn[j]){
			tarjan(j);
			low[u] = min(low[u], low[j]);
		}
		else if(st[j]){
			low[u] = min(low[u], dfn[j]);
		}
	}
	
	if(dfn[u] == low[u]){ // 找到强连通分量的根节点
		cnt ++;
		int y;
		do{
			y = stk[tt --];
			id[y] = cnt;
			g[cnt] ++; // 统计该分量的节点数,可根据需求修改
			st[y] = false; 
		}while(y != u);
	}
}

二、Tarjan求割点

2.1 割点定义

割点:在一个无向图中,如果去掉某个顶点及其所有连接的边后,图的连通分量数量增加(即不再连通),那么这个顶点就是割点(又称割顶)。

2.2 普通判定方法

最朴素的思路:遍历每个节点,删除该节点及其所有边,判断图的连通性。若连通性发生变化,则该节点是割点。

图

如上图,删除2号节点后,图分为两个连通分量,因此2号节点是割点。

2.3 Tarjan求割点原理

Tarjan求割点同样基于DFS,沿用 dfn(时间戳)和 low(回溯最早时间戳)两个数组,核心判定逻辑如下:

在DFS访问到节点u时,图被u分为“已访问部分”和“未访问部分”。若u是割点,则存在未访问的节点,在不经过u的情况下,无法到达已访问部分。

具体判定条件:

  1. 对于非根节点u:若存在子节点j,满足 low[j] >= dfn[u],说明j及其子树无法回溯到u的祖先节点,删除u后,j及其子树会与原图分离,因此u是割点;

  2. 对于根节点u:若其儿子数量 son > 1,说明删除根节点后,多个儿子子树会相互分离,因此u是割点。

2.4 参考代码(模板题)

模板题:Luogu P3388 割点(模板)

int n, m;
vector<int> e[N];

int dfn[N], low[N], tot;
int stk[N], tt;

int cnt = 0; // 割点的个数
bool cut[N]; // cut[u]:标记u是否是割点

inline void tarjan(int u, int fa){ // fa:u的父节点,根节点fa=-1
	
	dfn[u] = low[u] = ++ tot;
	stk[ ++ tt] = u;
	int son = 0; // 记录u的儿子数量(仅统计直接子节点)
	
	for(register int j : e[u]){
		if(!dfn[j]){
			son ++;
			tarjan(j, u);
			low[u] = min(low[u], low[j]);
			// 非根节点,满足割点判定条件
			if(low[j] >= dfn[u] && fa != -1){
				if(!cut[u]) cnt ++;
				cut[u] = true;
			}
		}
		else{
			// 回溯更新low[u](排除父节点,避免重复计算)
			low[u] = min(low[u], dfn[j]);
		}
	}
	
	// 根节点,儿子数>1则为割点
	if(son > 1 && fa == -1){
		if(!cut[u]) cnt ++;
		cut[u] = true;
	}
}

三、Tarjan求割边(桥)

3.1 割边定义

割边:在一个无向图中,如果去掉某条边后,图的连通分量数量增加(即不再连通),那么这条边就是割边(又称桥)。

3.2 割边示意图

图2

如上图,删除2-4这条边后,图分为两个连通分量,因此2-4是割边。

3.3 Tarjan求割边原理

与割点判定类似,Tarjan求割边同样基于 dfn 和 low 数组,核心区别在于判定条件:

对于边 (u, j)(u是j的父节点),若满足 low[j] > dfn[u],说明j及其子树无法回溯到u或u的祖先节点,删除边 (u, j) 后,j及其子树会与原图分离,因此这条边是割边。

注:本代码为不带重边的割边判定,若存在重边,需额外处理(避免将重边误判为割边)。

3.4 参考代码

int n, m;
vector<int> e[N];

int dfn[N], low[N], tot;
int stk[N], tt;

int num = 0; // 割边的个数

inline void tarjan(int u, int fa){
	
	dfn[u] = low[u] = ++ tot;
	stk[ ++ tt] = u;
	
	for(register int j : e[u]){
		if(!dfn[j]){
			tarjan(j, u);
			low[u] = min(low[u], low[j]);
			// 割边判定条件:子节点无法回溯到当前节点及以上
			if(low[j] > dfn[u]){
				num ++;
			}
		}
		else if(j != fa){ // 排除父节点,避免重复计算
			low[u] = min(low[u], dfn[j]);
		}
	}
}

四、Tarjan求边双连通分量

4.1 边双连通分量定义

边双连通分量(Biconnected Component, BCC):无向图中,不含割边的极大连通子图。即子图中任意两点之间至少存在两条不重合的边(去掉任意一条边,子图仍连通)。

4.2 参考代码(模板题)

模板题:P8436 边双连通分量模板

int n, m;
vector<PII> e[N]; // e[u] 存储 (邻接节点j, 边的编号)

int dfn[N], low[N], tot;
int stk[N], tt = 0;
bool st[N]; // 标记节点是否在栈中

int cnt, g[N], id[N]; // cnt:边双连通分量个数;id[u]:节点u所属分量编号
vector<vector<int>> ans; // 存储每个边双连通分量的节点

inline void tarjan(int u, int lt){ // lt:当前边的编号,用于排除反向边(避免重边误判)
	
	dfn[u] = low[u] = ++ tot;
	stk[ ++ tt] = u;
	st[u] = true;
	
	for(auto it : e[u]){
		int j = it.first;
		if(it.second == (lt ^ 1)) continue; // 排除反向边(假设边编号从0开始,异或1得到反向边)
		if(!dfn[j]){
			tarjan(j, it.second);
			low[u] = min(low[u], low[j]);
		}
		else if(st[j]){
			low[u] = min(low[u], dfn[j]);
		}
	}
	
	if(dfn[u] == low[u]){ // 找到边双连通分量的根节点
		cnt ++;
		int y;
		vector<int> v; 
		do{
			y = stk[tt --];
			id[y] = cnt;
			v.push_back(y);
			st[y] = false; 
		}while(y != u);
		ans.push_back(v); // 存储当前分量的所有节点
	}
}

五、Tarjan求点双连通分量

5.1 点双连通分量定义

点双连通分量(Vertex Biconnected Component, VBCC):无向图中,不含割点的极大连通子图。即子图中任意两点之间至少存在两条不经过同一个节点的路径(去掉任意一个节点,子图仍连通)。

5.2 参考代码(模板题)

模板题:P8435 点双连通分量模板

int n, m;
vector<int> e[N];

int dfn[N], low[N], stk[N], tt = 0;
int tot = 0; // 时间戳计数器
bool st[N]; // 标记节点是否在栈中
int cnt = 0; // 点双连通分量个数
int rt; // 根节点
bool ct[N]; // 标记节点是否是割点

vector<int> ans[N]; // ans[cnt] 存储第cnt个点双连通分量的节点

inline void tarjan(int u, int fa){
	
	dfn[u] = low[u] = ++ tot;
	stk[ ++ tt] = u;
	// 特殊情况:孤立节点(单独作为一个点双连通分量)
	if(fa == -1 && e[u].size() == 0){
		ans[ ++ cnt].push_back(u);
		return ;
	}
	st[u] = true;
	int son = 0;
	
	for(register int j : e[u]){
		if(!dfn[j]){
			son ++;
			tarjan(j, u);
			low[u] = min(low[u], low[j]);
			
			// 点双连通分量判定,同时标记割点
			if(low[j] >= dfn[u]){
				son ++;
				if(u != rt || son > 1){
					ct[u] = true; // 标记u为割点
				}
				cnt ++;
				int y;
				do{
					y = stk[tt --];
					ans[cnt].push_back(y);
				}while(y != j);
				ans[cnt].push_back(u);	// 割点会被包含在多个点双连通分量中
			}
		}
		else if(j != fa){ // 排除父节点,避免重复计算
			low[u] = min(low[u], dfn[j]);
		}
	} 
}

六、相关练习题(按知识点分类)

1. 最朴素的Tarjan(强连通分量基础)

2. Tarjan求割点

3. Tarjan求割边

4. Tarjan求边双连通分量

5. Tarjan求点双连通分量

6. 缩点应用(点双/边双/强连通缩点)

7. 强连通分量/缩点进阶

posted @ 2026-02-27 12:07  神烦doge  阅读(35)  评论(0)    收藏  举报