专题 无向图的割点与割边

概念解释

a7175754-4ad9-4ea4-bb3e-9c02b533ee22

定义

         首先给出无向图 G=(V,E) ,

        割点(割顶) 

        对于 x\in V ,从图中删去 x 及其关联边后,整个图不再联通,称 x 为 G 的割点(割顶)。

        割边(桥)

        对于 e\in E ,从图中删去 e 及其关联边后,整个图不再联通,称 e 为 G 的割边(桥)。

        时间戳

        遍历顺序。

        追溯值

        设 subtree(u) 为搜索树上以 u 为根节点的子树。

        定义为以下节点中的时间戳的最小值。

  • subtree(u) 中的节点
  • 通过一条不在 subtree(u) 上的边,能够到达 subtree(u) 的节点。

        这自然而然地有着两种更新方式。非常重要!!!

        根据定义,初始化为当前节点的时间戳。

  • 若在搜索树上,u 是 v 的父亲,那么  low[u]=min(low[u],low[v]) ;
  • 若无向边 (u,v) 不在搜索树上,那么 low[u]=min(low[u],dfn[v]) 。

算法分析

割边判定法则

        无向边 (u,v) 是桥,当且仅当 u 是 v 的父亲,满足

        dfn[u]<low[v] 

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]=1dfn[2]=2dfn[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。

总结

low[v] > dfn[u]的设计确保了算法能精确识别那些‌唯一连通子树与祖先‌的边,其本质是通过DFS动态维护的可达性信息来验证边的不可替代性‌12。

割点判定法则

        若 u 不是搜索树上的根节点,则当且仅当搜索树上存在 u 的一个子节点 v,满足

   low[v]\geq dfn[u]

        特别地,若  u 是搜索树上的根节点,则当且仅当 u 至少存在两个子节点满足上述条件时,u 时割点。

一、割点判定条件 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
  • 这精确统计了‌会导致图分裂的子树数量‌(关键区别)‌
        ‌不同位置的意义
  1. 若移到if(!dfn[v])之后:
    child++;  // 错误!会统计所有子节点而非关键子树
    if(low[v] >= dfn[u]) {...}
    

    会错误统计所有DFS访问的子节点,包括不满足割点条件的子树

  2. 若移到if(low[v] >= dfn[u])之前:
    if(low[v] >= dfn[u]) {
        child++;  // 逻辑等价但易读性差
        if(u != root || child > 1)...
    }
    

    虽结果相同,但破坏了"先判断条件再计数"的语义逻辑。事实上,这样其实是正确的。

            根节点判定的本质
             通过child > 1判断根节点是否为割点时:
    • 必须‌仅统计满足low[v] >= dfn[u]的子树;
    • 普通子节点(如通过回边连通的)不应被计入

    割边的代码细节

    1.边的存贮遍历问题

            首先,这里是无向图,所以会有反向边。这里考虑是将索引从1开始遍历,第一条边存储在idx=2 的位置,这样方便进行一些比较灵活的操作。给出三份代码片段:

    if(low[v]>dfn[u]) bridge[i]=bridge[i^1]=true;

            这是标记桥的bridge[i] = bridge[i^1] = true 使用 ^1 操作是为了‌同步标记双向边‌。这是由无向图的存储方式决定的:

    1. 技术背景
      无向图的边通常被存储为两条有向边(如u→vv→u)。代码中常用邻接表+成对下标法(例如idx从0或2开始,i^1即可得到反向边索引)‌1

    2. ^1的作用

      • i是偶数时,i^1 = i+1(如2^1=3
      • i是奇数时,i^1 = i-1(如3^1=2
        这样能快速定位到当前边的反向边‌2
    3. 必要性
      若不标记反向边,可能导致:

      • 后续遍历时误判未被标记的边
      • 割边统计遗漏(因为算法需要确认双向边均不可达)

    2.在更新过的边之间抉择的问题(走还是不走)& 与割点算法的对比

            这个问题是这样的:与割点不同,else if(i!=(last_e^1)) low[u]=min(low[u],dfn[v]);与割点在这里使用else不同,这里使用elseif分支。笔者这里着重分析一下。

            在Tarjan割边算法中,else if(i!=(last_e^1))分支与割点算法的区别及其作用如下:

    1. 核心逻辑差异

      • 割边算法必须排除‌父边回退‌的情况,通过i!=(last_e^1)确保不处理当前边的反向边(即父边)‌
      • 割点算法无需此判断,因为割点判定依赖子树数量而非单一边的性质
    2. 处理场景
      该条件专门处理以下情况:

      • 当遇到已访问节点v时,需判断是否通过非父边形成环
      • v是通过父边访问的(即i == (last_e^1)),则跳过更新,避免错误降低low[u]值‌
    3. 必要性分析

      • 若无此限制,父边会被误判为回边,导致low[u]被错误更新为dfn[v]
      • 这将破坏low[v]>dfn[u]的割边判定条件,可能漏判真正的桥‌

             问题又来了:为什么割点不用排除父边回退的情况?

         一、割点判定原理
    1. 核心条件依赖子树连通性
      割点判定依据是:‌子节点v能否不经过u回到更早祖先‌。通过条件 low[v] >= dfn[u] 判断:

      • 若成立,说明v及其子树无法绕过u访问祖先,删除u会导致图分裂,此时u是割点‌。
      • 父边回退更新low[u]不影响此逻辑(见下文分析)。
    2. 父边回退更新的无害性(这是最关键的一点)
      当通过父边更新low[u]时(即else if分支不排除父边),需分两种情况:

      • 非根节点‌:父节点fdfn[f]必然小于当前节点udfn[u],因此更新 low[u] = min(low[u], dfn[f]) 只会降低low[u]值,使条件 low[v] >= dfn[u] ‌更难成立‌(降低误判割点风险)‌。
      • 根节点‌:根无父节点,无需处理此情况。
    二、对比割边判定的敏感性
    1. 割边判定依赖严格单向性
      割边条件 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-CA为根:

    • 割点场景‌:
      • 节点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)),编号连续且互为ii^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=2i=4):

    • 正解‌:处理边i=2时,E^1=3,不会与i=4冲突,正确排除反向边。
    • 错解‌:若fa=vi=4v仍满足v != fa,错误更新low[u],导致low[u]被低估。
    5. 结论

            正解通过‌边编号异或‌精准控制回边排除,而错解因依赖父节点判断,无法处理平行边和反向边干扰,导致割边判定失效24。这是错解在复杂图结构中错误的核心原因。

            我们还需要知道,这两种语句什么是时候适用。这非常重要。

            在图论算法中,这两种判断回边的语句分别适用于不同的存储方式和场景:

    1. i != (E^1) 的适用场景
      该语句用于‌邻接表成对存储法‌(常见于网络流、无向图处理),其中:

      • 每条无向边被拆分为两条有向边(如u→v存为边i=2v→u存为i=3
      • E^1通过异或操作快速定位反向边索引(如2^1=33^1=2
      • 作用:排除父边干扰,避免通过父边错误更新low值‌
    2. v != fa 的适用场景
      该语句用于‌显式记录父节点‌的传统DFS实现,其中:

      • fa直接存储父节点编号
      • 作用:防止通过父节点回溯更新low,确保只处理非父边的回边(如横向边或跨子树边)‌

    ‌        关键区别对比

    条件形式适用存储方式优势典型算法
    i != (E^1)成对下标邻接表节省空间,快速定位反向边网络流、割边算法
    v != fa显式父节点记录逻辑直观,便于扩展其他判定传统DFS、割点算法

            实际选择取决于图的存储实现:成对存储优先用异或判断,显式父节点记录则用v != fa

    总结归纳

            本文主要是归纳tarjan算法在割边割点的设计。十分精彩,但是细节十分多。不得不感叹tarjan教授本人巧夺天工的算法设计,层层相扣,美不胜收。本文花费笔者一上午的时间查阅资料,整合信息,判断疏漏,编写代码,可谓是真心佩服与这么流畅的算法设计,尽管逻辑复杂,但本文应该是讲得十分详细的。

    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值