无向图的双连通分量
无向图的双连通分量
一、割点和割边
-
割点:在无向连通图中,删除一个顶点以及和它相邻的所有边,图中的连通分量个数增加,则该顶点称为 割点
-
割边(桥):在无向连通图中,删除一条边,图中的连通分量个数增加,则该条边称为 割边 或者 桥
举栗子:
割点
割边
二、边双连通分量 和 点双连通分量
1.边双连通分量
边双连通图:若一个无向图的点两两间都有两条不重合的路径,那么我们就称这个无向图是 边-双连通 的。
我们看看这个定义是什么意思,任意两点都有两条不重合的路径,就是说任意点都有两条边可以到达,那么任意去掉一条边,肯定还有另一条边连接,也就是说这个图中不存在割边,所以这个图是边双连通图。
我们画个图来理解:
所谓分支就是一个子图,那么边双连通分支就是说原图中 最大 的一个 双连通分支的子图,一定是最大不然会影响结果。比较好理解,直接上图。

这个图有两个边双连通分量, 边双连通分量,就是这么多内容,我们再讲讲边双连通分量缩点。
如果将 边双连通分量 用一个点表示,那么就叫做 缩点 。
经过缩点后建的图必然不存边双连通分量,图中存在的边都不在边双连通分量中,也就是说缩点后的边都是桥。

感悟:因为桥是一条割边,也就是唯一的一条通路,其它有多条通路的点都缩成了一个点,剩下的这条边,一定是割边。
2.点双连通分量
定义:任意两条边都在一个简单环中。
就是说没有割点。还是画图吧!

这两个最大连通子图就是点双连通分支,类比边双连通分支。
也就是说 经过 缩点 后的图中的点除了只有一条边的的点都是割点。
比如都是缩点后形成的新点,它们俩都是只有一条边连向割点,而割点肯定有最少两条边,就是割点。

