1. 前言
割点与桥,是图论的一个分支,常使用 Tarjan 算法实现。
没错又是这个算法
注意割点与桥中的 Tarjan 算法与强连通分量中的 Tarjan 算法在具体实现上有所不同。
前置知识:
- dfs 树 / dfs 序
2. 割点
2.1 定义
割点的定义:在一张图中,如果删掉一个点之后,这张图的连通块个数增加,那么这个点就是这张图的割点。
比如下面这张图,4 号点就是割点。
需要注意的是,一张图可能会有多个割点。
2.2 求法
那么如何求割点呢?
采用 dfs 的方式求割点,此时显然会有一个 dfs 序与一棵 dfs 树。
在过程中记录两个值 d f n , l o w dfn,low dfn,low。 d f n dfn dfn 表示这个点在 dfs 中是第几个被访问到的,专用名词叫时间戳。 l o w low low 表示这个点在不经过其 dfs 树中的父亲节点(即过来的点)时能够回到的时间戳最小的点。初始值 l o w = d f n low=dfn low=dfn。
那么对于一条边 ( u , v ) (u,v) (u,v)(此处规定 dfs 树中 u u u 是 v v v 的父亲),如果 l o w v > = d f n u low_v>=dfn_u lowv>=dfnu,那么此时此刻 u u u 就是割点,因为 v v v 除了经过 u u u 不能再到 u u u 上面的点了。
如何更新 l o w low low 呢?
对于当前点 n o w now now,设其下一个走向点为 u u u,分两种情况:
-
u
u
u 没有被走过。
这个时候我们走向 u u u 继续 dfs。dfs 完之后令 l o w n o w ← min ( l o w n o w , l o w u ) low_{now} \leftarrow \min(low_{now},low_u) lownow←min(lownow,lowu),因为 u u u 能到 n o w now now 也一定能到。 -
u
u
u 被走过了。
此时直接更新 l o w n o w ← min ( l o w n o w , d f n u ) low_{now} \leftarrow \min(low_{now},dfn_u) lownow←min(lownow,dfnu) 即可,因为此时 n o w now now 没有经过父亲节点便走到了 u u u。
需要注意的是,代码中并没有对第 2 种情况单独拎出来处理,具体原因是对于任意点 u u u, d f n u ≥ l o w u dfn_u \geq low_u dfnu≥lowu。
因此实质上如果是第一种情况,更新 l o w n o w ← min ( l o w n o w , d f n u ) low_{now} \leftarrow \min(low_{now},dfn_u) lownow←min(lownow,dfnu) 这一步是无效的。
算法清晰,正确性显然,但是有一个点我们没有处理到。
dfs 开始时的节点呢?
这个节点在 dfs 树中是根节点,这个点的时间戳已经是最小的了,没办法更小。
因此对于这一个点,其判定方法为:当其进入下一层 dfs 次数不少于 2 次时,这个点为割点。
因为此时如果删掉根节点,由于往下递归了至少 2 次,说明至少会多出一个连通块。由定义,这个点为割点。
现在一切处理完毕。
代码:
/*
========= Plozia =========
Author:Plozia
Problem:P3388 【模板】割点(割顶)
Date:2021/5/10
========= Plozia =========
*/
#include <bits/stdc++.h>
typedef long long LL;
const int MAXN = 2e4 + 10, MAXM = 1e5 + 10;
int n, m, Head[MAXN], cnt_Edge = 1, dfn[MAXN], Low[MAXN], cnt_node;
struct node { int to, Next; } Edge[MAXM << 1];
bool book[MAXN];
int read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return sum * fh;
}
void add_Edge(int x, int y) { ++cnt_Edge; Edge[cnt_Edge] = (node){ y, Head[x] }; Head[x] = cnt_Edge; }
int Min(int fir, int sec) { return (fir < sec) ? fir : sec; }
void dfs(int now, int father)
{
dfn[now] = Low[now] = ++cnt_node; int val = 0;
for (int i = Head[now]; i; i = Edge[i].Next)
{
int u = Edge[i].to;
if (!dfn[u])
{
dfs(u, now); ++val;
Low[now] = Min(Low[now], Low[u]);
if (Low[u] >= dfn[now] && now != father) book[now] = 1;
}
Low[now] = Min(Low[now], dfn[u]);
}
if (now == father && val >= 2) book[now] = 1;
}
int main()
{
n = read(), m = read();
for (int i = 1; i <= m; ++i)
{
int x = read(), y = read();
add_Edge(x, y); add_Edge(y, x);
}
for (int i = 1; i <= n; ++i)
if (!dfn[i]) dfs(i, i);
int ans = 0;
for (int i = 1; i <= n; ++i)
if (book[i]) ++ans;
printf("%d\n", ans);
for (int i = 1; i <= n; ++i)
if (book[i]) printf("%d ", i);
printf("\n"); return 0;
}
3. 桥
笔者暂时没有找到模板题。
2.1 定义
桥的定义与割点的定义非常类似。
桥的定义:对于一条边 ( u , v ) (u,v) (u,v),如果删去这条边之后图的连通块个数增多,那么这条边为割边,也称其为桥。
比如下面这张图, ( 4 , 5 ) (4,5) (4,5) 就是这张图的桥。
同样的,一张图可能会有多个桥。
2.2 求法
同样采用 Tarjan 算法,仍然要维护 d f n , l o w dfn,low dfn,low。
不过这里对于边 n o w − > u now->u now−>u,如果 l o w u ≥ d f n n o w low_u \geq dfn_{now} lowu≥dfnnow,说明这条边为割边。
而特别的,我们在更新的时候 绝对不能走原来的边,也就是说哪条边过来我们不能从这条边上更新,否则此时会造成答案错误。
为什么?
因为实质上桥是 割点与一整块连通块之间的连边,所以如果更新了 l o w low low,就会出现这样一个情形:
如果回去的边能够被更新的话,那么:
一开始 d f n 1 = l o w 1 = 1 dfn_1=low_1=1 dfn1=low1=1。
因为回去的边可以被更新,那么 l o w 2 = 1 low_2=1 low2=1。
此时会满足 l o w 2 ≥ d f n 1 low_2 \geq dfn_1 low2≥dfn1, ( 1 , 2 ) (1,2) (1,2) 为桥。
同理可得, l o w 3 = 1 low_3=1 low3=1。
此时 l o w 3 < d f n 2 = 2 low_3 < dfn_2=2 low3<dfn2=2,算法判断其不为桥,但是实际上其为桥。
因而算法错误。
但是如果回去的边不能更新就不会有这个问题啦~
代码:
/*
========= Plozia =========
Author:Plozia
Problem:(暂无题目)【模板】桥
Date:2021/5/10
Remarks:本题的数据范围采用 P3388 的数据范围
========= Plozia =========
*/
#include <bits/stdc++.h>
typedef long long LL;
const int MAXN = 2e4 + 10, MAXM = 1e5 + 10;
int n, m, Head[MAXN], cnt_Edge = 1, Cut[MAXM << 1], Dfn[MAXN], Low[MAXN], cnt_node;
struct node { int to, Next; } Edge[MAXM << 1];
int read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return sum * fh;
}
void add_Edge(int x, int y) { ++cnt_Edge; Edge[cnt_Edge] = (node){y, Head[x]}; Head[x] = cnt_Edge; }
int Min(int fir, int sec) { return (fir < sec) ? fir : sec; }
void dfs(int now, int E)
{
Dfn[now] = Low[now] = cnt_node;
for (int i = Head[now]; i; i = Edge[i].Next)
{
int u = Edge[i].to;
if (!Dfn[u])
{
dfs(u, i);
Low[now] = Min(Low[now], Low[u]);
if (Low[u] >= Dfn[now]) Cut[i] = Cut[i ^ 1] = 1;
}
else if (i != (E ^ 1)) Low[now] = Min(Low[now], Dfn[u]);
}
}
int main()
{
n = read(), m = read();
for (int i = 1; i <= m; ++i)
{
int x = read(), y = read();
add_Edge(x, y); add_Edge(y, x);
}
for (int i = 1; i <= n; ++i)
if (!Dfn[i]) dfs(i, 0);
int ans = 0;
for (int i = 2; i <= cnt_Edge; i += 2)
if (Cut[i]) ++ans;
printf("%d\n", ans);
for (int i = 2; i <= cnt_Edge; i += 2)
if (Cut[i]) printf("%d %d\n", Edge[i ^ 1].to, Edge[i].to);
return 0;
}
2.3 易错点
这里谈两个最常见的易错点:
- 两个割点的连边一定是割边。
- 割边的两个端点一定是割点。
这两个实际上都是错的,反例如下:
上图中 2,3 是割点,但是 ( 2 , 3 ) (2,3) (2,3) 不是割边。
显然 ( 2 , 3 ) (2,3) (2,3) 为割边,但是 3 不是割点。
4. 总结
割点割边算法采用 Tarjan 算法求解。
d f n dfn dfn 为时间戳, l o w low low 为最早能够回到的点的最小时间戳(不经过父节点)。
再次提醒:不能与求强连通分量的 Tarjan 算法弄混!