题解:洛谷 P8435 点双连通分量

【题目来源】

洛谷:P8435 【模板】点双连通分量 - 洛谷

【题目描述】

对于一个 \(n\) 个节点 \(m\) 条无向边的图,请输出其点双连通分量的个数,并且输出每个点双连通分量。

【输入】

第一行,两个整数 \(n\)\(m\)

接下来 \(m\) 行,每行两个整数 \(u, v\),表示一条无向边。

【输出】

第一行一个整数 \(x\) 表示点双连通分量的个数。

接下来的 \(x\) 行,每行第一个数 \(a\) 表示该分量结点个数,然后 \(a\) 个数,描述一个点双连通分量。

你可以以任意顺序输出点双连通分量与点双连通分量内的结点。

【输入样例】

5 8
1 3
2 4
4 3
1 2
4 5
5 1
2 4
1 1

【输出样例】

1
5 1 2 3 4 5

【核心思想】

  1. 问题分析:给定一个包含 \(n\) 个节点 \(m\) 条边的无向图,需要找出所有的点双连通分量。点双连通分量是指图中不存在割点的最大子图,即删除任意一个节点后子图仍然连通。这是一个点双连通分量分解问题,可以使用 Tarjan 算法求解。

  2. 算法选择

    • Tarjan 算法求点双连通分量:基于 DFS,通过 \(dfn\)\(low\) 数组判断割点并分解点双连通分量
    • \(dfn\) 数组:记录每个节点的 DFS 序(时间戳)
    • \(low\) 数组:记录通过回边能到达的最小 \(dfn\)
    • 割点判断条件:对于节点 \(x\) 的子节点 \(y\),若 \(low[y] \geq dfn[x]\),则 \(x\) 是割点
    • 栈维护:使用栈记录当前 DFS 路径上的节点,遇到割点时弹出形成点双连通分量
  3. 关键步骤

    • 建图:读入 \(n\)(节点数)、\(m\)(边数),使用邻接表存储无向图(忽略自环)
    • Tarjan DFS(从节点 \(x\) 出发,\(root\) 为当前 DFS 树的根):
      • 初始化 \(dfn[x] = low[x] = ++tot\),节点入栈
      • 特殊情况:若 \(x\) 是孤立节点(无邻接边),单独形成一个点双连通分量
      • 遍历邻接点 \(y\)
        • 树边\(y\) 未访问):
          • 递归访问 \(y\),回溯时 \(low[x] = \min(low[x], low[y])\)
          • 割点判断:若 \(low[y] \geq dfn[x]\),说明 \(y\) 无法通过回边到达 \(x\) 的祖先,\(x\) 是割点
          • 根节点特殊处理:若 \(x\) 是根且子树数 \(> 1\),则 \(x\) 是割点
          • 创建点双连通分量\(cnt++\),弹出栈中节点直到 \(y\),这些节点与 \(x\) 构成一个分量
        • 回边\(y\) 已访问):\(low[x] = \min(low[x], dfn[y])\)
    • 处理非连通图:遍历所有节点,对未访问的节点执行 Tarjan
    • 输出结果:输出点双连通分量数量及每个分量的节点
  4. 时间/空间复杂度

    • 时间复杂度:\(O(n + m)\),每个节点和边只被访问一次
    • 空间复杂度:\(O(n + m)\),邻接表、\(dfn\)/\(low\) 数组、栈、分量数组
    • 适用于点双连通性分析和图的分解问题
  5. 点双连通分量的核心思想

    • 点双连通分量定义:不存在割点的最大子图,删除任意一个节点后子图仍连通
    • 割点的作用:割点是连接不同点双连通分量的关键节点,一个割点可能属于多个分量
    • 与边双连通分量的区别
      • 点双连通分量关注节点,边双连通分量关注边
      • 点双连通分量中任意两点间有两条点不相交路径
      • 边双连通分量中任意两点间有两条边不相交路径
    • 栈的使用技巧:DFS 过程中节点入栈,遇到割点时弹出形成分量,割点保留在栈中供后续使用
    • 割点的多重归属:割点会被加入多个点双连通分量,非割点只属于一个分量
    • 适用于图的分解、网络可靠性分析、关键节点识别等场景

