无向图的割边与边双连通分量
割边:对于一个无向图,如果删掉其中某条边后图中的连通块个数增加了,则称这条边为割边或桥。
割边的判定:对于 DFS 树中的节点 \(u\) 与其子节点 \(v\),满足 \(low_v > dfn_u\),则说明边 \(u \leftrightarrow v\) 是割边,因为这种情况下从 \(v\) 出发,在不经过 \(u \leftrightarrow v\) 这条边的情况下不管怎么走都无法到达 \(u\) 或 DFS 树上更早的节点。
和割点的判定对比可以发现,就差一个等号,这是由于在割点判定时允许走 \(u \leftrightarrow v\) 的反边更新 \(low\) 值,而割边判定时不允许走反边来更新 \(low\) 值。
struct Edge {
int x, y;
};
Edge e[M]; // 边集
vector<int> g[N]; // 这里存的是边的编号
int n, m, dfn[N], low[N], tot;
bool b[M]; // 是否为割边
void build() {
for (int i = 1; i <= m; i++) {
int x = e[i].x, y = e[i].y;
g[x].push_back(i);
g[y].push_back(i);
}
}
void tarjan(int u, int from) {
dfn[u] = low[u] = ++tot;
for (int i : g[u]) {
if (i == from) continue; // 避免回边
int v = e[i].x == u ? e[i].y : e[i].x;
if (!dfn[v]) {
tarjan(v, i);
low[u] = min(low[u], low[v]);
if (low[v] > dfn[u]) {
b[i] = true; // 这条边是割边
}
} else {
low[u] = min(low[u], dfn[v]);
}
}
}
边双连通分量:无向图中极大的不包含割边的连通块被称为边双连通分量(edge Double Connected Components,eDCC)。

通过之前的算法找到所有的割边后,只需要在原图上将割边屏蔽进行遍历,每次找到的连通块就是一个边双连通分量。
例题:P8436 【模板】边双连通分量
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using std::vector;
using std::min;
const int N = 500005;
const int M = 2000005;
struct Edge {
int x, y;
};
Edge e[M];
bool b[M], vis[N];
vector<int> g[N], eDCC[N];
int dfn[N], low[N], tot, cnt;
void tarjan(int u, int from) {
dfn[u] = low[u] = ++tot;
for (int i : g[u]) {
if (i == from) continue;
int v = e[i].x == u ? e[i].y : e[i].x;
if (!dfn[v]) {
tarjan(v, i);
low[u] = min(low[u], low[v]);
if (low[v] > dfn[u]) {
b[i] = true;
}
} else {
low[u] = min(low[u], dfn[v]);
}
}
}
void dfs(int u) {
vis[u] = true; eDCC[cnt].push_back(u);
for (int i : g[u]) {
if (b[i]) continue;
int v = e[i].x == u ? e[i].y : e[i].x;
if (!vis[v]) dfs(v);
}
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int x, y; scanf("%d%d", &x, &y);
e[i] = {x, y};
g[x].push_back(i); g[y].push_back(i);
}
for (int i = 1; i <= n; i++) if (!dfn[i]) tarjan(i, 0);
for (int i = 1; i <= n; i++)
if (!vis[i]) {
cnt++;
dfs(i);
}
printf("%d\n", cnt);
for (int i = 1; i <= cnt; i++) {
printf("%d", (int)eDCC[i].size());
for (int j : eDCC[i]) {
printf(" %d", j);
}
printf("\n");
}
return 0;
}
习题:P2860 [USACO06JAN] Redundant Paths G
解题思路
希望每对点都有“两条独立的路线”(至少两条没有交集的路径),要达到这个效果等价于整个图是一个边双连通分量。因为整个图是边双连通分量意味着没有割边,而割边是某两个点的所有路径中的必经之路。因此本题相当于加若干条边,使得图中没有割边,变成一个独立的边双连通分量。
对原图中的所有边双连通分量缩点,得到的新图(实际上会是一棵树)中的边都是原图中的割边,显然如果在两个叶子节点之间连一条边,则这两个叶子节点之间的原始路径上的边都不再是割边。因此本题相当于使加的边对应的链的能够覆盖原来所有的割边。
一个容易猜到的结论是将叶子节点两两配对,假如共 \(c\) 个叶子节点,则最少需要添加 \(\left \lceil \dfrac{c}{2} \right \rceil\) 条边。
那么具体方案是怎么得到的呢?注意并不是随便两两配对加边就能变成一个 eDCC。

