P8435 【模板】点双

· · 题解

七月二十一号更新:更正了一处笔误。

先介绍几个概念:

连通分量:无向图中,满足任意两点之间都有路径相连的极大连通子图。也就是说,抽离出一些点以及它们之间的边,满足这些点任意两点之间,可以直接或间接到达对方,在这个前提下,满足抽离出的图越大越好,把抽离出的图叫做连通分量。

割点:无向图中,删除该点及与其相连的边后,图的连通分量数量增加,则称其为割点。换而言之,删除一个割点及相关边后,图中原来连通的两点不再连通,从而使得一个连通分量分裂成两个(或多个)连通分量。

点双连通:若对于一个无向图,其任意一个节点对于这个图本身而言都不是割点,则称其点双连通。也就是说,删除任意点及其相关边后,整个图仍然属于一个连通分量。

点双连通分量:无向图中,极大的点双连通子图。与连通分量类似,抽离出一些点及它们之间的边,使得抽离出的图是一个点双连通图,在这个前提下,使得抽离出的图越大越好。

先讲讲怎么求割点:

引入 Tarjan 算法。

我们把 DFS 遍历无向图过程中形成的图叫做搜索树,其中,从 u 到一个未被搜索过的节点 v 的一条边叫树边,回溯到一个祖先 v 的边叫返祖边。

定义 dfn_u 表示 u 在搜索树中的访问时间戳(第几个搜到),low_u 表示 u 通过返祖边可回溯到的最小时间戳。

初始时,low_u=dfn_u

对于一条树边 (u,v)low_u=\min\{low_v,low_u\},即:可以先下到 v,再回溯。

对于一条返祖边 (u,v),且 v 不是 u 的直系父亲,low_u = \min\{dfn_v, low_u\},即:直接回溯。

对于树边 (u,v),如果 low_v\geq dfn_u,即 v 和其子树能够通过返祖边回溯到的时间戳最小只能是 dfn_u,那么要把它们的时间戳回溯到到 dfn_u 之前就需要与 u 相关的边。也就是说这时如果把 u 去掉,与其有关的边全部消失,那么 low_v 不可能小于等于 dfn_u,也就是不可能回溯到时间戳比 u 更小的点,此时这个子树与其它点无法连通,u 就是割点。

注意:一个连通分量的搜索树的根节点一定满足上面的条件,因为在这个搜索树中,不存在一个 dfn 值比它小的节点,但是当且仅当其至少拥有两个以上的子树,它才能被称为割点。

画个图吧(红色代表根节点,圆角矩形代表一个子树):

显然如果这个图只存在根节点和其中一个子树时,由于根节点是第一访问的节点,它会被我们上面的判断条件误判为割点,但是它并不是一个割点,而当它有多个子树时,删除它会使得子树不再连通,这时它才是一个割点。

代码(核心部分):

inline void tarjan(int u, int fa) {
    int son = 0;//子树个数
    low[u] = dfn[u] = ++idx;//打上时间戳标记
    s[++top] = u;//u进入搜索树
    for(int i = fir[u]; i; i = nxt[i]) {
        int v = to[i];
        if(!dfn[v]) {//树边
            son++;
            tarjan(v, u);
            low[u] = min(low[u], low[v]);
            if(low[v] >= dfn[u]) cut[u] = true;
        } else if(v != fa) low[u] = min(low[u], dfn[v]);//返祖边
    }
    if(fa == 0 && son < 2) cut[u] = false;//是根节点,且子树小于2,不是割点
}

知道了割点怎么求,点双连通分量(接下来简称点双)就很好求了:

两个点双最多只有一个公共点(即都有边与之相连的点);且这个点在这两个点双和它形成的子图中是割点。

对于第一点,因为当它们有两个及以上公共点时,它们可以合并为一个新的点双(矩形代表一个点双,圆形代表公共点):

当有两个及以上公共点时,删除其中一个点及其与两个点双相连的边后,这两个点双总是可以通过另一个公共点到达彼此,属于一个连通分量,所以这些公共点对于这个子图而言并不是一个割点,按照定义,这两个点双和这些公共点应该是一个更大的点双。

对于第二点,与第一点类似,当对于这个子图而言它不是一个割点时,这两个点双也可以合并为一个新的点双:

当这个公共点对于这个子图不是一个割点时,也就意味着这两个点双有着另外的边相连,而这些边相连的点同样也是两个点双的公共点,可以归到第一种情况里。

对于一个点双,它在 DFS 搜索树中 dfn 值最小的点一定是割点或者树根。

当这个点是割点时,它所属的点双必定不可以向它的父亲方向包括更多点,因为一旦回溯,它就成为了新的子图的一个割点,不是点双。所以它应该归到其中一个或多个子树里的点双中。

当这个点是树根时,它的 dfn 值是整棵树里最小的。它若有两个以上子树,那么它是一个割点;它若只有一个子树,它一定属于它的直系儿子的点双,因为包括它;它若是一个独立点,视作一个单独的点双。

换句话说,一个点双一定在这两类点的子树中。

我们用栈维护点,当遇到这两类点时,将子树内目前不属于其它点双的非割点或在子树中的割点归到一个新的点双。注意这个点可能还是与其它点双的公共点,所以不能将其出栈。

本题代码如下:

#include <bits/stdc++.h>
using namespace std;
const int N = 5e5 + 5, M = 4e6 + 5;
int cnt = 1, fir[N], nxt[M], to[M];
int s[M], top, bcc, low[N], dfn[N], idx, n, m;
vector<int> ans[N];
inline void tarjan(int u, int fa) {
    int son = 0;
    low[u] = dfn[u] = ++idx;
    s[++top] = u;
    for(int i = fir[u]; i; i = nxt[i]) {
        int v = to[i];
        if(!dfn[v]) {
            son++;
            tarjan(v, u);
            low[u] = min(low[u], low[v]);
            if(low[v] >= dfn[u]) {
                bcc++;
                while(s[top + 1] != v) ans[bcc].push_back(s[top--]);//将子树出栈
                ans[bcc].push_back(u);//把割点/树根也丢到点双里
            }
        } else if(v != fa) low[u] = min(low[u], dfn[v]);
    }
    if(fa == 0 && son == 0) ans[++bcc].push_back(u);//特判独立点
}
inline void add(int u, int v) {
    to[++cnt] = v;
    nxt[cnt] = fir[u];
    fir[u] = cnt;
}
int main() {
    scanf("%d%d", &n, &m);
    for(int i = 1; i <= m; i++) {
        int u, v;
        scanf("%d%d", &u, &v);
        add(u, v), add(v, u);
    }
    for(int i = 1; i <= n; i++) {
        if(dfn[i]) continue;
        top = 0;
        tarjan(i, 0);
    }
    printf("%d\n", bcc);
    for(int i = 1; i <= bcc; i++) {
        printf("%d ", ans[i].size());
        for(int j : ans[i]) printf("%d ", j);
        printf("\n");
    }
    return 0;
}