双连通分量
边双连通分量
在图论中,边双连通分量(Edge Biconnected Component,通常简称 e-BCC)描述的是一种具有高度可靠性的网络拓扑结构。
定义
简单来说,边双连通分量是指不包含任何“桥”(割边)的极大连通子图。这意味着在这个子图内部,无论你切断哪一条边,子图内的所有节点依然保持连通。
性质
- 在同一个 e-BCC 中,任意两个节点之间,必定存在至少两条完全不共享任何边(边不相交)的路径。
- 这是它与点双连通分量(v-BCC)最大的区别:在点双中,割点可以同时属于多个分量;但在边双中,每一个节点都恰好属于唯一一个 e-BCC。
- 图中的“桥”不属于任何一个边双连通分量,它们的唯一作用是连接不同 e-BCC。
求法 1
采用两遍 DFS。
- 第一遍 DFS(跑 Tarjan 找桥):
- 遍历图,维护 \(dfn\) 和 \(low\) 数组。
- 当发现某条边 \((u, v)\) 满足 \(low[v] > dfn[u]\) 时,说明 \(v\) 往下走绝对绕不回 \(u\) 的上方,这条边就是“桥”。
- 用一个布尔数组将这条边特殊标记下来。
- 第二遍 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)。
点双连通分量
定义
点双连通分量的定义是一个极大点双连通子图,而点双连通图的定义存在争议。
一种定义是“图中任意两不同点之间都有至少两条点不重复的路径”,而另外一种是“不存在割点的图”,这两种定义存在细微差别,这里采用后者。
性质
- 在同一个点双连通分量中,任意两个节点之间,必定存在至少两条完全不共享任何点(除端点)的路径。特别的,两个点的点双连通分量除外。
- 在点双中,割点可以同时属于多个分量。
- 如果一个点双有两个不同方向的“最高分支”,由于无向图 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;
}
easy42
OI / C++ / Algorithm
懒惰者出局。
OI / C++ / Algorithm
懒惰者出局。

浙公网安备 33010602011771号