无向图的双连通分量
1. 无向图的双连通分量原理
原理
- 双联通分量也被称为重连通分量,一下内容都是针对无向图来说的。
- 无向图的双联通分量可以分为两类:
(1)边双联通分量(e-DCC);
(2)点双联通分量(v-DCC);
-
对于(1)边双联通分量的定义,我们需要引入桥的概念,桥是指连通图中的一条边,这条边满足:如果删除这条边,整个图会变的不连通,则这条边被称为桥。极大的不含有桥的连通区域称为边连通分量。根据定义可知e-DCC有以下性质:
① 在e-DCC中,无论删除哪条边,该e-DCC仍是连通的;
② 在e-DCC中,任意两点之间至少存在两条不相交(边是严格不相交的,点可以相交)的路径;
-
对于(2)点双联通分量的定义,我们需要引入割点的概念,割点是指连通图中的一个点,这个点满足:如果删除这个点以及该点相关联的所有边,整个图会变的不连通,则这个点被称为割点。极大的不含有割点的连通区域称为点连通分量。根据定义可知v-DCC有以下性质:
① 每个割点至少属于两个v-DCC
- 割点和桥之间没有任何关系
(1)两个割点之间的边一定是桥吗?不一定是,如下图:
(2)一个桥的两个端点一定是割点吗?不一定是,如下图:
- 边连通分量和点连通分量之间也没有任何关系
(1)一个图是边连通分量,则它一定是点连通分量吗?不一定是,如下图:
(2)一个图是点连通分量,则它一定是边连通分量吗?不一定是,如下图:
- 对于一棵树来说,所有的边都是桥,因此树中的每个点都是一个边连通分量;除叶节点外的所有节点都是割点,因此每条边以及边的两个端点构成的图都是点连通分量。
边双联通分量(e-DCC)
-
如何求解边双联通分量?这里的做法和求有向图的强联通分量(网址)是类似的,也需要时间戳以及 d f n 、 l o w dfn、low dfn、low数组。
-
引入时间戳(从1开始计数)的概念,根据DFS过程中每个节点遍历到的顺序依次给每个节点递增赋值。
-
对于每个节点,定义两个时间戳: d f n [ u ] dfn[u] dfn[u]和 l o w [ u ] low[u] low[u]。
d f n [ u ] dfn[u] dfn[u]:表示遍历到u的时间戳;
low[u]:从u开始走,所能遍历到的最小时间戳。
-
如何找到所有的桥?边(x, y)是桥 ⟺ d f n [ x ] < l o w [ y ] \iff dfn[x] < low[y] ⟺dfn[x]<low[y]。
-
如何找到所有的边连通分量?存在两种做法:
(1)将所有的桥删除,剩余的每个连通分量就是e-DCC。
(2)类似于有向图求SCC,可以使用一个栈记录当前边连通分量中的点,如果有 d f n [ u ] = = l o w [ u ] dfn[u] == low[u] dfn[u]==low[u],说明此时递归过程中走到x的边是桥,此时可以弹出栈中的元素,得到一个e-DCC。
点双联通分量(v-DCC)
-
此时仍然需要使用时间戳以及 d f n 、 l o w dfn、low dfn、low数组。
-
首先我们需要考虑如何求割点?考虑DFS过程中从点u遍历到点j,如果有 d f n [ u ] ≤ l o w [ j ] dfn[u] \le low[j] dfn[u]≤low[j],则:
① 如果x不是根节点,那么x是割点;
② 如果x是根节点,则至少存在两各个节点y,使得 l o w [ y i ] ≥ d f n [ x ] low[y_i] \ge dfn[x] low[yi]≥dfn[x],此时x才是割点。
-
如何求点连通分量(v-DCC)呢?
首先如果一个点是一个孤立点的话,也是一个v-DCC;
做法类似于有向图求SCC,可以使用一个栈记录当前边连通分量中的点,如果在DFS过程中从点u遍历到点j,有 d f n [ u ] ≤ l o w [ j ] dfn[u] \le low[j] dfn[u]≤low[j],说明u可能是割点,具体步骤如下:
if (dfn[u] <= low[j]) { cnt++; // cnt记录u的子树的个数 if (x非根 || cnt > 1) x是割点; 将栈中元素弹出直至弹出y为止; u也属于该v-DCC; }
-
我们可能对下图存在疑问,下图中j是割点,u不是v-DCC中的点?
- 上图中其实u也是v-DCC中的点,但是上图有两个v-DCC,如下图:
2. AcWing上的无向图的双连通分量题目
AcWing 395. 冗余路径
问题描述
-
问题链接:AcWing 395. 冗余路径
分析
- 在一个连通图中,任意两点之间至少存在两条不相交(边是严格不相交的,点可以相交)的路径 ⟺ \iff ⟺这个图是一个边双联通分量。这里不给证明,记住结论即可。
- 根据上面的结论,则这个题目相当于问:给我们一个无向连通图,问至少加几条边,可以将其变为一个边双联通分量(e-DCC)。注意可以认为AcWing 367. 学校网络是这一题的有向图版本,问的是有向图中加几条边能够成为SCC。
- 具体的做法是:将图中所有的边连通分量进行缩点,缩点完成后的新图就会变成一棵树,对于树中所有的叶子节点(假设有cnt个),我们都至少要给他们加上一条边,因此我们至少需要给图中添加 ⌈ c n t / 2 ⌉ = ⌊ ( c n t + 1 ) / 2 ⌋ \lceil cnt/2 \rceil = \lfloor (cnt+1)/2 \rfloor ⌈cnt/2⌉=⌊(cnt+1)/2⌋条边。
- 因此,最终的结论是:
(1)在一个无向连通图中,如果该图原本就是一个e-DCC,则不需要加边就满足条件;对图中所有e-DCC缩点之后图会变成一棵树,若新图中叶节点个数为cnt,则图中添加 ⌈ c n t / 2 ⌉ = ⌊ ( c n t + 1 ) / 2 ⌋ \lceil cnt/2 \rceil = \lfloor (cnt+1)/2 \rfloor ⌈cnt/2⌉=⌊(cnt+1)/2⌋条边即可让整张图变为一个e-DCC。对应本题。
(2)在一个有向图中,如果该图原本就是一个SCC,则不需要加边就满足条件;否则对图中所有的SCC进行缩点之后,若起点有P个,终点有Q个,则至少增加 M A X ( P , Q ) MAX(P, Q) MAX(P,Q)条边即可让整个有向图成为一个SCC。对应AcWing 367. 学校网络。
代码
- C++
#include <iostream>
#include <cstring>
using namespace std;
const int N = 5010, M = 20010;
int n, m;
int h[N], e[M], ne[M], idx;
int dfn[N]; // dfn[u]: 表示遍历到u的时间戳
int low[N]; // low[n]: 从u开始走,所能遍历到的最小时间戳。
int timestamp; // 时间戳
int stk[N], top; // 存储在当前e-DCC中的点
int id[N]; // 表示某个点所在的e-DCC编号
int dcc_cnt; // 表示当前有多少个e-DCC
bool is_bridge[M]; // 记录每条边是否是桥
int d[N]; // 每个e-DCC的度数
void add(int a, int b) {
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
// 点u是从边from过来的,防止从u向边from回搜
void tarjan(int u, int from) {
dfn[u] = low[u] = ++ timestamp;
stk[++top] = u;
for (int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
if (!dfn[j]) {
tarjan(j, i);
low[u] = min(low[u], low[j]);
if (dfn[u] < low[j]) // 说明从j无法回到前面,(u, j)是桥
is_bridge[i] = is_bridge[i ^ 1] = true;
} else if (i != (from ^ 1)) // 第i条边不能是回去的边
low[u] = min(low[u], dfn[j]);
}
if (dfn[u] == low[u]) {
++dcc_cnt;
int y;
do {
y = stk[top--];
id[y] = dcc_cnt;
} while (y != u);
}
}
int main() {
cin >> n >> m;
memset(h, -1, sizeof h);
while (m--) {
int a, b;
cin >> a >> b;
// 第0条边和第1条边是一对,之后类似
// 如果知道其中一条边的是第i条边,则另一条边是第i^1条边
add(a, b), add(b, a);
}
tarjan(1, -1);
// idx记录的边的数目
// 统计每个e-DCC的出度
for (int i = 0; i < idx; i++)
if (is_bridge[i])
d[id[e[i]]]++;
// 统计叶子节点的数目
int cnt = 0;
for (int i = 1; i <= dcc_cnt; i++)
if (d[i] == 1)
cnt++;
printf("%d\n", (cnt + 1) / 2);
return 0;
}
AcWing 1183. 电力
问题描述
-
问题链接:AcWing 1183. 电力
分析
- 做法如下:
(1)统计一下所有连通块的个数(结果为cnt);
(2)枚举从哪个块中删,然后再枚举这个块中删除哪个点,会得到删除点之后形成块的数量,去最大值,记为ans。最终的答案就是ans+cnt-1。
- 我们需要考虑删除哪个点?答案是我们应该删除每个连通块中的割点,因为根据割点定义,只有删除割点才能使连通块的数量增加。对于每个连通块而言,如果u是割点,假设u存在2个孩子,此时还需要判断u是否为这个图的根节点(即遍历该连通块时第一个遍历到的节点),如果是根节点,则删去u之后能形成2个连通块,如果u不是根节点,则删去u之后能形成3个连通块,如下图:
代码
- C++
#include <iostream>
#include <cstring>
using namespace std;
const int N = 10010, M = 30010;
int n, m;
int h[N], e[M], ne[M], idx;
int dfn[N], low[N], timestamp;
int root; // 记录每个连通块的"根节点"
int ans; // 记录每个连通块去掉一个点形成的连通块数目的最大值
void add(int a, int b) {
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void tarjan(int u) {
dfn[u] = low[u] = ++ timestamp;
int s = 0; // 如果当前点u是割点的话,去掉该点u得到的连通分量的个数
for (int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
if (!dfn[j]) {
tarjan(j);
low[u] = min(low[u], low[j]);
if (dfn[u] <= low[j]) // 说明u是可能是割点, u存在一棵子树
s++;
} else low[u] = min(low[u], dfn[j]);
}
//如果不是根节点
/*
/
u 删掉u后 除子节点yi外
/ \ 还要要加上父节点部分+1
o o
*/
if (u != root) s++; // 不用加上&& s的判断,因为u不是割点的话,s要取1
ans = max(ans, s);
}
int main() {
while (scanf("%d%d", &n, &m), n || m) {
memset(dfn, 0, sizeof dfn); // dfn还具有判重数组的作用
memset(h, -1, sizeof h);
idx = timestamp = 0;
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++) // 节点编号从0~n-1
if (!dfn[root]) {
cnt++;
tarjan(root);
}
printf("%d\n", cnt + ans - 1);
}
return 0;
}
AcWing 396. 矿场搭建
问题描述
-
问题链接:AcWing 396. 矿场搭建
分析
- 本题相当于问:给定一个无向图,问最少在几个点上设置出口,可以使得不管其他哪个点被删除,其余所有点都可以与某个出口连通。
- 本题中给的无向图可能不是连通的,存在多个连通分量,我们不需要考虑连通分量的情况,我们考虑每个v-DCC即可。如果某个连通分量中不含有割点,则该连通分量就是一个v-DCC,对应下面的情况(1)。
- 假设每个v-DCC的需要设置的通道数记为 r e s [ i ] res[i] res[i],方案数为 n u m [ i ] num[i] num[i],则最终整个图需要的通道数为 r e s = ∑ r e s [ i ] res = \sum res[i] res=∑res[i],方案数为 n u m = ∏ n u m [ i ] num = \prod num[i] num=∏num[i]。首先,我们的出口数量必须有 r e s ≥ 2 res \ge 2 res≥2,否则只存在一个出口的话,万一出口坏了,则其他点都出不去了。下面我们聚焦讨论每个v-DCC(假设当前讨论的第i个v-DCC点的数量为size个,割点数量为cnt个,这可以使用tarjan算法求解)。
(1)如果v-DCC中不含有割点(这种情况对应图中某个连通分量中不存在割点),即cnt==0,则需要设置两个出口( r e s [ i ] = 2 res[i]=2 res[i]=2),这两个出口可以任意选两个,方案数 n u m [ i ] = C s i z e 2 num[i]=C_{size}^2 num[i]=Csize2。
(2)如果v-DCC中有割点,即cnt>1,则需要对该v-DCC所在的连通分量进行缩点操作(实际代码中不需要进行缩点,只需要计算cnt即可,这里为了分析方便),这里的缩点规则是:每个割点单独作为一个点;从每个v-DCC向其所包含的每个割点连边。如下图(缩点后至少存在三个点,我们不需要考虑单独的割点,考虑割点所在的v-DCC即可):
缩点之后边的个数不会增加,但是点的数量可能增加,新图中点的个数=连通分量的个数+割点的数目,因此最多有2倍的点。
如上图,因为这个连通分量存在割点,因此存在v-DCC,具体来说,上图对应的连通分量3个v-DCC,我们需要依次考虑每个v-DCC。
(2.1)如果cnt==1,如上图中的绿色和青色对应的缩点,这个割点相当于出口,则我们需要在该v-DCC中出除了割点的位置外设置一个出口即可,方案数为size-1;这样能保证该v-DCC的安全,因为如果割点坏了,通过该v-DCC中的出口该v-DCC中的其他点可以出去;如果这个出口坏了,可以通过这个割点到达其他v-DCC,也可以通过其他v-DCC设置的出口安全出去。
(2.2)如果cnt>=2,如上图中的紫色对应的缩点,则该v-DCC不需要设置任何出口;因为无论该v-DCC中的哪个点坏了,都可以通过割点到达其他v-DCC,然后通过其他v-DCC设置的出口安全出去。
- 最后我们还需要考虑孤立点的情况,孤立点不是割点,我们在孤立点也需要设置一个出口,否则若其他点坏了,这各孤立点没法通过出口出去。
- 另外这一题没有给点数,但是是从1开始的自然数,我们需要自己求一下点数。
代码
- C++
#include <iostream>
#include <cstring>
#include <vector>
using namespace std;
typedef unsigned long long ULL;
const int N = 1010, M = 1010;
int n, m;
int h[N], e[M], ne[M], idx;
int dfn[N], low[N], timestamp;
int stk[N], top;
int dcc_cnt; // v-DCC的个数
vector<int> dcc[N]; // 存储每个v-DCC有哪些点,之后用来求每个v-DCC中割点的数量
bool cut[N]; // 记录每个点是不是割点
int root; // 记录每个连通块的"根节点"
void add(int a, int b) {
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void tarjan(int u) {
dfn[u] = low[u] = ++timestamp;
stk[++top] = u;
// 判断点u是否为孤立点
if (u == root && h[u] == -1) {
dcc[++dcc_cnt].push_back(u);
return;
}
int cnt = 0; // u的不能回到u之前的子树数量
for (int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
if (!dfn[j]) {
tarjan(j);
low[u] = min(low[u], low[j]);
if (dfn[u] <= low[j]) {
cnt++;
/* 判断割点对应两种情况:
/
u
/ \
o o
*/
if (u != root || cnt > 1) cut[u] = true;
++dcc_cnt;
int y;
do {
y = stk[top--];
dcc[dcc_cnt].push_back(y);
} while (y != j);
dcc[dcc_cnt].push_back(u);
}
} else
low[u] = min(low[u], dfn[j]);
}
}
int main() {
int T = 1;
while (cin >> m, m) {
// 因为存在多组测试数据,需要初始化变量
for (int i = 1; i <= dcc_cnt; i++) dcc[i].clear();
idx = n = timestamp = top = dcc_cnt = 0;
memset(h, -1, sizeof h);
memset(dfn, 0, sizeof dfn);
memset(cut, 0, sizeof cut);
while (m--) {
int a, b;
cin >> a >> b;
n = max(n, a), n = max(n, b);
add(a, b), add(b, a);
}
// 求v-DCC
for (root = 1; root <= n; root++)
if (!dfn[root])
tarjan(root);
int res = 0; // 最少需要设置的出口数量
ULL num = 1; // 方案数
for (int i = 1; i <= dcc_cnt; i++) {
int cnt = 0; // 该v-DCC对应的割点数目
int t = dcc[i].size(); // 当前v-DCC中点的数量
for (int j = 0; j < t; j++)
if (cut[dcc[i][j]])
cnt++;
if (cnt == 0) {
if (t == 1) res++;
else res += 2, num *= t * (t - 1) / 2;
} else if (cnt == 1)
res++, num *= t - 1;
}
printf("Case %d: %d %llu\n", T++, res, num);
}
return 0;
}