【解题思路】

img/Pasted image 20260521090628.png
img/Pasted image 20260521090744.png

【算法标签】

普及+ #双连通分量

【代码详解】

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

const int N = 500005;        // 最大节点数

vector<int> e[N];            // 原图的邻接表
vector<int> ne[N];           // 缩点后新图的邻接表(未使用)

int dfn[N];                  // DFS序(时间戳)
int low[N];                  // 通过回边能到达的最小DFN值
int tot;                     // 时间戳计数器

stack<int> stk;              // Tarjan算法栈
vector<int> dcc[N];          // 存储每个点双连通分量的节点

int cut[N];                  // 标记节点是否为割点
int root;                    // 当前DFS树的根节点
int cnt;                     // 点双连通分量计数器
int num;                     // 新图节点计数器(用于缩点建图)
int id[N];                   // 节点在新图中的编号(用于缩点)

int n, m, a, b;              // n:节点数, m:边数, a,b:临时变量

/**
 * Tarjan算法求点双连通分量
 * @param x 当前节点
 */
void tarjan(int x)
{
    // 初始化当前节点的DFN和LOW值
    dfn[x] = low[x] = ++tot;

    // 将当前节点压入栈
    stk.push(x);

    // 特殊情况:孤立节点(没有邻接边)
    if (!e[x].size())
    {
        dcc[++cnt].push_back(x);  // 创建一个新的点双连通分量
        return;
    }

    int child = 0;  // 记录根节点的子树数量(用于割点判断)

    // 遍历当前节点的所有邻接节点
    for (int y : e[x])
    {
        // 如果邻接节点y未被访问(树边)
        if (!dfn[y])
        {
            tarjan(y);                      // 递归访问y
            low[x] = min(low[x], low[y]);   // 更新low值

            // 判断割点条件:low[y] >= dfn[x]
            if (low[y] >= dfn[x])
            {
                child++;  // 增加子树计数

                // 割点判断:如果不是根节点,或者根节点有多个子树
                if (x != root || child > 1)
                {
                    cut[x] = true;  // 标记x为割点
                }

                // 创建一个新的点双连通分量
                cnt++;

                // 弹出栈中节点直到遇到y
                while (true)
                {
                    int z = stk.top();
                    stk.pop();
                    dcc[cnt].push_back(z);  // 将节点加入当前分量
                    if (z == y)
                    {
                        break;
                    }
                }

                // 将当前节点x也加入该分量(割点属于多个分量)
                dcc[cnt].push_back(x);
            }
        }
        // 如果y已被访问(回边)
        else
        {
            low[x] = min(low[x], dfn[y]);   // 通过回边更新low值
        }
    }
}

int main()
{
    // 输入节点数和边数
    cin >> n >> m;

    // 输入所有边
    while (m--)
    {
        cin >> a >> b;

        // 忽略自环边
        if (a == b)
        {
            continue;
        }

        // 添加无向边
        e[a].push_back(b);
        e[b].push_back(a);
    }

    // 对每个未访问的节点执行Tarjan算法
    for (root = 1; root <= n; root++)
    {
        if (!dfn[root])
        {
            tarjan(root);
        }
    }

    // 为新图分配节点编号(割点单独编号)
    num = cnt;  // 初始编号从cnt+1开始
    for (int i = 1; i <= n; i++)
    {
        if (cut[i])
        {
            id[i] = ++num;  // 为割点分配新编号
        }
    }

    // 输出点双连通分量信息
    cout << cnt << endl;  // 输出分量数量
    for (int i = 1; i <= cnt; i++)
    {
        // 输出当前分量的节点数和节点列表
        cout << dcc[i].size() << " ";
        for (int j = 0; j < dcc[i].size(); j++)
        {
            cout << dcc[i][j] << " ";
        }
        cout << endl;
    }

    return 0;
}
#include <bits/stdc++.h>
using namespace std;
const int N = 500005, M = 2000005 * 2;
int n, m;  // n: 节点数,m: 边数
int h[N], e[M], ne[M], idx;  // 邻接表存储图
int dfn[N], low[N], tot;  // dfn: DFS序,low: 追溯值,tot: 时间戳
stack<int> stk;  // 栈,用于Tarjan算法
int con_cnt, con_size[N], con_id[N];  // con_cnt: 点双连通分量数量,con_size: 分量大小,con_id: 节点所属分量编号
bool cut[N];  // 记录节点是否为割点
vector<int> ans[N];  // 存储每个点双连通分量的节点

