图的连通性
一、相关定义
1. 连通图
在无向图 GGG 中,若对于任意两点 xxx 与 yyy 有路径,则称 xxx 与 yyy 连通,图 GGG 一定为连通图;
2. 连通分量
无向图 GGG 的极大连通子图称为 GGG 的连通分量;
极大连通子图则为在图 G=(V,E)G = (V, E)G=(V,E) 中,对于连通子图 G′=(V′,E′),V′∈V,E′∈EG' = (V', E'), V' \in V, E' \in EG′=(V′,E′),V′∈V,E′∈E ,不存在包含 G′G'G′ 的更大的 GGG 的连通子图 G′′=(V′′,E′′)G'' = (V'', E'')G′′=(V′′,E′′) ,其中 V′∈V′′,U′∈U′′V' \in V'', U' \in U''V′∈V′′,U′∈U′′ ;
连通图只有一个连通分量,及其自身,而非连通的无向图有多个连通分量;
3. 强连通图
有向图 GGG 中,若对任意两点 xxx 与 yyy ,存在从 xxx 到 yyy 的路径以及从 yyy 到 xxx 的路径,则称 GGG 是强连通图;
4. 强连通分量
有向图 GGG 的极大强连通子图称为 GGG 的强连通分量;
极大强连通子图则为在图 G=(V,E)G = (V, E)G=(V,E) 中,对于强连通子图 G′=(V′,E′),V′∈V,E′∈EG' = (V', E'), V' \in V, E' \in EG′=(V′,E′),V′∈V,E′∈E ,不存在包含 G′G'G′ 的更大的 GGG 的强连通子图 G′′=(V′′,E′′)G'' = (V'', E'')G′′=(V′′,E′′) ,其中 V′∈V′′,U′∈U′′V' \in V'', U' \in U''V′∈V′′,U′∈U′′ ;
强连通图只有一个强连通分量,及其自身,而非连通的有向图有多个强连通分量;
5. 割点
割点
无向连通图删除某点后,使整个图变为不连通的两部分的点;
割点集
所有割点的集合称为割点集;
点连通度
最小的割点集点数;
6. 割边 (桥)
割边 (桥)
无向连通图删除某边后,使整个图变为不连通的两部分的边;
割边集
所有割边的集合称为割边集;
边连通度
最小的割边集边数;
7. 双连通图
点双连通图
若无向图中删掉任意一个结点图仍联通,即不存在割点,则将此图称作点双连通图;
边双连通图
若无向图中删掉任意一条边图仍联通,即不存在桥,则将此图称作边双连通图;
8. 双连通分量
点双连通分量
无向图的每一个极大点双连通子图称作此无向图的点双连通分量 (v - DCC);
极大点连通双连通子图则为在图 G=(V,E)G = (V, E)G=(V,E) 中,对于点双连通子图 G′=(V′,E′),V′∈V,E′∈EG' = (V', E'), V' \in V, E' \in EG′=(V′,E′),V′∈V,E′∈E ,不存在包含 G′G'G′ 的更大的 GGG 的点双连通子图 G′′=(V′′,E′′)G'' = (V'', E'')G′′=(V′′,E′′) ,其中 V′∈V′′,U′∈U′′V' \in V'', U' \in U''V′∈V′′,U′∈U′′ ;
边双连通分量
无向图的每一个极大边双连通子图称作此无向图的边双连通分量 (e - DCC);
极大边连通双连通子图则为在图 G=(V,E)G = (V, E)G=(V,E) 中,对于边双连通子图 G′=(V′,E′),V′∈V,E′∈EG' = (V', E'), V' \in V, E' \in EG′=(V′,E′),V′∈V,E′∈E ,不存在包含 G′G'G′ 的更大的 GGG 的边双连通子图 G′′=(V′′,E′′)G'' = (V'', E'')G′′=(V′′,E′′) ,其中 V′∈V′′,U′∈U′′V' \in V'', U' \in U''V′∈V′′,U′∈U′′ ;
区别与联系
-
均基于无向图;
-
点双联通图为删点后联通性不变,而边双连通分量为删边后联通性不变;
-
点双连通分量一定为边双连通分量,反之不一定;
可将删点看作删除与该点相连的边,由于图删除点后仍联通,则图删除点与点相连的边后仍联通,则图为边双连通分量;但若图为边双连通分量,则为只删一条边图仍联通,但不能保证删除所有与点相连通的边后图仍连通;
-
点双连通分量可以有公共点,但边双连通分量不能有公共边;
若边双连通分量有公共边,则其不再为极大子图;
二、连通性判断
1. 判断无向图的联通性
思路
由于若有边 (x,y)(x, y)(x,y) ,则所有与 xxx 连通的节点均可通过此边与 yyy 连通,所以使用并查集,对于每条边,将边两顶点的连通集合合并,最终统计并查集根结点的数量,可得到连通分量的个数;
代码
int n, m, father[MAXN], tot;
bool flag[MAXN];
void firstset(int n) {
for (int i = 1; i <= n; i++) {
father[i] = i;
}
return;
}
int findset(int x) {
if (father[x] == x) return x;
return father[x] = findset(father[x]);
}
void push(int x, int y) {
int a = findset(x), b = findset(y);
if (a != b) father[a] = b;
return;
}
int main() {
scanf("%d %d", &n, &m);
firstset(n);
for (int i = 1; i <= m; i++) {
int x, y;
scanf("%d %d", &x, &y);
push(x, y); // 通过此边连通两点
}
for (int i = 1; i <= n; i++) {
if (!flag[findset(i)]) { // 找不同的连通分量
flag[findset(i)] = true;
tot++;
}
}
printf("%d", tot);
return 0;
}
2. 判断图中任意两点连通性
思路
可通过 Floyd 算法判断图中两点是否连通,还可在判断的同时,进行传递闭包计算;
代码
void firstset(int n) {
for (int k = 1; k <= n; k++) {
for (int i = 1; i <= n; i++) {
for (int j =1; j <= n; j++) {
dp[i][j] |= dp[i][k] & dp[k][j]; // 传递闭包
}
}
}
return;
}
bool check (int x, int y) {
return dp[x][y]; // 返回 x 与 y 的联通性
}
3. 判断图是否存在环
无向图
使用 DFS 遍历图,若在遍历过程中访问到已访问过节点则有环;
代码
bool dfs(int i) {
colour[i] = true; // 该节点已被遍历
for (int t = 0; t < g[i].size(); t++) { // 遍历子节点
int v = g[i][t];
fa[v] = i;
if (fa[i] != v) { // 遍历节点不为父节点
if (colour[v] == true) return false; // 重复走到同一节点,有环
bool tot = dfs(v); // 继续搜索
if (tot == false) return false;
}
}
return true;
}
有向图
若仍然使用无向图判环的方法,则由于可能有多条路径从一点到另一点,所以找不到环;
由于环可从环内一点经过环走回其本身;
则可在搜索时标记,当遇到从一点起搜索,还未回溯时,再次访问该节点,则有环;
将为访问节点标记为 0 ,将已经遍历完成的节点标记为 2 ,将已访问但未回溯的节点标记为 1 ;
对图进行 DFS 遍历,若重复访问标记为 1 的节点,则说明原图有环;
代码,
bool dfs(int i) {
colour[i] = 1; // 染色为已访问但未回溯
for (int t = 0; t < g[i].size(); t++) { // 遍历子节点
int v = g[i][t];
if (colour[v] == 1) return false; // 若子节点搜索时回到搜索路径上,则有环
bool tot = dfs(v); // 继续搜索
if (tot == false) return false;
}
colour[i] = 2; // 染色为完成搜索
return true;
}
三、Tarjan 算法
1. Tarjan 算法
Tarjan 算法时间复杂度为 O(V+E)O(V + E)O(V+E) ;
该算法利用了 DFS 遍历图的过程所记录的 DFN 与追溯值,可用来计算强连通分量与割点,割边;
2. 时间戳
在图的深度优先遍历过程中,按照每个节点被遍历的时间顺序,依次标记所有节点,该标记称为时间戳;
3. 搜索树
在连通图中任选一个节点出发进行深度优先遍历,每个点只访问一次,所有经过的边构成一棵树,此树则为搜索树;
若图不连通,则将图的各个连通块的搜索树构成无向图的搜索森林;
4. 追溯值
1. 定义
low[x]low[x]low[x] 为 xxx 子节点与 xxx 经过一条不在搜索树上的边 (反向边) 所能够到达的最小时间戳;
2. 求法
当以第一次访问一个节点 vvv 时, dfn[v]dfn[v]dfn[v] 与 low[v]low[v]low[v] 相同;
然后遍历 vvv 的子节点,
- 当节点未被访问过,由于 vvv 的子节点可以通过反向边遍历到的 vvv 的祖先节点,又由于 vvv 可以通过其与子节点的边遍历到其子节点遍历到的节点,则 low[v]low[v]low[v] 可通过其子节点的 lowlowlow 值改变,所以 low[v]low[v]low[v] 为其子节点的 lowlowlow 值的最小值;
- 当节点已被访问过,则说明此边为一条反向边,由于 lowlowlow 为只经过一条反向边所到达的节点 的时间戳最小值,所以 low[v]low[v]low[v] 不能再向上沿反向边遍历,所以 low[v]low[v]low[v] 取其与当前节点的 dfndfndfn 值的最小值;
则对图进行 DFS 遍历,遍历 iii 时进行初始化 low[i]=dfn[i]low[i] = dfn[i]low[i]=dfn[i] ,当遍历 iii 的子节点 vvv 时,如果 vvv 没有被遍历过,则先遍历 vvv ,得到子节点的 lowlowlow 值,然后使 low[i]=min(low[i],low[v])low[i] = min(low[i], low[v])low[i]=min(low[i],low[v]) ,如果 vvv 被遍历过,则使 low[i]=min(low[i],dfn[v])low[i] = min(low[i], dfn[v])low[i]=min(low[i],dfn[v]) ;
6. 代码
以邻接表存储图为例;
void tarjan(int i) {
dfn[i] = low[i] = ++len; // 初始化
for (int t = 0; t < g[i].size(); t++) { // 遍历子节点
int v = g[i][t];
if (!dfn[v]) tarjan(v), low[i] = min(low[i], low[v]); // v 未被遍历过
else low[i] = min(low[i], dfn[v]); // v 已被遍历过
}
retvrn;
}
四、强连通分量
1. 思路
如果某个节点 vvv 的 low[v]==dfn[n]low[v] == dfn[n]low[v]==dfn[n] ,表明该节点的 lowlowlow 值无法通过其父节点或孩子节点改变,则表明该节点不存在能够到达该节点的祖先节点的反向边,则该节点一定在一个由其自己与其的子孙节点组成的强连通分量中;
2. 实现
则对图进行 tarjan 算法,在遍历的同时,用一个栈存储遍历过的节点,
在遍历 iii 的子节点 vvv 时,如果 vvv 没有被遍历过,则先遍历 vvv ,得到子节点的 lowlowlow 值,然后使 low[i]=min(low[i],low[v])low[i] = min(low[i], low[v])low[i]=min(low[i],low[v]) ;
若 vvv 已经遍历过,
-
若 vvv 属于某个强连通分量,说明此边为 iii 与 vvv 之间的一条交叉边,不为当前所求以 iii 为根的强连通分量的边,所以不应对 vvv 的 lowlowlow 值产生影响;
-
在 vvv 不属于任何强连通分量时,表明此边为 iii 与 vvv 之间的环的一部分,则可用 dfn[v]dfn[v]dfn[v] 更新 low[i]low[i]low[i] ;
若计算完 low[i]low[i]low[i] 后,得到 low[i]==dfn[i]low[i] == dfn[i]low[i]==dfn[i] ,则说明搜索栈中直到上一次访问到 iii 点之间的所有节点均属于以 iii 为根的强连通分量;
3. 代码
void tarjan(int i) {
dfn[i] = low[i] = ++len;
s.push(i);
for (int t = 0; t < g[i].size(); t++) {
int v = g[i][t];
if (!dfn[v]) tarjan(v), low[i] = min(low[i], low[v]);
else if (!sccno[v]) low[i] = min(low[i], dfn[v]); // 当 v 不在任何强连通分量时,才用其的值更新 low[i]
}
if (dfn[i] == low[i]) { // i 为一个强连通分量的跟
tot++; // 数量
while (1) {
int t = s.top();
s.pop();
sccno[t] = tot; // 将节点放入强连通分量中
if (t == i) break;
}
}
return;
}
// 主程序
for (int i = 1; i <= n; i++) {
if (!sccno[i]) { // 使每个节点均有所属的强连通分量
tarjan(i);
}
}
4. 缩点
思路
枚举每一条边,若边上的两端点属于不同的强连通分量,则说明可以通过此边将两个强连通分量连接;
代码
vector <int> g1[MAXN]; // 存储缩点后的图
void resetpoint() {
for (int i = 1; i <= n; i++) {
for (int t = 0; t < g[i].size(); t++) {
int v = g[i][t];
if (sccno[i] != sccno[v]) { // 若两点不在同一个强连通分量
g1[sccno[i]].push_back(sccno[v]); // 重新建边
}
}
}
return;
}
5. 构造强连通图
描述
给出一个有向图,求增加最少的边使得原图成为强连通图;
思路
由于强连通分量中的节点互相连通,则只需使各个强连通分量连通即可;
先求出图中所有的强连通分量,然后对原图进行缩点,得到一个 DAG 图,若缩点后的图有 tot1tot1tot1 个入度为 0 的强连通分量,有 tot2tot2tot2 个出度为 0 的强连通分量;
若一个节点的入度为 0 ,则此点是不能从其父节点向下遍历到的;如果一个节点的出度为 0 ,则此点是不能从子节点向上遍历到的;
则只要在每组出度为 0 的点和入度为 0 的点之间连一条的边,即可使图强连通;
则添加的边数为 max(tot1,tot2)max(tot1, tot2)max(tot1,tot2);
代码
#include <cstdio>
#include <vector>
#include <cstring>
#include <stack>
#include <algorithm>
#define MAXN 10005
using namespace std;
int n, m, dfn[MAXN], low[MAXN], len, sccno[MAXN], tot;
vector <int> g[MAXN], g1[MAXN];
stack <int> s;
bool in[MAXN], out[MAXN];
void tarjan(int i) {
dfn[i] = low[i] = ++len;
s.push(i);
for (int t = 0; t < g[i].size(); t++) {
int v = g[i][t];
if (!dfn[v]) tarjan(v), low[i] = min(low[i], low[v]);
else if (!sccno[v]) low[i] = min(low[i], dfn[v]); // 当 v 不在任何强连通分量时,才用其的值更新 low[i]
}
if (dfn[i] == low[i]) { // i 为一个强连通分量的跟
tot++; // 数量
while (1) {
int t = s.top();
s.pop();
sccno[t] = tot; // 将节点放入强连通分量中
if (t == i) break;
}
}
return;
}
void resetpoint() {
for (int i = 1; i <= n; i++) {
for (int t = 0; t < g[i].size(); t++) {
int v = g[i][t];
if (sccno[i] != sccno[v]) { // 若两点不在同一个强连通分量
g[sccno[i]].push_back(sccno[v]); // 重新建边
out[sccno[i]] = in[sccno[v]] = true; // 统计入/出度
}
}
}
return;
}
int num() {
int tot1 = 0, tot2 = 0;
for (int i = 1; i <= tot; i++) {
if (in[i] == false) tot1++; // 计算入/出度为 0 的强连通分量个数
if (out[i] == false) tot2++;
}
return max(tot1, tot2);
}
int main() {
scanf("%d %d", &n, &m);
for (int i = 1; i <= m; i++) {
int x, y;
scanf("%d %d", &x, &y);
g[x].push_back(y);
}
for (int i = 1; i <= n; i++) {
if (!sccno[i]) {
tarjan(i);
}
}
resetpoint();
printf("%d", num());
return 0;
}
五、割点
1. 思路
当图进行 DFS 遍历时,会得到一颗搜索树,
则对于 iii 为割点时,条件有且仅有以下 2 个;
-
iii 是搜索树的顶点,且 iii 在搜索树中至少有 2 个子节点;
删除 iii 后, iii 在搜索树中的各个子节点不连通,所以 iii 为割点;
-
iii 不为搜索树顶点,且对于 iii 的子节点 vvv 有, low[v]>=dfn[i]low[v] >= dfn[i]low[v]>=dfn[i] ;
对于 low[v]>=dfn[i]low[v] >= dfn[i]low[v]>=dfn[i] 时,即 vvv 通过其子节点或一条反向边只能走到 iii 的子节点及其自身,不能到达 iii 的祖先节点,所以当把 iii 点删除后, vvv 的子节点无法与 iii 的祖先节点联通,所以 iii 为割点;
2. 代码
void tarjan(int i) {
dfn[i] = low[i] = ++len; // 初始化
int tot = 0;
for (int t = 0; t < g[i].size(); t++) { // 遍历子节点
int v = g[i][t];
if (!dfn[v]) {
tot++; // 记录搜索树中根结点的子树数量
tarjan(v); // 计算节点 v low 值
low[i] = min(low[i], low[v]); // 更新 low 值
if ((i == root && tot > 1) || (i != root && low[v] >= dfn[i])) { // 判断是否为割点
cnt[i] = true; // 进行标记
}
} else {
low[i] = min(low[i], dfn[v]); // 更新 low 值
}
}
return;
}
// 主程序
for (int i = 1; i <= n; i++) {
if (!dfn[i]) { // 所有点均遍历到
len = 0; // 注意初始化
root = i; // 根结点开始搜索
tarjan(i);
}
}
六、桥
1. 思路
对于无向图中一条边 (i,v)(i, v)(i,v) 为桥时,当且仅当
- low[v]>dfn[i]low[v] > dfn[i]low[v]>dfn[i]
即 vvv 通过其子节点或一条反向边只能走到 iii 的子节点,不能到达 iii 的祖先节点以其本身,所以当把 (i,v)(i, v)(i,v) 删除后, vvv 的子节点无法与 iii 的祖先节点及其自身联通,所以 (i,v)(i, v)(i,v) 为割边;
注意,在进行 tarjan 算法时,应判断将要搜索的边是否为当前边的反向边,若是,则不应更新其的 lowlowlow 值,否则则更新;
对于桥的标记,由于是无向图,需要双向建边,所以当找到桥时,要将两向的边标记,所以可用链式前向星存图,双向边存在相邻位上,再利用 ^ 的性质,对于 iii ^ 1,若 iii 为奇数,则 iii ^ 1 等于其 i+1i + 1i+1 ;若 iii 为偶数,则 iii ^ 1 等于其 i−1i - 1i−1 ,则当得到 ttt 为桥时,则 ttt ^ 1 也为桥;
则为对于枚举边寻找桥时,应只找编号为奇数的边;
2. 代码
void add(int x, int y) { // 建边
ver[++tot] = y, net[tot] = head[x], head[x] = tot;
return;
}
void tarjan(int i, int e) {
dfn[i] = low[i] = ++len; // 初始化
for (int t = head[i]; t; t = net[t]) {
int v = ver[t];
if (!dfn[v]) {
tarjan(v, t); // 遍历子节点
low[i] = min(low[i], low[v]);
if (low[v] > dfn[i]) { // 满足为桥的条件
bridge[t] = bridge[t ^ 1] = true; // 标记桥
}
} else if (t != (e ^ 1)) { // 若不为当前边反向边
low[i] = min(low[i], dfn[v]);
}
}
return;
}
int main() {
scanf("%d %d", &n, &m);
for (int i = 1; i <= m; i++) {
int x, y;
scanf("%d %d", &x, &y);
add(x, y), add(y, x); // 双向相邻建边
}
for (int i = 1; i <= n; i++) {
if (!dfn[i]) {
tarjan(i, 0);
}
}
for (int i = 1; i <= tot; i += 2) { // 只枚举奇数号边
if (bridge[i]) ans++;
}
printf("%d\n", ans);
return 0 ;
}
七、双连通分量
1. 点双连通分量
思路
由于点双连通分量之间由割点连接,则对于一个 e-dcc 最先遍历到的一定为搜索树根节点或割点;
所以对图进行 DFS 遍历后,割点的搜索子树即为一个 v-dcc ;
但由于割点为 DFS 遍历完后从下向上回溯时寻找,所以包含树根的 v-dcc 不会被计算到,所以对于搜索树树根无论其子树大小,也应将其看作一个 v-dcc 的根;
则求法为,
- 先进行 tarjan 算法求割点,用栈记录遍历的节点;
- 每找到一个割点或树根,将其搜索树子树上的节点弹出栈,点集则为一个 v-dcc ,再将当前割点放入栈继续搜索;
代码
void tarjan(int i) {
dfn[i] = low[i] = ++len; // 初始化
s.push(i); // 使用栈存储遍历过的节点
int tot1 = 0;
for (int t = 0; t < g[i].size(); t++) { // 遍历子节点
int v = g[i][t];
if (!dfn[v]) {
tot1++; // 记录搜索树中根结点的子树数量
tarjan(v); // 计算节点 v low 值
low[i] = min(low[i], low[v]); // 更新 low 值
if (low[v] >= dfn[i]) { // 判断是否为割点,但不需要判断是否为搜索树根结点
if ((i != root) || (i == root && tot1 > 1)) cnt[i] = true; // 存储割点用于缩点
tot++; // 点双连通分量数量
while (1) {
int x = s.top();
s.pop();
dcc[tot].push_back(x); // 放入点双连通分量
if (v == x) break;
}
dcc[tot].push_back(i); // 放入割点
}
} else {
low[i] = min(low[i], dfn[v]); // 更新 low 值
}
}
return;
}
// 主程序
for (int i = 1; i <= n; i++) {
if (!dfn[i]) { // 所有点均遍历到
len = 0; // 注意初始化
root = i; // 根结点开始搜索
tarjan(i);
}
}
缩点
思路
由于割点可能同时存在多个点双连通分量中,所以将每个割点与每个 v-DCC 作为缩点后的节点建图;
则先对所有的割点从 v-dcc 的个数开始重新编号,作为新图的节点编号,然后枚举每个 v-dcc 若当前 v-dcc 中有割点,则将割点与当前 v-dcc 建边,得到图;
代码
void resetpiont() {
int num = tot; // 缩点新编号,从 v-dcc 个数开始
for (int i = 1; i <= n; i++) {
if (cnt[i] == true) {
id[i] = ++num;
}
}
for (int i = 1; i <= tot; i++) { // 枚举每个 e-dcc
for (int j = 0; j < dcc[i].size(); j++) {
int v = g[i][j];
if (cnt[v]) { // 当前节点为割点
g1[i].push_back(id[v]); // 建边
g1[id[v]].push_back(i);
} else {
c[v] = i; // 记录节点 i 所在 v-dcc
}
}
}
}
2. 边双连通分量
思路
由于各个 e-dcc 由桥边连接,又由于桥边不会属于任何一个 e-dcc ,所以只需计算出桥,然后将桥删除,剩余部分即为 e-dcc ;
则求法为,
- 先进行 tarjan 算法计算桥;
- 删除桥;
- 剩余部分为边双连通分量;
代码
void tarjan(int i, int e) {
dfn[i] = low[i] = ++len; // 初始化
for (int t = head[i]; t; t = net[t]) {
int v = ver[t];
if (!dfn[v]) {
tarjan(v, t); // 遍历子节点
low[i] = min(low[i], low[v]);
if (low[v] > dfn[i]) { // 满足为桥的条件
bridge[t] = bridge[t ^ 1] = true; // 标记桥
}
} else if (t != (e ^ 1)) { // 若不为当前边反向边
low[i] = min(low[i], dfn[v]);
}
}
return;
}
void dfs(int i, int dcc) {
c[i] = dcc; // i 属于 dcc 号 e-dcc
for (int t = head[i]; t; t = net[t]) { // 继续遍历 i 子节点
int v = ver[t];
if (!c[v] && !bridge[t]) dfs(v, dcc); // 若 v 未被搜索过且当前边不为桥
}
return;
}
// 主程序
for (int i = 1; i <= n; i++) {
if (!dfn[i]) {
tarjan(i, 0); // 计算桥边
}
}
for (int i = 1; i <= n; i++) {
if (!c[i]) {
tot++; // 当前 e-dcc 编号
dfs(i, tot);
}
}
缩点
思路
枚举每一条边,若边上的两端点属于不同的 e-dcc ,则说明可以通过此边将两个 e-dcc 连接,则建边;
由于两个 e-dcc 由桥连接,则说明两 e-dcc 没有其他的连接方式,则缩点后的 e-dcc 为一颗树;
代码
void resetpoint() {
for (int i = 1; i <= e; i += 2) { // 枚举每一条边
int x = ver[i], y = ver[i ^ 1];
if (c[x] != c[y]) { // 若边上两点不在同一 e-dcc 则建边
g1[c[x]].push_back(c[y]);
g1[c[y]].push_back(c[x]);
}
}
}
构造边双连通图
思路
构造边双连通图,即增加边使所有的 e-dcc 连通,由于缩点后 e-dcc 为树形,所以增加的最少边则在树的叶子节点间增加,则若缩点后树叶子节点为 leafleafleaf 个,则最少增加边为 (leaf+1)/2(leaf + 1) / 2(leaf+1)/2 ;
注意,由于建边时不知道两顶点的顺序,所以建边时,两顶点入度均 + 1 ,最后入度为 1 的即为叶子节点;
代码
#include <cstdio>
#include <vector>
#include <cstring>
#include <algorithm>
#define MAXN 500005
using namespace std;
int e = 1, ver[MAXN * 2], net[MAXN * 2], head[MAXN * 2];
int n, m, low[MAXN], dfn[MAXN], len, ans1, ans2, root, c[MAXN], tot = 0, leaf = 0, in[MAXN];;
bool bridge[MAXN];
void add(int x, int y) {
ver[++e] = y, net[e] = head[x], head[x] = e;
return;
}
void tarjan(int i, int e) {
dfn[i] = low[i] = ++len; // 初始化
for (int t = head[i]; t; t = net[t]) {
int v = ver[t];
if (!dfn[v]) {
tarjan(v, t); // 遍历子节点
low[i] = min(low[i], low[v]);
if (low[v] > dfn[i]) { // 满足为桥的条件
bridge[t] = bridge[t ^ 1] = true; // 标记桥
}
} else if (t != (e ^ 1)) { // 若不为当前边反向边
low[i] = min(low[i], dfn[v]);
}
}
return;
}
void dfs(int i, int dcc) {
c[i] = dcc; // i 属于 dcc 号 e-dcc
for (int t = head[i]; t; t = net[t]) { // 继续遍历 i 子节点
int v = ver[t];
if (!c[v] && !bridge[t]) dfs(v, dcc); // 若 v 未被搜索过且当前边不为桥
}
return;
}
void resetpoint() {
for (int i = 1; i <= e; i += 2) { // 枚举每一条边
int x = ver[i ^ 1], y = ver[i];
if (c[x] != c[y]) { // 若边上两点不在同一 e-dcc 则建边
in[c[x]]++; // 统计节点入度,双节点都加
in[c[y]]++;
}
}
}
int main() {
scanf("%d %d", &n, &m);
for (int i = 1; i <= m; i++) {
int x, y;
scanf("%d %d", &x, &y);
add(x, y), add(y, x);
}
for (int i = 1; i <= n; i++) {
if (!dfn[i]) {
tarjan(i, 0); // 计算桥边
}
}
for (int i = 1; i <= n; i++) {
if (!c[i]) {
tot++; // 当前 e-dcc 编号
dfs(i, tot);
}
}
resetpoint();
for (int i = 1; i <= tot; i++) {
if (in[i] == 1) { // 入度为 1 则为叶子节点
leaf++;
}
}
printf("%d", (leaf + 1) / 2);
return 0 ;
}