图论专题-学习笔记:割点与桥
update:发现之前桥的 std 写错了,现已更正。
1. 前言
割点与桥,是图论的一个分支,常使用 Tarjan 算法实现。
没错又是这个算法
注意割点与桥中的 Tarjan 算法与强连通分量中的 Tarjan 算法在具体实现上有所不同。
前置知识:
- dfs 树 / dfs 序
2. 割点
2.1 定义
割点的定义:在一张图中,如果删掉一个点之后,这张图的连通块个数增加,那么这个点就是这张图的割点。
比如下面这张图,4 号点就是割点。
需要注意的是,一张图可能会有多个割点。
2.2 求法
那么如何求割点呢?
采用 dfs 的方式求割点,此时显然会有一个 dfs 序与一棵 dfs 树。
在过程中记录两个值 \(dfn,low\)。\(dfn\) 表示这个点在 dfs 中是第几个被访问到的,专用名词叫时间戳。\(low\) 表示这个点在不经过其 dfs 树中的父亲节点(即过来的点)时能够回到的时间戳最小的点。初始值 \(low=dfn\)。
那么对于一条边 \((u,v)\)(此处规定 dfs 树中 \(u\) 是 \(v\) 的父亲),如果 \(low_v\ge dfn_u\),那么此时此刻 \(u\) 就是割点,因为 \(v\) 除了经过 \(u\) 不能再到 \(u\) 上面的点了。
如何更新 \(low\) 呢?
对于当前点 \(now\),设其下一个走向点为 \(u\),分两种情况:
- \(u\) 没有被走过。
这个时候我们走向 \(u\) 继续 dfs。dfs 完之后令 \(low_{now} \leftarrow \min(low_{now},low_u)\),因为 \(u\) 能到 \(now\) 也一定能到。 - \(u\) 被走过了。
此时直接更新 \(low_{now} \leftarrow \min(low_{now},dfn_u)\) 即可,因为此时 \(now\) 没有经过父亲节点便走到了 \(u\)。
需要注意的是,代码中并没有对第 2 种情况单独拎出来处理,具体原因是对于任意点 \(u\),\(dfn_u \geq low_u\)。
因此实质上如果是第一种情况,更新 \(low_{now} \leftarrow \min(low_{now},dfn_u)\) 这一步是无效的。
算法清晰,正确性显然,但是有一个点我们没有处理到。
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)\),如果删去这条边之后图的连通块个数增多,那么这条边为割边,也称其为桥。
比如下面这张图,\((4,5)\) 就是这张图的桥。
同样的,一张图可能会有多个桥。
2.2 求法
同样采用 Tarjan 算法,仍然要维护 \(dfn,low\)。
不过这里对于边 \(now\to u\),如果 \(low_u>dfn_{now}\),说明这条边为割边。
而特别的,我们在更新的时候 绝对不能走原来的边,也就是说哪条边过来我们不能从这条边上更新,否则此时会造成答案错误(其实割点也不行,只不过因为割点判定时有一个等号所以不会造成答案错误)。
为什么?
因为实质上桥是 割点与一整块连通块之间的连边,所以如果更新了 \(low\),就会出现这样一个情形:
如果回去的边能够被更新的话,那么:
一开始 \(dfn_1=low_1=1\)。
因为回去的边可以被更新,那么 \(low_2=1\)。
此时会满足 \(low_2 \geq dfn_1\),\((1,2)\) 为桥。
同理可得,\(low_3=1\)。
此时 \(low_3 < dfn_2=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)\) 为割边,但是 3 不是割点。
4. 总结
割点割边算法采用 Tarjan 算法求解。
\(dfn\) 为时间戳,\(low\) 为最早能够回到的点的最小时间戳(不经过父节点)。
再次提醒:不能与求强连通分量的 Tarjan 算法弄混!