// 添加无向边
void add(int a, int b)
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}

// Tarjan算法求点双连通分量
void tarjan(int u, int fa)
{
    int child = 0;  // 子树计数
    dfn[u] = low[u] = ++tot;  // 初始化dfn和low为时间戳
    stk.push(u);  // 节点入栈

    for (int i = h[u]; i != -1; i = ne[i])  // 遍历u的所有邻接点
    {
        int v = e[i];  // 邻接点v
        if (!dfn[v])  // 如果v未被访问
        {
            child++;
            tarjan(v, u);  // 递归访问v
            low[u] = min(low[u], low[v]);  // 更新low[u]

            if (low[v] >= dfn[u])  // 判断u是否为割点
            {
                cut[u] = true;  // 标记u为割点
                con_cnt++;  // 增加点双连通分量计数
                while (1)  // 弹出栈中节点,直到遇到v
                {
                    int y = stk.top();  // 取出栈顶
                    stk.pop();  // 弹出栈顶
                    con_id[y] = con_cnt;  // 记录节点y所属的分量编号
                    con_size[con_cnt]++;  // 更新分量大小
                    ans[con_cnt].push_back(y);  // 将节点加入分量
                    if (y == v)  // 直到弹出v为止
                        break;
                }
                ans[con_cnt].push_back(u);  // 将割点u加入分量
            }
        }
        else if (v != fa)  // 如果是回退边且不是父节点
        {
            low[u] = min(low[u], dfn[v]);  // 更新low[u]
        }
    }

    if (fa == 0 && child == 0)  // 如果u是孤立节点
    {
        con_cnt++;  // 增加点双连通分量计数
        con_id[u] = con_cnt;  // 记录节点u所属的分量编号
        con_size[con_cnt]++;  // 更新分量大小
        ans[con_cnt].push_back(u);  // 将节点加入分量
        stk.pop();  // 弹出栈顶
    }
}

int main()
{
    cin >> n >> m;  // 输入节点数和边数
    memset(h, -1, sizeof(h));  // 初始化邻接表

    while (m--)  // 读入所有无向边
    {
        int a, b;
        cin >> a >> b;
        add(a, b), add(b, a);  // 添加无向边
    }

    for (int i = 1; i <= n; i++)  // 遍历所有节点
        if (dfn[i] == 0)  // 如果节点未访问
            tarjan(i, 0);  // 执行Tarjan算法

    cout << con_cnt << endl;  // 输出点双连通分量数量

    for (int i = 1; i <= con_cnt; i++)  // 遍历所有点双连通分量
    {
        cout << ans[i].size() << " ";  // 输出分量大小
        for (int j = 0; j < ans[i].size(); j++)  // 输出分量内的所有节点
            cout << ans[i][j] << " ";
        cout << endl;
    }
    return 0;
}

【运行结果】

5 8
1 3
2 4
4 3
1 2
4 5
5 1
2 4
1 1
1
5 5 2 4 3 1
posted @ 2026-06-16 10:15  团爸讲算法  阅读(10)  评论(0)    收藏  举报