洛谷

洛谷题单指南-连通性问题-P1656 炸铁路(无向图的双连通分量,割边、割点)

原题链接:https://www.luogu.com.cn/problem/P1656

题意解读:求无向图中所有的割边。

解题思路:

本题开始学习无向图的双连通性,在此之前建议先学习有向图的强连通性相关知识:https://www.cnblogs.com/hackerchef/p/18851009

一、基本概念

  1. 连通性 (Connectivity)

连通图:无向图中任意两个顶点之间都存在路径相连。如果图中存在多个互不连通的子图,则称为非连通图。

连通分量:无向图中的极大连通子图。对于连通图,整个图本身就是一个连通分量;对于非连通图,则包含多个连通分量。

  1. 双连通性 (Biconnectivity)

双连通性分为两种类型,分别针对边和顶点的删除:

(1) 边双连通性 (Edge Biconnectivity)

定义:一个连通无向图如果删除任意一条边后仍然保持连通,则称为边双连通图。

关键概念:

割边/桥 (Bridge):割边也称为桥,在无向图中,如果移除某条边后,图的连通分量数量增加,那么这条边就被称为割边。

边双连通分量:极大边双连通子图(不包含任何桥的极大子图)
内部连通性:在一个边双连通分量中,任意两个顶点之间都至少存在两条边不重复的路径。
极大性:无法再向该分量中添加其他顶点和边,使其成为更大的边双连通子图。
缩点性质:如果将每个边双连通分量看作一个 “缩点”,那么原图就可以转化为一棵由这些缩点组成的树(如果原图是连通的),树中的边就是原图中的割边。
(2) 点双连通性 (Vertex Biconnectivity)

定义:一个连通无向图如果删除任意一个顶点(及其关联边)后仍然保持连通,则称为点双连通图。

关键概念:

割点:在无向图中,若移除某个顶点以及与之相连的所有边后,图的连通分量数量增加,那么这个顶点就被称为割点。

点双连通分量:极大点双连通子图

内部连通性:点双连通分量内任意两点之间至少存在两条点不重复的路径。
极大性:无法向该分量中添加其他顶点和边以构成更大的点双连通子图。
割点的特殊性:两个不同的点双连通分量最多只有一个公共顶点,且这个公共顶点是原图中的割点。若两个点双连通分量有多个公共顶点,那么删除其中一个顶点后,这两个分量仍能保持连通,这与点双连通分量的定义不符,所以公共顶点只能是割点。
缩点性质:将每个点双连通分量看作一个 “缩点”,同时将原图中的割点保留,可得到一个新的图结构。在这个新结构中,割点连接着不同的点双连通分量缩点,呈现出一种类似树状的结构(如果原图是连通的),不过与边双连通分量缩点后得到的严格树形结构有所不同,这里割点可能会连接多个缩点。
边的特性:点双连通分量中的边具有这样的特点,对于任意一条边,其两个端点必然在同一个点双连通分量中。而且,若删除该边,不会影响该分量内其他顶点之间的点双连通性。
二、求割边和边双连通分量

1、算法原理

求解边双连通分量和割边通常采用 Tarjan 算法,其核心思想基于深度优先搜索(DFS)。在 DFS 过程中,为每个顶点记录两个重要信息:

dfn[u]:u的DFS序号
low[u]:u通过树边和回边(不能走指向父节点的边)能追溯到的最小的dfn值
对于一条边u->v,若low[v] > dfn[u],则u->v是割边。

这是因为从v及其子孙节点无法通过其他路径回到u或其祖先节点,所以删除u->v会使图的连通性改变。

求边双连通分量有两种方法:

可以将所有割边删除,再在图中进行dfs求出所有边双连通分量

更简单的做法是类似求强联通分量,用栈保存dfs中的节点,在回溯时,如果low[u] == dfn[u],说明u是当前栈中节点双连通分量的根,对堆栈进行处理即可。

注意:在求边双连通分量时,由于是无向图,不存在方向问题,一条边的两个端点可以互相到达,只需要找到割边,然后在原图上将割边屏蔽进行遍历(不能往回走),每次找到的连通块就是一个边双连通分量,不需要判断节点是否在堆栈中。

2、缩点流程

使用 Tarjan 算法求出所有边双连通分量和割边。
如果两个边双连通分量之间存在割边,则在缩点后的图中连接它们。
最终形成一棵树(或森林,如果原图不连通)。
3、代码示例

void tarjan(int u, int p)
{
    dfn[u] = low[u] = ++timestamp;
    stk.push(u);

    for(int i = head[u]; i; i = nxt[i])
    {
        int v = to[i];
        if(v == p) continue; //不能往回走
        if(!dfn[v])
        {
            tarjan(v, u);
            low[u] = min(low[u], low[v]);

            if(low[v] > dfn[u]) //找到割边
            {
                bridge.push_back({min(u, v), max(u, v)});
            }
        }
        else
        {
            low[u] = min(low[u], dfn[v]);
        }
    }

    if(low[u] == dfn[u]) //找到双连通分量的起点
    {
        cnt++;
        while(true)
        {
            int t = stk.top(); stk.pop();
            ebcc[t] = cnt;
            if(t == u) break;
        }
    }
}

三、求割点和点双连通分量