三、求割点和割边
算法除了栈还引入了个数组,分别是:
- 节点的 时间戳,用来 标记节点访问的先后顺序 以及 是否被访问过, 理解为自己的出生日期。
- 当前 环 里最先被访问到的节点,相当于 当前这个连通分量里的根, 理解为你家老祖宗的出生日期。
割点判定
(1) 不是根节点
在搜索过程中发现存在的一个子节点,满足, 那么是割点
(2) 是根节点
在搜索过程中发现 至少存在两个子节点 ,满足 ,那么是割点
先来看 第一种,不是根节点 ,分为以下三个式子讨论:
① 如果,如下图所示:
② 如果,如下图所示:
③ 如果,如下图所示:
综上可知,当 不是根节点 ,并且 时,说明节点是割点。
再来看 第二种: 是根节点,分两种情况讨论:
① 如果是根节点,但是它 只有一个子节点:
② 如果是根节点,但是它 至少有两个子节点:
割边判定
在搜索树上存在的一个子节点,满足,说明 无向边是 桥 (割边)
分为以下三个式子讨论:
当时:
当时:
当时:
综上,当时,才能说明边是一条割边
四、成对变换
在无向图中,求割边时,不需要考虑从子节点到父节点之间的边!!
原因如下:
为了解决这样的问题,我们使用了成对变换的办法:
对于非负整数:
当是 偶数, ^
当是 奇数, ^
这一性质经常用于图论邻接表中 边集 的存储。在具有无向边的图中把一条正反方向的边分别存储在邻接表数组的第与位置(其中是偶数),那么就可以通过 ^ 运算获得与当前边所反向的边的存储位置了(存储位置也就是这条边的编号)
如下图:
从中我们发现 成对变换 可以 帮助我们在求割边时避免从子节点走回到父节点 。
:如何理解割边的代码模板中的i!=(from^1)
呢?
答:就是为了不走回头路!
五、【模板】割边
#include <bits/stdc++.h>
using namespace std;
const int N = 5e5 + 10, M = 4e6 + 10;
typedef pair<int, int> PII;
#define x first
#define y second
// 链式前向星
int e[M], h[N], idx, w[M], ne[M];
void add(int a, int b, int c = 0) {
e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++;
}
int dfn[N], low[N], ts, root;
set<PII> _set;
void tarjan(int u, int fa) {
dfn[u] = low[u] = ++ts;
// 对比有向图强连通分量的代码,这里没有入栈的操作,原因是本题只想求割边,并不是想缩点,
// 怀疑缩点时就需要加入stk,in_stk等操作了,待验证
for (int i = h[u]; ~i; i = ne[i]) {
int v = e[i];
if (v == fa) continue;
if (!dfn[v]) {
tarjan(v, u);
low[u] = min(low[u], low[v]);
// 记录割边,论证过的结论
if (low[v] > dfn[u]) _set.insert({u, v}); // SET天生会排序,按first由小到大
} else
low[u] = min(low[u], dfn[v]);
}
}
int n, m;
int main() {
#ifndef ONLINE_JUDGE
freopen("P1656.in", "r", stdin);
#endif
memset(h, -1, sizeof h);
scanf("%d %d", &n, &m);
while (m--) {
int a, b;
scanf("%d %d", &a, &b);
if (a != b) add(a, b), add(b, a);
}
for (root = 1; root <= n; root++)
if (!dfn[root]) tarjan(root, -1);
for (auto i : _set) printf("%d %d\n", i.x, i.y);
return 0;
}
六、【模板】割点
注意:本题如果
low[u] = min(low[u], dfn[j]);
写成low[u] = min(low[u], low[j]);
会有两个点的错误,一定要严格按算法模板来!
#include <bits/stdc++.h>
using namespace std;
const int N = 5e5 + 10, M = 4e6 + 10;
// 链式前向星
int e[M], h[N], idx, w[M], ne[M];
void add(int a, int b, int c = 0) {
e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++;
}
int dfn[N], low[N], ts, root;
set<int> _set;
void dfs(int u, int fa) {
low[u] = dfn[u] = ++ts;
int son = 0;
for (int i = h[u]; ~i; i = ne[i]) {
int v = e[i];
if (v == fa) continue;
if (!dfn[v]) {
son++; // 儿子数量在割点时有用
dfs(v, u);
low[u] = min(low[u], low[v]);
// 记录割点
if (u != root && low[v] >= dfn[u]) _set.insert(u);
if (u == root && son >= 2) _set.insert(u);
} else
low[u] = min(low[u], dfn[v]);
}
}
int n, m;
int main(void) {
scanf("%d %d", &n, &m);
// 链式前向星初始化
memset(h, -1, sizeof h);
while (m--) {
int a, b;
scanf("%d %d", &a, &b);
add(a, b), add(b, a);
}
for (root = 1; root <= n; root++)
if (!dfn[root]) dfs(root, -1);
printf("%d\n", _set.size());
for (auto i : _set) printf("%d ", i);
return 0;
}
七、【模板】边双连通分量
#include <bits/stdc++.h>
using namespace std;
const int N = 5e5 + 10, M = 4e6 + 10;
int e[M], h[N], idx, w[M], ne[M];
void add(int a, int b, int c = 0) {
e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++;
}
int dfn[N], low[N], stk[N], top, ts, root;
int n, m;
// 边双模板
int bcnt;
vector<int> bcc[N];
// 这里与有向图的强连通分量有区别,那个是id[N]+sz[N]静态数组实现,记录的是某个点在哪个强连通分量中,
// 而这里,记录的是某个边双连通分量中有哪些节点,是捋着的顺序不同,实现方式不同,无法追求统一,否则就会在某种场景下造成使用双重循环使得代码TLE掉,
// 不要问我是怎么知道的~
void tarjan(int u, int from) {
dfn[u] = low[u] = ++ts;
stk[++top] = u;
// 边双连通分量模板,对比有向图强连通分量,只用栈记录数据,并没有用状态数组in_stk标识是否某节点在栈中
for (int i = h[u]; ~i; i = ne[i]) {
int v = e[i];
if (!dfn[v]) {
tarjan(v, i); // v:点,i:哪条边
low[u] = min(low[u], low[v]);
} else if (i != (from ^ 1))
low[u] = min(low[u], dfn[v]);
}
if (dfn[u] == low[u]) {
++bcnt; // 边双数量+1
int x;
do {
x = stk[top--];
bcc[bcnt].push_back(x); // 记录边双中有哪些点
} while (x != u);
}
}
int main() {
memset(h, -1, sizeof h);
scanf("%d %d", &n, &m);
while (m--) {
int a, b;
scanf("%d %d", &a, &b);
if (a != b) add(a, b), add(b, a);
}
for (root = 1; root <= n; root++)
if (!dfn[root]) tarjan(root, -1);
// 个数
printf("%d\n", bcnt);
for (int i = 1; i <= bcnt; i++) {
printf("%d ", bcc[i].size());
for (auto j : bcc[i]) printf("%d ", j);
printf("\n");
}
return 0;
}
八、【模板】点双连通分量
#include <bits/stdc++.h>
using namespace std;
const int N = 5e5 + 10, M = 4e6 + 10;
int e[M], h[N], idx, w[M], ne[M];
void add(int a, int b, int c = 0) {
e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++;
}
// 点双模板
int dfn[N], low[N], stk[N], ts, top, root;
vector<int> bcc[N];
int bcnt;
void tarjan(int u, int fa) {
low[u] = dfn[u] = ++ts;
stk[++top] = u;
int son = 0;
for (int i = h[u]; ~i; i = ne[i]) {
int v = e[i];
if (v == fa) continue;
if (!dfn[v]) {
son++;
tarjan(v, u);
low[u] = min(low[u], low[v]);
if (low[v] >= dfn[u]) {
int x;
bcnt++;
do {
x = stk[top--];
bcc[bcnt].push_back(x);
} while (x != v); // 将子树出栈
bcc[bcnt].push_back(u); // 把割点/树根也丢到点双里
}
} else
low[u] = min(low[u], dfn[v]);
}
// 特判独立点
if (fa == -1 && son == 0) bcc[++bcnt].push_back(u);
}
int n, m;
int main() {
memset(h, -1, sizeof h);
scanf("%d %d", &n, &m);
while (m--) {
int a, b;
scanf("%d %d", &a, &b);
if (a != b) add(a, b), add(b, a);
}
for (root = 1; root <= n; root++)
if (!dfn[root]) tarjan(root, -1);
// 个数
printf("%d\n", bcnt);
for (int i = 1; i <= bcnt; i++) {
printf("%d ", bcc[i].size());
for (int j = 0; j < bcc[i].size(); j++)
printf("%d ", bcc[i][j]);
printf("\n");
}
return 0;
}
九、常见问题
① 割点和桥之间没有任何关系
- 两个割点之间的边一定是桥吗?不一定是,如下图:

