双连通分量

边双连通分量

在图论中,边双连通分量(Edge Biconnected Component,通常简称 e-BCC)描述的是一种具有高度可靠性的网络拓扑结构。

定义

简单来说,边双连通分量是指不包含任何“桥”(割边)的极大连通子图。这意味着在这个子图内部,无论你切断哪一条边,子图内的所有节点依然保持连通。

性质

  1. 在同一个 e-BCC 中,任意两个节点之间,必定存在至少两条完全不共享任何边(边不相交)的路径。
  2. 这是它与点双连通分量(v-BCC)最大的区别:在点双中,割点可以同时属于多个分量;但在边双中,每一个节点都恰好属于唯一一个 e-BCC。
  3. 图中的“桥”不属于任何一个边双连通分量,它们的唯一作用是连接不同 e-BCC。

求法 1

采用两遍 DFS。

  1. 第一遍 DFS(跑 Tarjan 找桥):
  • 遍历图,维护 \(dfn\) 和 \(low\) 数组。
  • 当发现某条边 \((u, v)\) 满足 \(low[v] > dfn[u]\) 时,说明 \(v\) 往下走绝对绕不回 \(u\) 的上方,这条边就是“桥”。
  • 用一个布尔数组将这条边特殊标记下来。
  1. 第二遍 DFS(避开桥找分量):
  • 清空访问标记,再次遍历所有节点。
  • 执行普通的 DFS 或 BFS,遇到被标记为“桥”的边,绝对不走。
  • 这样一来,每一次搜索能走到的所有节点,就构成了一个边双连通分量。

代码(AI 注释)

注意,由于是双向边,所以要存两遍正反。但是显然不能连续走两遍,所以要打上边的标号。

洛谷 P8436 的代码。

#include<bits/stdc++.h>
using namespace std;
#define int long long

int n, m; // n: 节点数, m: 边数
int pcnt; // 边的编号计数器,用于给每条边分配唯一编号

// 边的结构体
struct edge{
	int vv, cnt; // vv: 边的目标节点, cnt: 边的唯一编号
};
vector<edge> v[500005]; // 邻接表存图

// Tarjan 算法相关的变量
int dfn[500005]; // dfn[x]: 节点 x 在 DFS 中被访问的次序(时间戳)
int low[500005]; // low[x]: 节点 x 先沿若干条 DFS 树边向下,通过最多一条返祖边能到达的最小 dfn 值
int cnt;         // 用于生成时间戳

bool b[2000005]; // b[i]=1 表示编号为 i 的边是一条“桥”(割边)

// 提取连通分量相关的变量
int bcc[500005];      // bcc[x] 记录节点 x 属于哪一个边双连通分量
int gcnt;             // 边双连通分量的总数量
vector<int> h[500005]; // h[i] 存储第 i 个边双连通分量包含的所有节点

// 第一遍 DFS:Tarjan 算法核心,用于寻找图中的所有“桥”
// x: 当前节点, f: 从上一个节点走到当前节点所经过的【边的编号】
void tarjan(int x, int f){
	// 初始化时间戳和追溯值
	dfn[x] = low[x] = ++cnt; 
	
	// 遍历当前节点 x 的所有出边
	for(int i = 0; i < v[x].size(); i++){
		// 1. 如果这条边是刚刚走过来的那条边,直接跳过,防止在无向图中原路返回
		if(v[x][i].cnt == f) continue; 
		
		// 2. 如果目标节点还没有被访问过(这是一条树边)
		if(!dfn[v[x][i].vv]){
			// 继续往下递归 DFS
			tarjan(v[x][i].vv, v[x][i].cnt);
			
			// 用子节点能到达的最小时间戳更新自己的 low 值
			low[x] = min(low[x], low[v[x][i].vv]);
			
			// 【核心判定】:如果子节点 v 怎么绕都无法回到 x 或 x 的祖先
			// 说明 x 到 v 的这条边是唯一的通道,因此这条边就是“桥”
			if(low[v[x][i].vv] > dfn[x]){
				b[v[x][i].cnt] = 1; // 将该边标记为桥
			}
		}
		// 3. 如果目标节点已经被访问过了(这是一条返祖边)
		else {
			// 用祖先节点的时间戳更新自己的 low 值
			low[x] = min(low[x], dfn[v[x][i].vv]);
		}
	}
}

// 第二遍 DFS:避开所有桥,提取出边双连通分量
void dfs(int x){
	bcc[x] = gcnt;           // 标记节点 x 属于当前的连通分量 gcnt
	h[gcnt].push_back(x);    // 将节点 x 加入当前分量的集合中
	
	for(int i = 0; i < v[x].size(); i++){
		// 如果这条边是桥,或者目标节点已经被归入某个分量了,就不走这条边
		if(b[v[x][i].cnt] || bcc[v[x][i].vv]) continue;
		
		// 否则,说明目标节点和当前节点属于同一个边双连通分量,继续 DFS
		dfs(v[x][i].vv);
	}
}

