在了解无向图的双连通分量之前大家可以先了解一下有向图的双连通分量,因为里面很多数组是一样的。(16条消息) 有向图强连通分量tarjan算法详解(适合新手) + 模板题:《信息学奥赛一本通》 , USACO , HAOI2006 受欢迎的牛_wsh1931的博客-CSDN博客
无向图的双连通分量:
一:边双连通分量
首先介绍一下桥的概念。在一个连通块中若去掉一条边会造成整个图不连通的话那么这条边就叫桥。
如图:红色的边就是一个桥
极大的不含桥的连通块即为一个边双连通分量:
二:点双连通分量
同样首先介绍一下割点的概念:在一个连通块中若去掉这个点以及和它相连的所有边会造成整个图不连通的话那么这个点就叫割点。
如图:红色的点即为一个割点。
极大的不含桥的连通块即为一个点双连通分量
问题:桥的两个端点是不是割点呢?
如果看下面这个图的话他是割点
但看这个图的话他不是:红色的边是一座桥,但红色的点和黑色的边并不是割点
问题二:两个割点构成的边是不是一个桥呢?
同理在这个图上他是
但是在这个图上:
红色点虽然是割点,但它们之间的边并不是桥
同理边的双连通分量不一定是点的双连通分量,点的双连通分量也不一定是边的双连通分量。
所以:桥和割点,以及点双连通分量和边双连通分量没有任何关系:
首先我们先来了解边双连通分量
问题一:如何判断桥?
注意:这里的low, 与dfn和有向图强连通分量的数组定义一样
结论:假设有一个从x 到 y的点若low[y] > dfn[x]则 x 到 y 的一条边可以称为桥
我们来解释为什么
如图一个从点 x 到点 y 的路径low[y]表示y所能走到的最小的时间戳,dfn[x]表示点x的时间戳。因为时间戳是按点的遍历顺序递增的又因为 y 比 x 后遍历所以dfn[y] > dfn[x],又因为y能走到的时间戳最小的节点大于 x 的时间戳即low[y] > dfn[x],即 y 永远不可能走到点 x 以及在点 x 遍历之前所遍历到的所有点 ,因此x 到 y 即为一座桥:
问题二:如何找到边双连通分量??
1:删掉所有桥
2:建立一个栈stk当dfn[u] == low[u] 时将栈里的所有元素都缩为一个点。缩完点后每个连通块之间的边即为一个桥:
如图:
缩点后两个连通块所连接的黑色的边即为一个桥
问题三:为啥当dfn[u] == low[u] 时将栈里的所有元素都缩为一个点?
因为dfn[u] == low[u]表示从点开始走 u 能走到自己。即点 u 在的图中即为一个联通图。
代码如下:
void tarjan(int u, int fa)//u表示正在枚举哪个点,fa表示上次递归遍历到 u 用的时哪条边 { dfn[u] = low[u] = ++ timestamp;//和有向图强连通分量一样 stk.push(u); for (int i = h[u]; i != -1; i = ne[i]) { int j = e[i]; if (!dfn[j])//若点 j 还未被遍历 { tarjan(j, i);//遍历点 j 以及遍历到点 j 走的是那条边 low[u] = min(low[u], low[j]);//因为u能走到j所以u能走到的最小时间戳即j能走到的最小时间戳 if (low[j] > dfn[u])//说明存在桥 is_bridge[i] = is_bridge[i ^ 1] = true ;//从点 u 到点 j 所走的边是 i 这条边又因为他是 //双向边,因此边i以及从j -> u的边即i ^ 1是桥 //为什么i的反向边是i ^ 1呢?? //根据邻接表的定义我们建边是按照0, 1为一条双向边,2, 3为一条双向边 //又因为奇数 ^ 1 = 奇数 - 1 //偶数 ^ 1 = 偶数 + 1 //所以i ^ 1等于他的反向边 } else if (i != (fa ^ 1)) low[u] = min(low[u], dfn[j]);//若当前遍历的边 i 不是上次一遍历的反向边 //即遍历的顺序没有往回走 } if (dfn[u] == low[u])//点 u 能走到点 u 。 { int y; dcc_cnt ++ ; do { y = stk.top(); stk.pop(); id[y] = dcc_cnt; } while (y != u);//找到点u所能到的所有点即为一个连通图,将其缩点为一个点 } }
冗余路径
例题如下:
为了从 F 个草场中的一个走到另一个,奶牛们有时不得不路过一些她们讨厌的可怕的树。
奶牛们已经厌倦了被迫走某一条路,所以她们想建一些新路,使每一对草场之间都会至少有两条相互分离的路径,这样她们就有多一些选择。
每对草场之间已经有至少一条路径。
给出所有 R 条双向路的描述,每条路连接了两个不同的草场,请计算最少的新建道路的数量,路径由若干道路首尾相连而成。
两条路径相互分离,是指两条路径没有一条重合的道路。
但是,两条分离的路径上可以有一些相同的草场。
对于同一对草场之间,可能已经有两条不同的道路,你也可以在它们之间再建一条道路,作为另一条不同的道路。
输入格式
第 1 行输入 F 和 R。
接下来 RR 行,每行输入两个整数,表示两个草场,它们之间有一条道路。
输出格式
输出一个整数,表示最少的需要新建的道路数。
数据范围
1≤F≤5000,
F−1≤R≤10000输入样例:
7 7 1 2 2 3 3 4 2 5 4 5 5 6 5 7
输出样例:
2
这个题目找的就是要想将一个图变成一个边双连通分量最少要加几个点。
如图将一个图缩点后为一棵树,因为环都变成了一个点因此缩点后就是一颗树
要想使他成为一个边双连通分量只需要把度为1的点都相互连接上即成为了一个双连通分量。可以的到规律需要连接的边数为(度为1的点的数量)/ 2 上取整即为 (度为1的点的数量 + 1) / 2
代码如下:
#include
#include #include #include using namespace std; const int N = 5010, M = 20010; int cnt; int d[N]; int n, m; stack stk; bool is_bridge[N]; int id[N], dcc_cnt; int h[N], e[M], ne[M], idx; int dfn[N], low[N], timestamp; void add(int a, int b) { e[idx] = b; ne[idx] = h[a]; h[a] = idx; idx ++ ; } void tarjan(int u, int fa)//u表示正在枚举哪个点,fa表示上次递归遍历到 u 用的时哪条边 { dfn[u] = low[u] = ++ timestamp;//和有向图强连通分量一样 stk.push(u); for (int i = h[u]; i != -1; i = ne[i]) { int j = e[i]; if (!dfn[j])//若点 j 还未被遍历 { tarjan(j, i);//遍历点 j 以及遍历到点 j 走的是那条边 low[u] = min(low[u], low[j]);//因为u能走到j所以u能走到的最小时间戳即j能走到的最小时间戳 if (low[j] > dfn[u])//说明存在桥 is_bridge[i] = is_bridge[i ^ 1] = true ;//从点 u 到点 j 所走的边是 i 这条边又因为他是 //双向边,因此边i以及从j -> u的边即i ^ 1是桥 //为什么i的反向边是i ^ 1呢?? //根据邻接表的定义我们建边是按照0, 1为一条双向边,2, 3为一条双向边 //又因为奇数 ^ 1 = 奇数 - 1 //偶数 ^ 1 = 偶数 + 1 //所以i ^ 1等于他的反向边 } else if (i != (fa ^ 1)) low[u] = min(low[u], dfn[j]);//若当前遍历的边 i 不是上次一遍历的反向边 //即遍历的顺序没有往回走 } if (dfn[u] == low[u])//点 u 能走到点 u 。 { int y; dcc_cnt ++ ; do { y = stk.top(); stk.pop(); id[y] = dcc_cnt; } while (y != u);//找到点u所能到的所有点即为一个连通图,将其缩点为一个点 } } int main() { cin >> n >> m; memset(h, -1, sizeof h); while (m -- ) { int a, b; scanf("%d %d", &a, &b); add(a, b), add(b, a); } tarjan(1, -1);//缩点 for (int i = 0; i < idx; i ++ )//枚举所有边 if (is_bridge[i])//若边i是桥 d[id[e[i]]] ++ ;//则 i 所连接的点e[i]所在联通块度数加1. for (int i = 1; i <= dcc_cnt; i ++ ) if (d[i] == 1) cnt ++ ;//找到各个联通块之间度数为1的连通块 cout << (cnt + 1) / 2 << endl; return 0; }
例题:
Critical Network Lines
题目链接:
P7687 [CEOI2005] Critical Network Lines - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
解题思路:
直接找出桥,然后判断桥两边的联通分量是否存在不满足两类条件即可#include
#include #include using namespace std; typedef pair PII; const int N = 100010, M = 2000010; int sum; PII ans[N]; bool st[M]; int n, m, l, k; int a[N], b[N]; int h[N], e[M], ne[M], idx; int low[N], dfn[N], timestamp; void add(int a, int b) { e[idx] = b; ne[idx] = h[a]; h[a] = idx; idx ++ ; } void tarjan(int u, int fa) { low[u] = dfn[u] = ++ timestamp; for (int i = h[u]; i != -1; i = ne[i]) { int j = e[i]; if (st[i]) continue; st[i] = st[i ^ 1] = true; if (!dfn[j] && j != fa) { tarjan(j, u); low[u] = min(low[u], low[j]); if (low[j] > dfn[u]) if (!a[j] || !b[j] || a[j] == l || b[j] == k)//若其中一种情况都在某一个连通块中则 ans[sum ++ ] = {u, j}; //记录答案。 }else if (j != fa) low[u] = min(low[u], dfn[j]); if (j != fa) a[u] += a[j], b[u] += b[j]; } } int main() { memset(h, -1, sizeof h); cin >> n >> m >> l >> k; for (int i = 1; i <= l; i ++ ) { int x; scanf("%d", &x); a[x] = 1; } for (int i = 1; i <= k; i ++ ) { int x; scanf("%d", &x); b[x] = 1; } for (int i = 0; i < m; i ++ ) { int a, b; scanf("%d %d", &a, &b); add(a, b), add(b, a); } tarjan(1, -1); cout << sum << endl; for (int i = 0; i < sum; i ++ ) printf("%d %d\n", ans[i].first, ans[i].second); return 0; }
然后我们介绍一下点的双连通分量的求法:
注意:以上数组与上题的数组一致
1:如何求割点??
1.1:假设当前枚举的 u 不是根节点,若存在一条从 u 走到 j 的路径满足low[j] >= dfn[u]则u为割点,即 j 能走到的时间戳的最低点大于等于 u 的时间戳。
1.2:假设 u 是根节点若 u 只有一个子节点,则将 u 去掉之后仍是一个连通图:
假设 u 有2个子节点两个子节点都需要满足low[j] >= dfn[u]则去掉 u 点后 u 的两个子节点所在的连通块将分别为两个联通块,此时 u 即为割点。
点联通分量例题:
给定一个由 n 个点 m 条边构成的无向图,请你求出该图删除一个点之后,连通块最多有多少。
输入格式
输入包含多组数据。
每组数据第一行包含两个整数 n,m。
接下来 m 行,每行包含两个整数 a,b,表示 a,b 两点之间有边连接。
数据保证无重边。
点的编号从 0 到 n−1。
读入以一行 0 结束。
输出格式
每组数据输出一个结果,占一行,表示连通块的最大数量。
数据范围
1≤n≤10000,
0≤m≤15000,
0≤a,b输入样例:
3 3 0 1 0 2 2 1 4 2 0 1 2 3 3 1 1 0 0 0
输出样例:
1 2 2
解题思路:
统计所有的连通块cnt,然后分别枚举从哪个连通块中删除点 u。假设在当前连通块中删除点 u 之后将该连通块分为了 ans 个部分,记录ans的最大值。最后答案为ans + cnt - 1。(将该联通块分为了ans个部分 + 其他联通块的个数cnt - 将连通块分为ans个部分之前的整个连通块的个数即为1)
点双连通分量例题:
#include
#include #include using namespace std; const int N = 10010, M = 30010; int n, m; int root, ans; int h[N], e[M], ne[M], idx; int dfn[N], low[N], timestamp; void add(int a, int b) { e[idx] = b; ne[idx] = h[a]; h[a] = idx; idx ++ ; } void tarjan(int u) { dfn[u] = low[u] = ++ timestamp; int cnt = 0;//求出以 u 为根节点所能分成的子树的个数 for (int i = h[u]; i != -1; i = ne[i]) { int j = e[i]; if (!dfn[j])//若j还未被遍历过 { tarjan(j); low[u] = min(low[u], low[j]);//因为 u 可以走到 j 所以 j 能走到的时间戳最小的点即为u能走到的点 if (low[j] >= dfn[u]) cnt ++ ;//u为割点,则增加一个子联通快. } else low[u] = min(low[u], dfn[j]); } if (u != root) cnt ++ ;//即要加上他的根节点的连通块 /* / x 删掉x后 除子节点yi外 / \ 还要要加上父节点部分+1 o o */ ans = max(ans, cnt);//记录删掉u这个点的所分成部分的最大值 } int main() { while (cin >> n >> m, n || m) { idx = 0; timestamp = 0; memset(h, -1, sizeof h); memset(dfn, 0, sizeof dfn); while (m -- ) { int a, b; scanf("%d %d", &a, &b); add(a, b), add(b, a); } ans = 0; int cnt = 0; for (root = 0; root < n; root ++ ) if (!dfn[root])//若root未被遍历 { cnt ++ ;//搜到的总共的连通块个数加1 tarjan(root);//以root为根节点所在的连通块依次删除每个割点所能分成的最多的部分 } cout << ans + cnt - 1 << endl; } return 0; }