- 一个桥的两个端点一定是割点吗?不一定是,如下图:

② 边连通分量 和 点连通分量 之间也没有任何关系
- 一个图是边连通分量,则它一定是点连通分量吗?不一定是,如下图:

(2)一个图是点连通分量,则它一定是边连通分量吗?不一定是,如下图:

③ 对于一棵树来说,所有的边都是桥,因此树中的每个点都是一个边连通分量
除叶节点外的所有节点都是割点,因此每条边以及边的两个端点构成的图都是点连通分量。
④ 点双连通分量的缩点

再来一个

将()命名为号连通块
将()命名为号连通块
将()命名为号连通块
将()命名为号连通块
将割点命名为号
将割点命名为号
将每个割点与它从前所属于的连通块进行联边,形如下:

此图也可以参考 这里
一个更复杂的样例

缩点后成为:

十、关于点双和边双的思考
点双 的判定要求比 边双 严格。
点双和边双都是一系列点构成的环, 在不考虑两点一边是点双的特殊情况下,
- ① 点双一定是边双
- ② 边双不一定是点双
- ③ 边双是由一些点双组成的
- ④ 点双内的任意两条边都在同一个简单环中
举个 栗子 方便理解:

由两个点双组成的边双
十一、答疑解惑
:为什么一定要用成对变换这种技巧,直接用邻接表的方法为什么不对呢?
重边对点双连通没有影响,但是对于边双连通有影响。
为什么重边对点双连通没有影响,因为在删除一个点之后,与它相连的点都会被删除,那么既然会被删除,即使再多重边对它也没有太大的影响。
而重边对于边双连通的影响很大,我举个例子:
因为重边的出现,图的连通性发生了改变,然而这种改变恰巧是颠覆性的!
比如说现在我问的是图中的双连通分支的数量:第一个图是,第二个图是。
在求边双连通时,要求对于任意俩点至少存在两条 边不重复 的路径,所以这个时候表示图我们不能用了,而是用链式前向星
注:因为 记录的是之间有边,但没有为之间的边编号!
添加边的时候我们要一次添加正反俩条边,而且要相互可以索引查找,类似网络流里的反向弧,这样在我们求割边时要以边的下标作为标记,在访问了一条边时,要把这条边和其反向边同时标记为访问,最后对所有的边进行遍历,发现时,同样要把正反俩条边标记成割边,最后在不经过桥的情况下求出边双连通分量即可。
感叹:不得不说,确实是大神,用一个链式前向星,就把复杂的无向图边双解决的清清楚楚,真的是个人才!
十二、练习题
[USACO06JAN]冗余路径Redundant Paths
题解
https://www.cnblogs.com/PinkRabbit/p/Introduction-to-Block-Forest.html
https://blog.csdn.net/weixin_51216553/article/details/123163433
再整理一遍模板
https://blog.csdn.net/qq_51605889/article/details/124608738
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!
2019-04-02 MySQL This function has none of DETERMINISTIC, NO SQL...错误1418 的原因分析及解决方法 (转)
2013-04-02 8、单机运行环境搭建之 --CentOS-6.4安装vsftpd
2013-04-02 7、单机运行环境搭建之 --CentOS-6.4安装Memcached
2013-04-02 6、单机运行环境搭建之 --CentOS-6.4安装MySQL 5.6.10并修改MySQL的root用户密码
2013-04-02 5、单机运行环境搭建之 --CentOS-6.4拷贝Tomcat7形成集群
2013-04-02 4、单机运行环境搭建之 --CentOS-6.5优化Tomcat7
2013-04-02 3、单机运行环境搭建之 --CentOS-6.5安装配置Tengine