图论专题-学习笔记:割点与桥

update:发现之前桥的 std 写错了,现已更正。

1. 前言

割点与桥,是图论的一个分支,常使用 Tarjan 算法实现。

没错又是这个算法

注意割点与桥中的 Tarjan 算法与强连通分量中的 Tarjan 算法在具体实现上有所不同。

前置知识:

  • dfs 树 / dfs 序

2. 割点

例题:P3388 【模板】割点(割顶)

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 算法弄混!

posted @ 2022-04-17 15:32  Plozia  阅读(451)  评论(0编辑  收藏  举报