1、算法原理

Tarjan 算法是求解无向图割点和点双连通分量的经典算法,基于深度优先搜索(DFS)实现。在 DFS 过程中,为每个顶点维护两个重要信息:
dfn[u]:u的DFS序号
low[u]:u通过树边和回边(不能走指向父节点的边)能追溯到的最小的dfn值
对于顶点u,判断其是否为割点的规则如下:
如果u是根节点(DFS 树的根),且u有至少两个子节点,那么u是割点。
如果u不是根节点,且存在一个子节点v使得low[v] >= dfn[u],那么u是割点。这是因为从v及其子孙节点出发,无法通过回边到达u的祖先节点(不包括u本身)。也就是说,一旦去掉u这个顶点,v及其子孙节点就会与图的其他部分断开联系,导致图的连通分量数量增加,所以u是割点。
在 DFS 过程中,使用栈来记录访问的边,这是因为不同的点双连通分量可以有相同的割点,不能把点归到某一个点双连通分量,但是一个点双连通分量中边是唯一的。实际操作时,只需要记录访问到的点,当发现割点时,从栈中弹出点,直到遇到当前割点的子节点,这些点加上当前割点构成一个点双连通分量,弹栈后割点还要保留在栈中,以访问包含这个割点的其他点双连通分量。

注意:求点双连通分量时,通常是通过判断割点来确定。在无向图的深度优先搜索中,通过维护一个父节点指针p来避免重复访问父节点导致的错误判断,而不需要借助堆栈来判断节点是否在某个特定的集合中。

2、缩点流程

使用 Tarjan 算法求出所有点双连通分量和割点。
如果割点u属于某个点双连通分量B,则在缩点后的图中连接u和B。

最终形成一棵树(或森林,如果原图不连通)。

3、代码示例

void tarjan(int u, int p)
{
    dfn[u] = low[u] = ++timestamp;
    stk.push(u);

    int child = 0;
    for(int i = head[u]; i; i = nxt[i])
    {
        int v = to[i];
        if(v == p) continue;
        if(!dfn[v])
        {
            child++;
            tarjan(v, u);
            low[u] = min(low[u], low[v]);
            if(low[v] >= dfn[u]) //找到割点u
            {
                if(p != 0 || child >= 2) //u不是根节点:直接判定割点 u是根节点:子节点大于2才是割点
                    cut[u] = true;
                //栈到u的点都是点双连通分量,但弹栈只到v,因为u还是其他连通分量的割点
                cnt++;
                vbcc[cnt].push_back(u); //割点肯定属于点双连通分量,但不能从栈中弹出
                while(true)
                {
                    int t = stk.top(); stk.pop();
                    vbcc[cnt].push_back(t);
                    if(t == v) break; //弹栈一直到v,都属于一个点双连通分量
                }
            }
        }
        else low[u] = min(low[u], dfn[v]);
    }
}

4、圆方树

将原图中的点称为圆点,代表电双连通分量的点称为方点,那么这样的一棵树形结构被称为圆方树。圆方树的建立只需要在找到割点以及点双连通分量时,将当前点双连通分量代表的点编号记为cnt+n,再建立cnt+n到所有电双连通分量中点的边。

这样一来,

圆方树中,所有割点都是度大于1的点,所有方点的度就是所代表点双连通分量的大小。

每个点双连通分量形成一个“菊花图”,多个“菊花图”通过原图中的割点连接在一起。

回到本题,只需要求割边即可。

100分代码:

#include <bits/stdc++.h>
using namespace std;

const int N = 155, M = 5005;
int head[N], to[M], nxt[M], idx = 1; //idx=1,也就是编号从2开始是为了便于通过idx^1判得到反向边
int dfn[N], low[N], timestamp;
stack<int> stk;
vector<pair<int, int>> bridge; //保存所有割边
//int ebcc[N], cnt; //标记节点属于哪个边双连通分量
int n, m;

void add(int a, int b)
{
    to[++idx] = b;
    nxt[idx] = head[a];
    head[a] = idx;
}

void tarjan(int u, int p)
{
    dfn[u] = low[u] = ++timestamp;
    stk.push(u);

    for(int i = head[u]; i; i = nxt[i])
    {
        int v = to[i];
        if(v == p) continue; //不能往回走
        if(!dfn[v])
        {
            tarjan(v, u);
            low[u] = min(low[u], low[v]);

            if(low[v] > dfn[u]) //找到割边
            {
                bridge.push_back({min(u, v), max(u, v)});
            }
        }
        else
        {
            low[u] = min(low[u], dfn[v]);
        }
    }
    /*
    if(low[u] == dfn[u]) //找到双连通分量的起点
    {
        cnt++;
        while(true)
        {
            int t = stk.top(); stk.pop();
            ebcc[t] = cnt;
            if(t == u) break;
        }
    }
    */
}

int main()
{
    cin >> n >> m;
    for(int i = 1; i <= m; i++)
    {
        int u, v;
        cin >> u >> v;
        add(u, v), add(v, u);
    }
    for(int i = 1; i <= n; i++)
        if(!dfn[i])
            tarjan(i, 0);
    sort(bridge.begin(), bridge.end());
    for(auto p : bridge) cout << p.first << " " << p.second << endl;
    return 0;
}