考虑对缩点后的树选一个根节点,使得其每个子节点下的子树中叶节点最多不超过 \(\left \lceil \dfrac{c}{2} \right \rceil\),可以证明肯定能找到这样的根节点。因为如果某棵子树中叶节点数量超了,可以通过调整法将根节点向该子树方向调整(类似于树的重心的原理)。此时可以证明一定有方案使得每次用来配对的叶子节点属于根节点的不同子树,只需要每次从剩余叶子节点最多的子树中拿一个叶子节点出来与其他子树的叶子节点配对,而这样配对过程中涉及到的链的并集会覆盖所有原来的割边。
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using std::vector;
using std::min;
using std::max;
const int N = 5005;
const int M = 10005;
struct Edge {
int x, y;
};
Edge e[M];
bool b[M], vis[N];
vector<int> g[N];
int dfn[N], low[N], tot, eDCC[N], cnt, d[N];
void tarjan(int u, int from) {
dfn[u] = low[u] = ++tot;
for (int i : g[u]) {
if (i == from) continue;
int v = e[i].x ^ e[i].y ^ u;
if (!dfn[v]) {
tarjan(v, i);
low[u] = min(low[u], low[v]);
if (low[v] > dfn[u]) {
b[i] = true;
}
} else {
low[u] = min(low[u], dfn[v]);
}
}
}
void dfs(int u) {
vis[u] = true; eDCC[u] = cnt;
for (int i : g[u]) {
if (b[i]) continue;
int v = e[i].x ^ e[i].y ^ u;
if (!vis[v]) dfs(v);
}
}
int main()
{
int f, r; scanf("%d%d", &f, &r);
for (int i = 1; i <= r; i++) {
int x, y; scanf("%d%d", &x, &y);
e[i] = {x, y};
g[x].push_back(i); g[y].push_back(i);
}
for (int i = 1; i <= f; i++)
if (!dfn[i]) tarjan(i, 0);
for (int i = 1; i <= f; i++)
if (!vis[i]) {
cnt++;
dfs(i);
}
for (int i = 1; i <= r; i++) {
int x = e[i].x, y = e[i].y;
if (eDCC[x] != eDCC[y]) {
d[eDCC[x]]++; d[eDCC[y]]++;
}
}
int ans = 0;
for (int i = 1; i <= cnt; i++) {
if (d[i] == 1) ans++;
}
printf("%d\n", (ans + 1) / 2);
return 0;
}
习题:P8867 [NOIP2022] 建造军营
给定一个无向连通图代表城市和连接关系,求在这个图中选择至少一个城市建造军营,并选择若干条道路进行看守,使得无论哪条未被看守的道路被切断,所有军营之间仍能保持连通的方案数。
解题思路(45 分)
测试点 \(1 \sim 7\)
这部分数据点和边的数量都不多,考虑枚举每个点是否建造军营,有 \(O(2^n)\) 种情况。
在一种特定的军营建造方案下,如果某条边断开之后会有军营之间无法连通,则这条边必须守卫,否则这条边可守可不守,因此可以计算出必须守卫的边的数量 \(c\),那么此时有 \(2^{m-c}\) 种边的守卫情况。这个过程枚举每条要断开的边,从某个军营出发对图做 DFS,如果存在一个到达不了的军营则这条边必须要守卫。
时间复杂度为 \(O(m(n+m)2^n)\)。
测试点 \(10 \sim 11\)
链的情况。此时如果选了某些点建兵营,则需要守卫最靠近两端的兵营之间的每一条边,具体地:
- 如果只选一个点建兵营,则每一条边都可守可不守,方案数为 \(n \times 2^m\)
- 如果选不止一个点,设编号最小的为 \(x\),编号最大的为 \(y\),则中间的 \(y-x\) 条边必须守卫,剩下的边有 \(2^{m-(y-x)}\) 种守卫情况,而 \(x+1\) 到 \(y-1\) 的兵营可建可不建,情况为 \(2^{y-x-1}\),总方案数为 \(2^{m-1}\),与 \(x\) 和 \(y\) 无关。而取出 \(x\) 和 \(y\) 的取法有 \(\dfrac{n(n-1)}{2}\) 种,因此总的方案数为 \(\dfrac{n(n-1)}{2} \times 2^{m-1}\)
参考代码
#include <cstdio>
#include <vector>
using std::vector;
const int N = 500005;
const int M = 1000005;
const int MOD = 1000000007;
vector<int> g[N];
int e[M], val, ban, p2[M];
bool vis[N];
void dfs(int u) {
vis[u] = true;
val |= 1 << (u - 1);
for (int i : g[u]) {
if (i == ban) continue;
int v = u ^ e[i];
if (!vis[v]) dfs(v);
}
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
p2[0] = 1;
bool chain = (m == n - 1);
for (int i = 1; i <= m; i++) {
int u, v; scanf("%d%d", &u, &v);
if (u + 1 != v && v + 1 != u) chain = false;
e[i] = u ^ v;
g[u].push_back(i); g[v].push_back(i);
p2[i] = 2ll * p2[i - 1] % MOD;
}
if (chain) {
int res = 1ll * n * p2[m] % MOD;
res = (res + 1ll * n * (n - 1) / 2 % MOD * p2[m - 1] % MOD) % MOD;
printf("%d\n", res);
return 0;
}
int ans = 0;
for (int i = 1; i < (1 << n); i++) { // 枚举选点方案
int s = 0;
for (int j = 0; j < n; j++)
if (i & (1 << j)) {
s = j + 1; break;
}
int cnt = 0;
for (int j = 1; j <= m; j++) { // 枚举每条边
val = 0; ban = j;
for (int k = 1; k <= n; k++) vis[k] = false;
dfs(s); // 在不使用j这条边的情况下从s出发能到达哪些点
if ((i & val) != i) cnt++; // 这条边必须守
}
ans = (ans + p2[m - cnt]) % MOD;
}
printf("%d\n", ans);
return 0;
}
解题思路(100 分)
在一个边双连通分量中,任意一条边被切断都不会改变该分量的连通性。因此,若一个边双连通分量内包含军营,则该分量内部的边可以任意选择是否看守,共 \(2^k\) 种方案(假设 \(k\) 是该分量内部的边数)。
可以通过 Tarjan 算法找到所有的桥,并以此进行缩点。缩点后的图是一棵树,树上的边对应原图中的桥,树上的节点对应原图中的边双连通分量。
需要在树上选择若干个节点(这些节点在原图中包含至少一个军营),并保证连接这些节点的路径上的所有桥都必须被看守,不在这些路径上的桥以及各节点内部的边可以任意选择。
设缩点后第 \(i\) 个节点包含的城市数为 \(c_i\),内部边数为 \(e_i\)。
设 \(f_{u,0}\) 表示在以 \(u\) 为根的子树中没有建造任何军营的方案数,设 \(f_{u,1}\) 表示在以 \(u\) 为根的子树中至少建造了一个军营,且子树内所有军营都与 \(u\) 连通(即连接子树内所有军营的最小连通子图包含 \(u\))的方案数,设 \(s_u\) 为以 \(u\) 为根的子树内包含的边总数(包含节点内部边和子树内的桥)。
初始化:\(f_{u,0} = 2^{e_u}\)(不建军营,内部边随意)。\(f_{u,1} = 2^{e_u} \times (2^{c_u} - 1)\)(至少建一个军营,内部边随意)。
合并子树 \(v\)
- \(f_{u,0} = f_{u,0} \times 2 \times f_{v,0}\)(子树 \(v\) 没军营,桥 \((u,v)\) 可看守可不看守,共 2 种选择)。
- \(f_{u,1} = f_{u,1} \times (2 \times f_{v,0} + f_{v,1}) + f_{u,0} \times f_{v,1}\)。前半部分代表 \(u\) 侧已有军营,若 \(v\) 侧无军营,桥 \((u,v)\) 随意,若 \(v\) 侧有军营,桥 \((u,v)\) 必守。后半部分代表\(u\) 侧原本无军营,\(v\) 侧有军营,则桥 \((u,v)\) 必守。
为了避免重复计数,在每个连接所有军营的最小连通图的最高节点处统计方案。
对于节点 \(u\),若它是包含所有军营的最小连通图的根,则该方案数即为 \(f_{u,1}\) 中剔除“所有军营都在某个子树 \(v\) 中”的情况。设以 \(u\) 为最高点的合法方案数为 \(g_u\),则 \(g_u = f_{u,1} - \sum \limits_{v \in \text{ch}(u)} f_{v,1} \times 2^{s_u - (s_v + 1)}\),其中 \(s_u - (s_v + 1)\) 表示子树 \(u\) 中除去子树 \(v\) 及其上方那条桥之外的所有边。
最后,子树 \(u\) 以外的所有边(共有 \(m - s_u\) 条)都可以随意选择,故总答案为 \(\sum g_u \times 2^{m - s_u}\)。
时间复杂度为 \(O(n+m)\)。
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
using ll = long long;
const int N = 500005;
const int M = 1000005;
const int MOD = 1000000007;
struct Edge {
int to, id;
};
vector<Edge> a[N];
vector<int> tr[N];
ll p2[M], f[N][2], ans;
int n, m, dfn[N], low[N], timer, id[N], vc[N], ec[N], s[N];
bool b[N];
void tarjan(int u, int p) {
dfn[u] = low[u] = ++timer;
for (Edge e : a[u]) {
if (e.id == p) continue;
if (!dfn[e.to]) {
tarjan(e.to, e.id);
low[u] = min(low[u], low[e.to]);
if (low[e.to] > dfn[u]) b[e.id] = true;
} else {
low[u] = min(low[u], dfn[e.to]);
}
}
}
void dfs(int u, int c) {
id[u] = c;
for (Edge e : a[u]) {
if (b[e.id] || id[e.to]) continue;
dfs(e.to, c);
}
}
void dfs_sz(int u, int p) {
s[u] = ec[u];
for (int v : tr[u]) {
if (v == p) continue;
dfs_sz(v, u);
s[u] += s[v] + 1;
}
}
void dfs_dp(int u, int p) {
f[u][0] = p2[ec[u]];
f[u][1] = p2[ec[u]] * (p2[vc[u]] - 1 + MOD) % MOD;
ll sum = 0;
for (int v : tr[u]) {
if (v == p) continue;
dfs_dp(v, u);
ll f0 = f[u][0], f1 = f[u][1];
// 更新 f[u][0]: 子树 v 内不能有军营,桥 (u, v) 可守可不守
f[u][0] = f0 * 2 * f[v][0] % MOD;
// 更新 f[u][1]:
// 1. 原本 u 子树已有军营,v 子树无军营 (桥 2 种),或 v 子树有军营 (桥必守 1 种)
// 2. 原本 u 子树无军营,v 子树有军营 (桥必守 1 种)
f[u][1] = (f1 * (2 * f[v][0] % MOD + f[v][1]) % MOD + f0 * f[v][1] % MOD) % MOD;
// 记录所有以 v 为根的军营结构连通到 u 的方案数,用于后续计算以 u 为顶点的方案
sum = (sum + f[v][1] * p2[s[u] - (s[v] + 1)]) % MOD;
}
// 计算以 u 为顶点的方案数:即 f[u][1] 减去所有军营完全位于某一个子树 v 内且连通到 u 的方案
// 这些被减去的方案会在其对应的子树根处被统计
ll g = (f[u][1] - sum + MOD) % MOD;
ans = (ans + g * p2[m - s[u]]) % MOD;
}
int main()
{
scanf("%d%d", &n, &m);
for (int i = 0; i < m; i++) {
int u, v; scanf("%d%d", &u, &v);
a[u].push_back({v, i});
a[v].push_back({u, i});
}
p2[0] = 1;
for (int i = 1; i <= m; i++) p2[i] = p2[i - 1] * 2 % MOD;
tarjan(1, -1);
int cnt = 0;
for (int i = 1; i <= n; i++) {
if (!id[i]) dfs(i, ++cnt);
}
for (int i = 1; i <= n; i++) {
vc[id[i]]++;
for (Edge e : a[i]) {
if (id[e.to] == id[i]) {
ec[id[i]]++;
} else {
tr[id[i]].push_back(id[e.to]);
}
}
}
for (int i = 1; i <= cnt; i++) ec[i] /= 2;
dfs_sz(1, 0);
dfs_dp(1, 0);
printf("%lld\n", ans);
return 0;
}

浙公网安备 33010602011771号