signed main(){
	// 读入节点数和边数
	cin >> n >> m;
	for(int i = 1; i <= m; i++){
		int u, vv;
		cin >> u >> vv;
		++pcnt; // 为每条无向边分配一个全局唯一编号
		// 无向图建边,双向都存,但赋予相同的边编号 pcnt
		v[u].push_back({vv, pcnt});
		v[vv].push_back({u, pcnt});
	}
	
	// 1. 跑 Tarjan 找桥
	// 考虑到图可能不连通(有多个连通块),所以要遍历所有节点
	for(int i = 1; i <= n; i++){
		if(!dfn[i]) tarjan(i, 0); // 0 表示起点没有 incoming edge
	}
	
	// 2. 跑普通 DFS 提取分量
	// 同样遍历所有节点,确保所有连通块都被处理到
	for(int i = 1; i <= n; i++){
		if(!bcc[i]){
			++gcnt;  // 开启一个新的边双连通分量
			dfs(i);
		}
	}
	
	// 3. 输出结果
	cout << gcnt << endl; // 输出边双连通分量的总数
	for(int i = 1; i <= gcnt; i++){
		cout << h[i].size() << " "; // 输出该分量内的节点数
		for(int j = 0; j < h[i].size(); j++){
			cout << h[i][j] << " "; // 输出该分量内的所有节点编号
		}
		cout << endl;
	}
	
	return 0;
}

求法 2

一个连通块,考虑搜出一颗搜索树。那么每条边要么是树边,要么是非树边(返祖边)。(此时已经给每条边定好方向)

可以发现,从这个连通块的“树根”走下来,每个深度较浅的点,可以到达深度较深的点。

如果这个连通块是一个边双连通图,那么里面不会存在割边,故对于任意一条树边 \((u,v)\) 来说,\(low[v] \le dfn[u]\)。即 \(v\) 的子树当中,有至少一个点可以通过非树边(返祖边)到达 \(u\) 或 \(u\) 的祖先。所以 \(v\) 可以先沿树边向下,再沿非树边(返祖边)向上,以此类推,到达“树根”,从而到达每个点,相当于强连通分量。

我们发现,边双连通分量和强连通分量非常类似,直接按求强连通分量的方法求即可(需要注意,它只是算法形式类似 SCC Tarjan,并不是把原无向图直接当作有向图求 SCC)。

点双连通分量

定义

点双连通分量的定义是一个极大点双连通子图,而点双连通图的定义存在争议。

一种定义是“图中任意两不同点之间都有至少两条点不重复的路径”,而另外一种是“不存在割点的图”,这两种定义存在细微差别,这里采用后者。

性质

  1. 在同一个点双连通分量中,任意两个节点之间,必定存在至少两条完全不共享任何点(除端点)的路径。特别的,两个点的点双连通分量除外。
  2. 在点双中,割点可以同时属于多个分量。
  3. 如果一个点双有两个不同方向的“最高分支”,由于无向图 DFS 不存在横叉边,这两部分只能通过共同祖先连接,那么就有共同祖先,产生矛盾。故点双连通分量有一个“最高点”。

求法

  • 遍历图,维护 \(dfn\) 和 \(low\) 数组。
  • 当发现某条边 \((u, v)\) 满足 \(low[v] \ge dfn[u]\) 时,说明 \(v\) 往下走绝对绕不回 \(u\) 的上方,\(u\) 点为一个割点(在除 \(u\) 为树根的情况外时,当它为树根时,则只有当它拥有至少两个子树时才是割点)。
  • 弹出 \(v\) 的子树在栈中剩余的点,但是注意不能弹出割点 \(u\),因为割点可能属于多个连通分量。

代码(AI 注释)

洛谷 P8435 的代码。

#include<bits/stdc++.h>
using namespace std;
#define int long long

int n,m;

// 无向图中的一条边
struct edge{
	int vv;   // 这条边连接到的另一个点
	int cnt;  // 边的编号,用来区分重边
};

vector<edge>v[500005];

// dfn[x]:x 被 DFS 第一次访问的时间戳
// low[x]:从 x 的 DFS 子树出发,最多经过一条返祖边
//         能到达的最小 dfn
int dfn[500005],low[500005];

// cnt:当前 DFS 时间戳
// scc_cnt:点双连通分量数量
// 虽然变量名叫 scc_cnt,但这里实际上统计的是点双,不是强连通分量
int cnt,scc_cnt;

