Tarjan 求割点
Tarjan 算法求割点的原理与实现:无向图连通性判定。
一句话算法
Tarjan 割点用 low[v] >= dfn[u] 判断:如果子树 v 不能绕过 u 回到更早的祖先,那么 u 就是这棵子树和外界的关口。
问题模型
给定一个无向图。若删除某个点 u 以及与它相连的所有边后,图的连通块数量增加,则 u 是割点。
割点描述的是无向图中的“关节点”:某些子图只能通过它和外部相连。
核心直觉
在 DFS 树上看一个点 u:
dfn[u]表示u第几次被 DFS 访问。low[u]表示从u的子树出发,最多能通过一条返祖边回到的最早节点编号。
如果 u 的某个孩子 v 满足:
说明 v 的整棵子树无法绕过 u 回到 u 的祖先。删除 u 后,v 子树就会和外界断开,所以 u 是割点。


算法步骤
- DFS 遍历无向图,记录每个点的
dfn和low。 - 遇到树边
u -> v:- 递归处理
v。 - 用
low[v]更新low[u]。 - 如果
u不是 DFS 根,且low[v] >= dfn[u],则u是割点。
- 递归处理
- 遇到返祖边
u -> v:- 用
dfn[v]更新low[u]。
- 用
- 对 DFS 根单独判断:
- 如果根有两个及以上 DFS 树儿子,则根是割点。
算法证明
关键不变量: low[u] 是 u 的 DFS 子树能到达的最早祖先的 dfn。
- 树边更新: 如果
u的孩子v的子树能回到某个更早祖先,那么u也能通过v子树到达它,所以用low[v]更新low[u]。 - 返祖边更新: 如果
u直接连到祖先v,那么u的子树能到达dfn[v]。 - 非根割点: 若存在孩子
v满足low[v] >= dfn[u],则v子树不能回到u的祖先。删除u后,v子树和外界断开,所以u是割点。 - 根割点: DFS 根没有祖先。只有当它有至少两个 DFS 子树时,删除根才会让这些子树彼此断开。
复杂度分析
每个点访问一次,每条无向边被检查两次。
- 时间复杂度:
。 - 空间复杂度:
。
代码实现
dfn是Depth First Number的缩写,意为深度优先搜索序列编号。
cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
struct TarjanCut {
int n, timer; // timer 对应你代码中的 cnt
int dfn[maxn], low[maxn];
bool is_cut[maxn]; // 标记是否为割点 (对应 cut[])
int root; // 当前 DFS 树的根节点
void set(int _n) {
n = _n;
timer = 0;
memset(dfn, 0, sizeof(dfn));
memset(low, 0, sizeof(low));
memset(is_cut, 0, sizeof(is_cut));
}
/*
* u: 当前节点
* fa: u 的父节点 (防止走回头路)
*/
void dfs(int u, int fa = -1) {
dfn[u] = low[u] = ++timer;
int child = 0; // 记录 root 在 DFS 树中的子节点数量
for (int i = e.h[u]; ~i; i = e[i].next) {
int v = e[i].v;
// 不走父子边
if (v == fa) continue;
// v 没有被访问过,是树边
if (!dfn[v]) {
child++;
dfs(v, u); // 【注意】这里必须传 u,表示 v 的父亲是 u
// 回溯时用子节点的 low 更新当前节点的 low
low[u] = std::min(low[u], low[v]);
// 割点判定情况 2: 非根节点
// 如果子节点 v 无法回到 u 的祖先 (low[v] >= dfn[u])
// 说明 u 是连接 v 子树和其他部分的必经点
if (low[v] >= dfn[u] && u != root) {
is_cut[u] = true;
}
// 处理返祖边
// 注意:v可能是u的父亲,但没有关系,最多low[u] == dfn[fa[u]]
// dfn[v] < dfn[u] 说明v是u的祖先,
// 在无向图上其实不可能 dfn[v] > dfn[u]
// 因为: v 是一个已经访问过的点, 如果dfn[v] > dfn[u] 说明u是v的祖先, 那么v在u的子树上,
// 根据dfs 的性质, 应该先访问u, 再访问v,但此时v已经被访问, 所以不可能出现dfn[v] > dfn[u]的情况
} else if( dfn[v] < dfn[u] ) {
// v 已经被访问过,是返祖边 (Back Edge)
// 用 v 的 dfn 更新 u 的 low
low[u] = std::min(low[u], dfn[v]);
// 注意:有些版本写成 min(low[u], low[v]) (对于已访问的 v) 是错误的。
// 对于返祖边,只能取 dfn[v],因为 low[v] 可能包含从 v 回到更上层的路径,
// 而边 (u, v) 并不代表 u 可以通过 v 再跳跃回去(除非有其他边)。
}
}
// 割点判定情况 1: 根节点
// 如果根节点在 DFS 树中有两个及以上的子节点,则它是割点
if (u == root && child > 1) {
is_cut[u] = true;
}
}
void solve() {
// 遍历所有点,防止图不连通
for (int i = 1; i <= n; i++) {
if (!dfn[i]) {
root = i; // 记录当前连通块的 DFS 根
dfs(i, 0);
}
}
}
// 辅助函数:获取所有割点
std::vector<int> get_cuts() {
std::vector<int> cuts;
for (int i = 1; i <= n; i++) {
if (is_cut[i]) cuts.push_back(i);
}
return cuts;
}
};
测试用例
输入:
5 4
1 2
2 3
3 4
3 5
这个图中 2 和 3 是割点。模板本身是结构体代码,实际题目中通常调用 solve() 后读取 get_cuts()。
应用分类详解
割点的本质是判断无向图中哪些点是连通性的必经关口。
一、网络脆弱点
典型模式: 删除某个点后,网络是否断开。 识别信号: 题面问“关键城市”“通信站”“关节点”。 核心建模: 点是网络节点,边是连接关系,割点就是单点故障位置。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 割点模板 | luogu-P3388 | Tarjan 求所有割点 |
二、点双连通分量前置
典型模式: 需要把无向图按点双连通块分解。 识别信号: 题目涉及“任意两点至少两条点不相交路径”。 核心建模: 割点可能属于多个点双,非割点只属于一个点双。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 点双连通分量 | luogu-P8435 | 用割点分割点双连通块 |
经典例题
-
luogu-P3388 割点模板题。重点是根节点必须单独判断。
-
luogu-P8435 点双连通分量模板题。理解割点为什么能属于多个点双。
-
UVA 10199 Tourist Guide 经典割点应用,把城市网络中的关键点输出出来。