【学习笔记】Tarjan 图论算法
- 前言
本文主要介绍 Tarjan 算法的「强连通分量」「割点」「桥」等算法。
争取写的好懂一些。
- 「强连通分量」
- 何为「强连通分量」
在有向图中,如果任意两个点都能通过直接或间接的路径相互通达,那么就称这个有向图是「强连通」的。
如果这个有向图的子图是「强连通」的,我们就称这个子图为「强连通分量」。
特别的,单独一个点也算是一个强连通分量。
例如下图
我们发现,
同理
- Tarjan
我们对每个节点定义两个值:
:表示这个节点的 dfs 序,即它在 dfs 中是第几个被访问到的。 :表示这个节点能追溯到的在栈中节点的最早的已经在栈中的节点。
其中,以
其中,每访问到一个节点时,我们初始化为
然后我们接着访问它下面的一个节点:
- 它没被访问过,不在栈中,就先
,对他进行深搜,并在回溯过程中以 更新 (因为 可以访问到的节点 一定也可以访问到)。 - 它被访问过,在栈中,根据
的定义,用 更新 。 - 它被访问过,不在栈中,这就说明它已经在一个强连通分量中了,无需更新。
接下来我们来看看当
根据定义,这个强联通分量中只有一个点的
因此,如果
总体的时间复杂度是
- 图解
起始图,从
顺次搜到
发现
回溯发现
至此找到两个强连通分量:
回溯时拓展
回到节点
回溯
虽然
到
至此,Tarjan Algorithm 结束,强连通分量为
- 代码实现
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 5;
int scc[N], stk[N], top, dfn[N], low[N], dfs_num, col_num;
bool vis[N];
vector<int> G[N];
void tarjan(int u){
dfn[u] = low[u] = ++dfs_num;
stk[++ top] = u; vis[u] = true;
for(auto &v : G[u]){
if(!dfn[v]){
tarjan(v);
low[u] = min(low[u], low[v]);
}
else if(vis[v]){
low[u] = min(low[u], dfn[v]);
}
}
if(dfn[u] == low[u]){
vis[u] = false;
scc[u] = ++col_num;
while(stk[top] != u){
scc[stk[top]] = col_num;
vis[stk[top--]] = false;
}
--top;
}
}
int main(){
// initalize graph
for(int i = 1; i <= n; i++){
if(!dfn[i]) tarjan(i);
}
}
- 「割边」(桥)
- 何为「割边」
在无向图中,如果把一条边删掉,导致这个图的极大联通子图数量增加,那么这条边就叫「割边」,也称作「桥」。
比如,下图:
割去
可以证明,整个图只有这一条割边。
- Tarjan 求割边
我们还是来定义一下
-
:时间戳,表示在 dfs 树中是第几个被访问到的。 -
:追溯值数组,表示以 为根的搜索树上的点以及通过一条不在搜索树上的边能达到的结点中的最小编号。
我们在访问到节点
然后我们接着访问它下面的一个节点:
- 它没被访问过,就先
,对他进行深搜,并在回溯过程中以 更新 (因为 可以访问到的节点 一定也可以访问到)。 - 它被访问过,且不是它在 dfs 树上的父节点,根据
的定义,用 更新 。
接下来我们想一想什么时候
显然,如果一条边连接的
- 代码实现(以 UVA796 为例)
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 5;
const int M = 1e6 + 5;
int dfn[N], low[N], dfs_num;
bool bridge[M];
struct graph{
int to, next;
}G[N];
int head[N], cnt;
void addEdge(int u, int v){
G[++cnt] = {v, head[u]}; head[u] = cnt;
}
vector<pair<int,int> > cut;
void tarjan(int u, int fa){
dfn[u] = low[u] = ++dfs_num;
for(int i = head[u]; i; i = G[i].next){
int v = G[i].to;
if(!dfn[v]){
tarjan(v, u);
if(low[v] > dfn[u]){
bridge[i] = bridge[i ^ 1] = true;
cut.push_back({min(u, v), max(u, v)});
}
low[u] = min(low[u], low[v]);
}
else if(v != fa){
low[u] = min(low[u], dfn[v]);
}
}
}
void solve();
int n;
int main(){
while(~scanf("%d", &n))
solve();
}
void solve(){
vector<pair<int, int> > ().swap(cut);
memset(head, 0, (n + 5) * sizeof head[0]);
memset(dfn, 0, (n + 5) * sizeof dfn[0]);
memset(low, 0, (n + 5) * sizeof low[0]);
memset(bridge, 0, (cnt + 5) * sizeof bridge[0]);
cnt = 1;
for(int i = 1; i <= n; i++){
int u, k;
scanf("%d (%d)", &u, &k); ++u;
for(int j = 1; j <= k; j++){
int v;
scanf("%d", &v); ++v;
addEdge(u, v);
addEdge(v, u);
}
}
for(int i = 1; i <= n; i++){
if(!dfn[i]) tarjan(i, 0);
}
sort(cut.begin(), cut.end());
printf("%d critical links\n", (int)cut.size());
for(auto edge: cut) printf("%d - %d\n", edge.first - 1, edge.second - 1);
putchar('\n');
}
- 「割点」(割顶)
- 何为「割点」
在无向图中,如果把一个点及其连到他的所有边删掉,导致这个图的极大联通子图数量增加,那么这条边就叫「割点」,也称作「割顶」。
例如下图:
我们看到,把
可以证明,整个图只有
- Tarjan 求割点
-
:时间戳,表示在 dfs 树中是第几个被访问到的。 -
:追溯值数组,表示以 为根的搜索树上的点以及通过一条不在搜索树上的边能达到的结点中的最小编号。
我们在访问到节点
然后我们接着访问它下面的一个节点:
- 它没被访问过,就先
,对他进行深搜,并在回溯过程中以 更新 (因为 可以访问到的节点 一定也可以访问到)。 - 它被访问过,且不是它在 dfs 树上的父节点,根据
的定义,用 更新 。
这里不可以用
更新 !!!
如果之前的是经过 的祖先节点的,那么 就不能算是 的可能情况(因为 要通过一条不在搜索树上的边),而 是确定的,可以更新。
接下来我们想一想什么时候
分情况:
-
是当前搜索树的根节点,那么,如果它在搜索树上有两个及以上的子树,它肯定是割点。因为割去它,一个子树必定无法到达另一个子树。 -
不是根节点,那么如果 ,说明 能追溯到的最早的节点顶多是 了,割去 会使它访问不到上面的节点。那么 是割点。
- 代码实现(以 洛谷 P3388 为例)
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 5;
const int M = 1e6 + 5;
int dfn[N], low[N], dfs_num;
bool cut[N];
vector<int> G[N];
int n, m, cut_num;
void tarjan(int u, int fa){
int son = 0;
dfn[u] = low[u] = ++dfs_num;
for(auto &v: G[u]){
if(!dfn[v]){
++son;
tarjan(v, u);
low[u] = min(low[u], low[v]);
if(u != fa && low[v] >= dfn[u]) cut[u] = true;
}
else if(v != fa) low[u] = min(low[u], dfn[v]);
}
if(u == fa && son >= 2) cut[u] = true;
}
int main(){
scanf("%d %d", &n, &m);
for(int i = 1; i <= m; i++){
int u, v;
scanf("%d %d", &u, &v);
G[u].push_back(v);
G[v].push_back(u);
}
for(int i = 1; i <= n; i++)
if(!dfn[i]) tarjan(i, i);
for(int i = 1; i <= n; i++)
if(cut[i]) cut_num++;
printf("%d\n", cut_num);
for(int i = 1; i <= n; i++)
if(cut[i]) printf("%d ", i);
}
- 「边双联通分量」
- 何为「边双联通分量」
如果对于原图的一个联通分量
- Tarjan 求边双联通分量
显然,一个边双联通分量中不存在割边,因为如果有割边的话两个分量就不会联通。
那么我们要做的事就很清晰了:先把割边删除,然后对于每一个没有上色的点 dfs,并对其联通分量染色。最后染成一色的就是一个边双。
实现代码时有一个小技巧:不需要真正地把割边删除,只需要标记这条边不可走即可。
- 代码实现(以 洛谷 P8436 为例)
#include <bits/stdc++.h>
using namespace std;
const int N = 6e5 + 5;
const int M = 6e6 + 5;
int dfn[N], low[N], dfs_num;
bool bridge[M], vis[N];
int n, m, scc_num;
vector<vector<int> > cut;
struct graph{
int to, next;
}G[M];
int head[N], cnt = 1;
void addEdge(int u, int v){G[++cnt] = {v, head[u]}; head[u] = cnt;}
void tarjan(int u, int fa){
dfn[u] = low[u] = ++dfs_num;
for(int i = head[u]; i; i = G[i].next){
int v = G[i].to;
if(!dfn[v]){
tarjan(v, u);
low[u] = min(low[u], low[v]);
if(low[v] > dfn[u])
bridge[i] = bridge[i ^ 1] = true;
}
else if(v != fa) low[u] = min(low[u], dfn[v]);
}
}
void dfs(int u, int col){
vis[u] = true;
cut[col - 1].push_back(u);
for(int i = head[u]; i; i = G[i].next){
int v = G[i].to;
if(vis[v] || bridge[i]) continue;
dfs(v, col);
}
}
int main(){
scanf("%d %d", &n, &m);
for(int i = 1; i <= m; i++){
int u, v;
scanf("%d %d", &u, &v);
if(u == v) continue;
addEdge(u, v);
addEdge(v, u);
}
for(int i = 1; i <= n; i++)
if(!dfn[i]) tarjan(i, i);
for(int i = 1; i <= n; i++){
if(!vis[i]){
++scc_num;
cut.push_back(vector<int>());
dfs(i, scc_num);
}
}
printf("%d\n", scc_num);
for(int i = 0; i < scc_num; i++){
printf("%d ", cut[i].size());
for(auto vertex: cut[i]) printf("%d ",vertex);
printf("\n");
}
}
- 「点双联通分量」
- 何为「点双联通分量」
如果对于原图的一个联通分量
容易看出,对于原图
割点连接着原图的两个(或以上)部分,必然每个部分都应将它包含。
- Tarjan 求点双联通分量
显然,一个点双联通分量中不存在割点,因为如果有割点的话删去之后两个分量就不会联通。
那么我们要做的事就很清晰了:先把找出割点,然后对于每一个没有上色的点 dfs,并对其联通分量染色,注意到了割点就不能往下走了。最后染成一色的就是一个点双。
注意每个割点会被染色多次,所以你只需要在 dfs 过程中统计即可。
- 代码实现
// 根据边双改改喵
// 根据边双改改谢谢喵
- Reference
-
:https://www.cnblogs.com/shadowland/p/5872257.html,感谢 SHHHS 大佬让我懂得了 Tarjan。 -
:关于这里为什么最好用 更新 ,下文讲到割点的时候会讲。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具