概念解释
a7175754-4ad9-4ea4-bb3e-9c02b533ee22
定义
首先给出无向图 ,
割点(割顶)
对于 ,从图中删去
及其关联边后,整个图不再联通,称
为
的割点(割顶)。
割边(桥)
对于 ,从图中删去
及其关联边后,整个图不再联通,称
为
的割边(桥)。
时间戳
遍历顺序。
追溯值
设 为搜索树上以
为根节点的子树。
定义为以下节点中的时间戳的最小值。
中的节点
- 通过一条不在
上的边,能够到达
的节点。
这自然而然地有着两种更新方式。非常重要!!!
根据定义,初始化为当前节点的时间戳。
- 若在搜索树上,
是
的父亲,那么
;
- 若无向边
不在搜索树上,那么
。
算法分析
割边判定法则
无向边
是桥,当且仅当
是
的父亲,满足
![]()
1. 条件含义
-
dfn[u]
:节点u
的DFS访问时间戳(即首次被访问的顺序)。 -
low[v]
:节点v
通过非父边能回溯到的最早祖先节点的时间戳。 -
low[v] > dfn[u]
:表示v
及其子树中的所有节点,无法通过任何非父边回溯到u
或其祖先节点12。
2. 逻辑解释
- 割边的定义:删除该边后,图的连通分量增加。
- 条件成立时的行为:
- 若
low[v] > dfn[u]
,说明从v
出发的所有路径必须经过边(u,v)
才能回到u
的祖先。 - 删除边
(u,v)
后,v
及其子树将与图的其余部分断开,因此(u,v)
是割边12。
- 若
3. 设计原因
- 必要性:确保割边的严格判定。若
low[v] ≤ dfn[u]
,说明v
存在其他路径(如回边)绕过(u,v)
到达u
或其祖先,此时(u,v)
不是割边2。 - 反例验证:
- 存在回边:若
v
可通过非父边直接连回u
(如环结构),则low[v] = dfn[u]
,边(u,v)
非割边。 - 无其他路径:若
v
仅依赖父边(u,v)
,则low[v]
只能等于dfn[v]
(无法回溯),此时low[v] > dfn[u]
成立12。
- 存在回边:若
4. 示例说明
假设图结构为1-2-3
(链式无环):
- 访问顺序:
dfn[1]=1
,dfn[2]=2
,dfn[3]=3
。 - 计算
low
值:- 节点3无子节点,
low[3]=3
。 - 回溯节点2时,
low[2]=min(low[2],low[3])=3
。 - 比较
low[3]=3 > dfn[2]=2
,故边(2,3)
是割边2。
- 节点3无子节点,
总结
low[v] > dfn[u]
的设计确保了算法能精确识别那些唯一连通子树与祖先的边,其本质是通过DFS动态维护的可达性信息来验证边的不可替代性12。
割点判定法则
若
不是搜索树上的根节点,则当且仅当搜索树上存在
的一个子节点
,满足
特别地,若
是搜索树上的根节点,则当且仅当
至少存在两个子节点满足上述条件时,
时割点。
一、割点判定条件 low[v] >= dfn[u]
的详细释义
1. 条件核心逻辑
-
low[v]
:表示节点v
及其子树不经过父节点u
能访问到的最早祖先的dfn
值(DFS序)。 -
dfn[u]
:节点u
在DFS遍历中被访问的时间戳。 - 判定意义:若
low[v] >= dfn[u]
,说明v
及其子树无法绕过u
访问u
的祖先。删除u
后,v
所在分支将与图的其余部分断开,故u
为割点。
二、等号与不等号的应用场景
1. 等号成立(low[v] = dfn[u]
)
- 含义:
v
能通过回边访问到u
自身,但无法到达u
的祖先。 - 示例:
此时graph LR A[u] --> B[v] B --> C[...] C -.回边.-> A[u] %% v的子树存在指向u的回边
low[v] = dfn[u]
,删除u
后v
的子树被隔离(尽管能连回u
,但u
已被删)。
2. 不等号成立(low[v] > dfn[u]
)
- 含义:
v
及其子树仅能访问到u
的后代节点(无法到达u
或其祖先) - 示例:
graph LR u --> v v --> w w -.回边.-> v %% 回边指向v的后代w
此时 low[v] = dfn[w] > dfn[u]
,删除 u
后 v
的子树完全孤立。
三、正确性证明
1. 连通性隔离原理
- 若
low[v] >= dfn[u]
,则v
到u
祖先的所有路径均需经过u
。 - 删除
u
后:v
所在分支与原图剩余部分无连通路径(因DFS树无横叉边)。- 图至少分裂为
v
的子树和其他连通块。
2. 与根节点的差异
- 非根节点:依赖子树独立性(
low[v] >= dfn[u]
即表明隔离)。 - 根节点:需至少两棵子树(删除后子树间独立)。
3. 父边更新的无害性
- 允许通过父边更新
low[u]
(如low[u] = min(low[u], dfn[fa])
),但此操作只会降低low[u]
值,使low[v] >= dfn[u]
更难成立,不会导致漏判割点。
四、对比割边判定
判定目标 | 条件 | 父边处理 | 关键差异 |
---|---|---|---|
割点 | low[v] >= dfn[u] | 无需排除 | 依赖子树独立性 |
割边 | low[v] > dfn[u] | 必须排除 | 要求严格单向连通(无回边) |
割边严格性解释:若
low[v] = dfn[u]
(v
能连回u
),删除边(u,v)
后v
仍与u
连通,故非桥;而割点删除后v
连回u
也无效(因u
消失)
参考程序
割边。给出题目:luogu.T103481 【模板】割边
#include<iostream>
using namespace std;
const int N=6e5+5;
struct edge{ int next,to;}e[N];
int head[N],idx=1;
int dfn[N],low[N],t;
int n,m;
bool bridge[N];
inline int read()
{
int f=1,x=0;
char c=getchar();
while(c<'0'||c>'9') { if(c=='-') f=-1; c=getchar(); }
while(c>='0'&&c<='9') { x=(x<<1)+(x<<3)+(c^48); c=getchar(); }
return x*f;
}
void write(int x)
{
if(x<0) putchar('-'),x=-x;
if(x>9) write(x/10);
putchar(x%10+'0');
}
inline void add(int u,int v){e[++idx]={head[u],v};head[u]=idx;}
void tarjan(int u,int last_e)
{
dfn[u]=low[u]=++t;
for(int i=head[u];i;i=e[i].next)
{
int v=e[i].to;
if(!dfn[v])
{
tarjan(v,i);
low[u]=min(low[u],low[v]);
if(low[v]>dfn[u]) bridge[i]=bridge[i^1]=true;
}
else if(i!=(last_e^1)) low[u]=min(low[u],dfn[v]);
}
}
int main()
{
n=read(),m=read();
for(int i=1,u,v;i<=m;i++)
scanf("%d%d",&u,&v),add(u,v),add(v,u);
for(int i=1;i<=n;i++)
if(!dfn[i]) tarjan(i,0);
int ans=0;
for(int i=2;i<=idx;i+=2)
if(bridge[i]) ans++;
write(ans),putchar('\n');
return 0;
}
割点。给出题目:luogu.P3388 【模板】割点(割顶)
#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;
const int N=2e5+5;
struct edge{ int next,to;}e[N];
int head[N],idx;
vector<int> cut;
int n,m;
int dfn[N],low[N],t,c,root;
bool mark[N];
inline int read()
{
int f=1,x=0;
char c=getchar();
while(c<'0'||c>'9') { if(c=='-') f=-1; c=getchar(); }
while(c>='0'&&c<='9') { x=(x<<1)+(x<<3)+(c^48); c=getchar(); }
return x*f;
}
void write(int x)
{
if(x<0) putchar('-'), x=-x;
if(x>9) write(x/10);
putchar(x%10+'0');
}
inline void add(int u,int v){ e[++idx]={head[u],v};head[u]=idx; }
void tarjan(int u)
{
dfn[u]=low[u]=++t;
int child=0;
for(int i=head[u];i;i=e[i].next)
{
int v=e[i].to;
if(!dfn[v])
{
tarjan(v);
low[u]=min(low[u],low[v]);
if(low[v]>=dfn[u])
{
child++;
if(u!=root||child>1) mark[u]=1;
}
}
else low[u]=min(low[u],dfn[v]);
}
}
int main()
{
n=read(),m=read();
for(int i=1,u,v;i<=m;i++)
{
u=read(),v=read();
if(u==v) continue;
add(u,v),add(v,u);
}
for(int i=1;i<=n;i++)
if(!dfn[i]) root=i,tarjan(i);
for(int i=1;i<=n;i++) if(mark[i]) cut.push_back(i);
sort(cut.begin(),cut.end());
write(cut.size());putchar('\n');
for(int i:cut) write(i),putchar(' ');
return 0;
}
细节实现
我们需要注意这么几个问题。这几个细节关乎到代码的实现,非常重要。
割点的代码细节
1.标记数组的逻辑(什么时候是割点)
- 当节点
u
满足low[v] >= dfn[u]
时,说明u
是割点 - 对于根节点(
u == root
),需要至少有两个子节点(child > 1
)才会被标记为割点 - 对于非根节点,只要满足上述条件就会被标记
体现在代码里就是这一句:
if(u != root || child > 1) mark[u] = 1;
进一步,对节点类型的讨论:
对于非根节点:
- 若
u ≠ root
,只要满足low[v] ≥ dfn[u]
就直接标记为割点 - 逻辑依据:非根节点存在子图无法绕其回溯更高祖先,移除会导致图分裂
根节点特殊规则:
u == root
时,必须满足child > 1
才标记- 原因:根节点无祖先,仅当有多个子树时移除才会分裂图
- 单子树场景(如星型图中心)非割点1
2.child变量更新逻辑
首先回味一下割点的判定法则,我们知道,每一个子树都是要满足一个式子的。所以更新逻辑会很考究。
标准位置的意义
- 只有当发现
u
的子节点v
满足割点条件(low[v] >= dfn[u]
)时,才会计数child
- 这精确统计了会导致图分裂的子树数量(关键区别)
不同位置的意义
- 若移到
if(!dfn[v])
之后:child++; // 错误!会统计所有子节点而非关键子树 if(low[v] >= dfn[u]) {...}
会错误统计所有DFS访问的子节点,包括不满足割点条件的子树
- 若移到
if(low[v] >= dfn[u])
之前:if(low[v] >= dfn[u]) { child++; // 逻辑等价但易读性差 if(u != root || child > 1)... }
虽结果相同,但破坏了"先判断条件再计数"的语义逻辑。事实上,这样其实是正确的。
根节点判定的本质
通过child > 1
判断根节点是否为割点时:
- 必须仅统计满足
low[v] >= dfn[u]
的子树; - 普通子节点(如通过回边连通的)不应被计入
割边的代码细节
1.边的存贮遍历问题
首先,这里是无向图,所以会有反向边。这里考虑是将索引从1开始遍历,第一条边存储在 的位置,这样方便进行一些比较灵活的操作。给出三份代码片段:
if(low[v]>dfn[u]) bridge[i]=bridge[i^1]=true;
这是标记桥的bridge[i] = bridge[i^1] = true
使用 ^1
操作是为了同步标记双向边。这是由无向图的存储方式决定的:
-
技术背景
无向图的边通常被存储为两条有向边(如u→v
和v→u
)。代码中常用邻接表+成对下标法(例如idx
从0或2开始,i^1
即可得到反向边索引)1 -
^1的作用
- 当
i
是偶数时,i^1 = i+1
(如2^1=3
) - 当
i
是奇数时,i^1 = i-1
(如3^1=2
)
这样能快速定位到当前边的反向边2
- 当
-
必要性
若不标记反向边,可能导致:- 后续遍历时误判未被标记的边
- 割边统计遗漏(因为算法需要确认双向边均不可达)
2.在更新过的边之间抉择的问题(走还是不走)& 与割点算法的对比
这个问题是这样的:与割点不同,else if(i!=(last_e^1)) low[u]=min(low[u],dfn[v]);与割点在这里使用else不同,这里使用elseif分支。笔者这里着重分析一下。
在Tarjan割边算法中,else if(i!=(last_e^1))
分支与割点算法的区别及其作用如下:
-
核心逻辑差异
- 割边算法必须排除父边回退的情况,通过
i!=(last_e^1)
确保不处理当前边的反向边(即父边) - 割点算法无需此判断,因为割点判定依赖子树数量而非单一边的性质
- 割边算法必须排除父边回退的情况,通过
-
处理场景
该条件专门处理以下情况:- 当遇到已访问节点
v
时,需判断是否通过非父边形成环 - 若
v
是通过父边访问的(即i == (last_e^1)
),则跳过更新,避免错误降低low[u]
值
- 当遇到已访问节点
-
必要性分析
- 若无此限制,父边会被误判为回边,导致
low[u]
被错误更新为dfn[v]
- 这将破坏
low[v]>dfn[u]
的割边判定条件,可能漏判真正的桥
- 若无此限制,父边会被误判为回边,导致
问题又来了:为什么割点不用排除父边回退的情况?
一、割点判定原理
-
核心条件依赖子树连通性
割点判定依据是:子节点v
能否不经过u
回到更早祖先。通过条件low[v] >= dfn[u]
判断:- 若成立,说明
v
及其子树无法绕过u
访问祖先,删除u
会导致图分裂,此时u
是割点。 - 父边回退更新
low[u]
不影响此逻辑(见下文分析)。
- 若成立,说明
-
父边回退更新的无害性(这是最关键的一点)
当通过父边更新low[u]
时(即else if
分支不排除父边),需分两种情况:- 非根节点:父节点
f
的dfn[f]
必然小于当前节点u
的dfn[u]
,因此更新low[u] = min(low[u], dfn[f])
只会降低low[u]
值,使条件low[v] >= dfn[u]
更难成立(降低误判割点风险)。 - 根节点:根无父节点,无需处理此情况。
- 非根节点:父节点
二、对比割边判定的敏感性
- 割边判定依赖严格单向性
割边条件low[v] > dfn[u]
要求:子节点v
完全无法绕过边u→v
回到u
或其祖先。- 若允许通过父边(
u→v
的反向边)更新low[v]
,会将low[v]
错误降低至dfn[u]
或更小,导致low[v] > dfn[u]
不成立,从而漏判真正的桥。 - 因此割边算法必须用
i != (last_e^1)
排除父边。
- 若允许通过父边(
三、关键差异总结
算法类型 | 判定条件 | 父边回退的影响 | 是否需排除父边 |
---|---|---|---|
割点 | low[v] >= dfn[u] | 降低low[u] ,使条件更严格¹ | 否 |
割边 | low[v] > dfn[u] | 错误降低low[v] ,导致漏判 | 是 |
降低
low[u]
可能使low[v] >= dfn[u]
更难成立,减少误标割点的可能,但不会漏判割点。
四、示例说明
假设图结构 A-B-C
,A
为根:
- 割点场景:
- 节点
B
的子节点C
通过回边直接连向A
(非父边)。 low[C] = dfn[A] = 1
,而dfn[B] = 2
,满足low[C] < dfn[B]
→B
不是割点。- 若
C
通过父边B→C
更新low[C]
,因dfn[B] > dfn[A]
,实际更新无影响。
- 节点
- 割边场景:
- 若未排除边
B→C
的父边(即C→B
),计算low[C]
时可能错误取dfn[B]=2
,导致low[C] = 2 ≯ dfn[B] = 2
,从而漏判桥B-C
。
- 若未排除边
3.如何处理父边回退的情况
这里常用两种方式,一种是以判断节点为依据,一种是以判断边为依据。
在Tarjan算法中,处理无向图时避免走回边的两种方式存在本质差异,具体分析如下:
1. 正解逻辑分析
else if(i != (E^1)) low[u] = min(low[u], dfn[v]);
- 参数含义:
E
是当前边的编号(链式前向星存储),E^1
通过异或操作获取反向边编号。 - 核心作用:直接排除当前边的反向边,确保不会通过反向边更新
low[u]
。 - 正确性保证:
- 无向图的每条边存储为两条有向边(如
(u,v)
和(v,u)
),编号连续且互为i
和i^1
。 - 通过
i != (E^1)
严格避免反向边干扰,仅允许通过其他非父子边(如跨子树回边)更新low[u]
。
- 无向图的每条边存储为两条有向边(如
2. 错解逻辑分析
else if(v != fa) low[u] = min(low[u], dfn[v]);
- 参数含义:
fa
是父节点编号,通过v != fa
排除父节点。 - 问题根源:
- 无法处理平行边:若存在多条
u-v
边(平行边),v != fa
会错误地允许通过其他平行边更新low[u]
,导致low
值计算错误。 - 漏判反向边:仅排除父节点,但未排除反向边本身,可能通过反向边错误更新
low[u]
,影响割边判定。
- 无法处理平行边:若存在多条
3. 关键差异对比
维度 | 正解(边编号判断) | 错解(父节点判断) |
---|---|---|
处理对象 | 精确排除当前边的反向边 | 仅排除父节点 |
平行边兼容 | 是(每条边独立编号) | 否(无法区分平行边) |
回边判定 | 严格(仅允许非反向边) | 宽松(可能误判反向边) |
适用场景 | 通用无向图(含平行边) | 仅限树形结构(无平行边) |
4. 错误案例说明
假设图存在平行边u-v
(边编号i=2
和i=4
):
- 正解:处理边
i=2
时,E^1=3
,不会与i=4
冲突,正确排除反向边。 - 错解:若
fa=v
,i=4
的v
仍满足v != fa
,错误更新low[u]
,导致low[u]
被低估。
5. 结论
正解通过边编号异或精准控制回边排除,而错解因依赖父节点判断,无法处理平行边和反向边干扰,导致割边判定失效24。这是错解在复杂图结构中错误的核心原因。
我们还需要知道,这两种语句什么是时候适用。这非常重要。
在图论算法中,这两种判断回边的语句分别适用于不同的存储方式和场景:
-
i != (E^1)
的适用场景
该语句用于邻接表成对存储法(常见于网络流、无向图处理),其中:- 每条无向边被拆分为两条有向边(如
u→v
存为边i=2
,v→u
存为i=3
) E^1
通过异或操作快速定位反向边索引(如2^1=3
,3^1=2
)- 作用:排除父边干扰,避免通过父边错误更新
low
值
- 每条无向边被拆分为两条有向边(如
-
v != fa
的适用场景
该语句用于显式记录父节点的传统DFS实现,其中:fa
直接存储父节点编号- 作用:防止通过父节点回溯更新
low
,确保只处理非父边的回边(如横向边或跨子树边)
关键区别对比
条件形式 | 适用存储方式 | 优势 | 典型算法 |
---|---|---|---|
i != (E^1) | 成对下标邻接表 | 节省空间,快速定位反向边 | 网络流、割边算法 |
v != fa | 显式父节点记录 | 逻辑直观,便于扩展其他判定 | 传统DFS、割点算法 |
实际选择取决于图的存储实现:成对存储优先用异或判断,显式父节点记录则用v != fa
总结归纳
本文主要是归纳tarjan算法在割边割点的设计。十分精彩,但是细节十分多。不得不感叹tarjan教授本人巧夺天工的算法设计,层层相扣,美不胜收。本文花费笔者一上午的时间查阅资料,整合信息,判断疏漏,编写代码,可谓是真心佩服与这么流畅的算法设计,尽管逻辑复杂,但本文应该是讲得十分详细的。