// 点栈
// 当前还没有被完整划分进点双的点暂时放在这里
stack<int>st;

// d[i]:第 i 个点双连通分量中有哪些点
vector<int>d[500005];

// tarjan(x,f)
// x:当前访问的点
// f:DFS 树中从父亲走到 x 使用的那条边的编号
//
// 这里不能直接传父亲节点,因为无向图可能有重边。
// 如果只判断 "v == fa",会把父子之间的所有重边都跳过。
// 所以应该记录父边编号,只跳过真正走过来的那一条边。
void tarjan(int x,int f){
	// 第一次访问 x
	// dfn 和 low 初始都设为当前时间戳
	dfn[x]=low[x]=++cnt;

	// x 暂时加入点栈
	st.push(x);

	// 枚举 x 的所有邻边
	for(int i=0;i<v[x].size();i++){
		// 如果这条边就是 DFS 时从父亲走过来的边
		// 直接跳过
		//
		// 注意这里只跳过"这一条边"
		// 如果 x 和父亲之间有另一条重边,那条边仍然需要处理
		if(v[x][i].cnt==f) continue;

		// 当前邻点
		int y=v[x][i].vv;

		// y 还没有被访问
		// 说明 (x,y) 是 DFS 树边
		if(!dfn[y]){
			// 继续向下 DFS
			// 并把当前边编号作为 y 的父边
			tarjan(y,v[x][i].cnt);

			// y 的子树搜索完成后
			// 用 low[y] 更新 low[x]
			low[x]=min(low[x],low[y]);

			// 点双 Tarjan 的核心判断:
			//
			// low[y] >= dfn[x]
			//
			// 表示 y 的 DFS 子树不能通过返祖边
			// 到达 x 的严格祖先。
			//
			// 因此从栈中一直弹到 y,
			// 再加上 x,
			// 就构成了一个点双连通分量。
			if(low[y]>=dfn[x]){
				++scc_cnt;

				// 把从当前栈顶到 y 的所有点弹出来
				// 它们与 x 一起组成一个点双
				while(!st.empty()){
					int p=st.top();
					st.pop();

					d[scc_cnt].push_back(p);

					// 防止栈已经空了
					if(st.empty()) break;

					// y 是当前 DFS 子树的根
					// 弹到 y 就停止
					if(p==y) break;
				}

				// x 本身不能在这里弹出
				// 因为 x 可能同时属于多个点双
				//
				// 例如:
				// 1-2-3
				// 点 2 可以同时属于 {1,2} 和 {2,3}
				//
				// 所以这里只把 x 加入当前点双
				d[scc_cnt].push_back(x);
			}
		}
		else{
			// y 已经访问过
			// 当前边不是父边,因此在无向 DFS 中可以看作返祖边
			//
			// 用 dfn[y] 更新 low[x]
			//
			// 这里不能写 low[y],
			// 因为对于返祖边 x -> y,
			// 我们真正直接到达的是 y 本身
			low[x]=min(low[x],dfn[y]);
		}
	}

	// 如果 x 是孤立点,即没有任何边
	// 那么代码把它单独作为一个点双
	//
	// 是否把孤立点算作点双,要看题目采用的定义。
	if(v[x].size()==0){
		d[++scc_cnt].push_back(x);
	}

	// f==0 表示 x 是这一棵 DFS 树的根
	//
	// 普通情况下,x 会一直留在栈里,
	// 用于参加父层形成的点双。
	//
	// DFS 根没有父亲,
	// 最终需要把根从栈中弹出去。
	if(f==0) st.pop();
}

signed main(){
	cin>>n>>m;

	// 输入无向图
	for(int i=1;i<=m;i++){
		int u,vv;
		cin>>u>>vv;

		// 自环直接忽略
		if(u==vv) continue;

		// 无向边需要存两个方向
		// 两个方向使用同一个边编号 i
		//
		// 这样 Tarjan 时可以准确判断哪一条是父边
		v[u].push_back({vv,i});
		v[vv].push_back({u,i});
	}

	// 图可能不连通
	// 所以每个尚未访问的点都要作为新的 DFS 根
	for(int i=1;i<=n;i++){
		if(!dfn[i]){
			tarjan(i,0);
		}
	}

	// 输出点双数量
	cout<<scc_cnt<<endl;

	// 输出每一个点双
	for(int i=1;i<=scc_cnt;i++){
		cout<<d[i].size()<<" ";

		for(int j=0;j<d[i].size();j++){
			cout<<d[i][j]<<" ";
		}

		cout<<endl;
	}

	return 0;
}
posted @ 2026-08-25 21:37  easy42  阅读(41)  评论(0)    收藏  举报