题解:AcWing 396 矿场搭建

【题目来源】

AcWing:396. 矿场搭建 - AcWing题库

【题目描述】

煤矿工地可以看成是由隧道连接挖煤点组成的无向图。

为安全起见,希望在工地发生事故时所有挖煤点的工人都能有一条出路逃到救援出口处。

于是矿主决定在某些挖煤点设立救援出口,使得无论哪一个挖煤点坍塌之后,其他挖煤点的工人都有一条道路通向救援出口。

请写一个程序,用来计算至少需要设置几个救援出口,以及不同最少救援出口的设置方案总数。

【输入】

输入文件有若干组数据,每组数据的第一行是一个正整数 \(N\),表示工地的隧道数。

接下来的 \(N\) 行每行是用空格隔开的两个整数 \(S\) 和 \(T\)\(S≠T\)),表示挖煤点 \(S\) 与挖煤点 \(T\) 由隧道直接连接。

注意,每组数据的挖煤点的编号为 \(1∼Max\),其中 \(Max\) 表示由隧道连接的挖煤点中,编号最大的挖煤点的编号,可能存在没有被隧道连接的挖煤点。

输入数据以 \(0\) 结尾。

【输出】

每组数据输出结果占一行。

其中第 \(i\) 行以 Case i: 开始(注意大小写,Case 与 i 之间有空格,i 与 : 之间无空格,: 之后有空格)。

其后是用空格隔开的两个正整数,第一个正整数表示对于第 \(i\) 组输入数据至少需要设置几个救援出口,第二个正整数表示对于第 \(i\) 组输入数据不同最少救援出口的设置方案总数。

输入数据保证答案小于 \(2^{64}\),输出格式参照以下输入输出样例。

【输入样例】

9
1  3
4  1
3  5
1  2
2  6
1  5
6  3
1  6
3  2
6
1  2
1  3
2  4
2  5
3  6
3  7
0

【输出样例】

Case 1: 2 4
Case 2: 4 1

【核心思想】

  1. 问题分析:给定一个无向图,需要在某些节点设置救援出口,使得删除任意一个节点(挖煤点坍塌)后,剩余节点都能到达某个救援出口。这等价于求点双连通分量中的割点分布,并在每个点双连通分量中合理设置出口。

  2. 算法选择

    • Tarjan 算法求点双连通分量:通过 \(dfn\)\(low\) 数组找出图中的所有点双连通分量
    • 割点判定:利用 \(low[y] \geq dfn[x]\) 判断节点 \(x\) 是否为割点
    • 分类讨论:根据每个点双连通分量中割点的数量,决定需要设置的出口数量和方案数
  3. 关键步骤

    • 建图:读入 \(m\) 条边,构建无向图邻接表,确定最大节点编号 \(n\)
    • Tarjan 求点双连通分量
      • 对每个未访问的节点作为根节点执行 \(tarjan(root)\)
      • 使用栈维护当前 DFS 路径上的节点
      • 当发现 \(low[y] \geq dfn[x]\) 时,找到一个点双连通分量,将栈中节点弹出直到 \(y\)
    • 统计每个分量中的割点数量 \(tmp\)
      • 情况1\(tmp = 0\),无割点):
        • 若分量大小为 \(1\)(孤立点):需要设置 \(1\) 个出口,方案数 \(\times 1\)
        • 若分量大小 \(\geq 2\):需要设置 \(2\) 个出口,方案数 \(\times C(size, 2) = \frac{size \times (size-1)}{2}\)
      • 情况2\(tmp = 1\),一个割点):需要设置 \(1\) 个出口在非割点位置,方案数 \(\times (size - 1)\)
      • 情况3\(tmp \geq 2\),多个割点):该分量被多个割点保护,无需设置出口
    • 输出答案:最少出口数 \(res\) 和总方案数 \(num\)
  4. 时间/空间复杂度

    • 时间复杂度:\(O(n + m)\),Tarjan 算法线性遍历所有节点和边
    • 空间复杂度:\(O(n + m)\),存储图结构和辅助数组
  5. 点双连通分量与割点的核心思想

    • 点双连通分量:图中删除任意一个节点后仍然连通的极大子图
    • 割点作用:割点是连接不同点双连通分量的"桥梁",删除割点会将图分割成多个连通块
    • 出口设置策略
      • 无割点的分量:必须内部设置出口,且至少 \(2\) 个(防止出口所在点坍塌)
      • 有一个割点的分量:割点坍塌时分量孤立,需在非割点设 \(1\) 个出口
      • 有多个割点的分量:任意点坍塌都能通过其他割点逃出,无需设出口
    • 孤立点特判:单个孤立点只需设置 \(1\) 个出口(自己就是出口)
    • 适用于网络可靠性、关键节点识别、冗余设计类问题

【算法标签】

双连通分量

【代码详解】

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

// 定义无符号长整型别名,用于存储方案数
typedef unsigned long long ULL;

// ================= 常量与全局变量 =================

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

int n;                       // 当前测试用例的节点数
int m;                       // 当前测试用例的边数

vector<int> e[N];            // 原图邻接表
vector<int> ne[N];           // 未使用的邻接表(保留原样)

// ---------- Tarjan 相关 ----------
int dfn[N];                  // 时间戳:节点首次被访问的顺序
int low[N];                  // 能通过回边到达的最小时间戳
int tot;                     // 时间戳计数器
int cnt;                     // 点双连通分量计数器

stack<int> stk;              // 栈,存储当前正在处理的节点
vector<int> dcc[N];          // 存储每个点双连通分量包含的节点

// ---------- 割点相关 ----------
int cut[N];                  // 标记节点是否为割点(1表示是割点)
int root;                    // 当前 DFS 树的根节点
int num;                     // 方案数(用于统计)
int id[N];                   // 未使用的数组(保留原样)

// ================= Tarjan 算法求点双连通分量 =================
void tarjan(int x)
{
    dfn[x] = low[x] = ++tot;    // 初始化时间戳
    stk.push(x);                // 当前节点入栈

    // 孤立点:单独形成一个点双连通分量
    if (!e[x].size())
    {
        dcc[++cnt].push_back(x);
        return;
    }

    int child = 0;              // 记录当前节点在 DFS 树中的子节点数

    // 遍历 x 的所有邻接点
    for (int y : e[x])
    {
        if (!dfn[y])            // y 尚未访问
        {
            tarjan(y);          // 递归访问 y
            low[x] = min(low[x], low[y]);   // 用子树更新 low[x]

            // 判断 x 是否为割点
            if (low[y] >= dfn[x])
            {
                child++;

                // 如果 x 不是根节点,或者 x 是根节点且有多个子节点
                if (x != root || child > 1)
                    cut[x] = true;

                // 找到一个点双连通分量
                cnt++;
                while (1)
                {
                    int z = stk.top();      // 取出栈顶
                    stk.pop();              // 弹出栈顶
                    dcc[cnt].push_back(z);  // 将节点加入当前分量
                    if (z == y)             // 直到弹出 y 为止
                        break;
                }
                dcc[cnt].push_back(x);      // 将 x 也加入当前分量
            }
        }
        else                    // 发现回边
        {
            low[x] = min(low[x], dfn[y]);   // 用回边更新 low[x]
        }
    }
}

// ================= 主函数 =================
int main()
{
    int T = 1;                  // 测试用例编号

    // 多组测试数据,直到 m = 0 结束
    while (cin >> m, m)
    {
        // 清空上一组数据
        for (int i = 1; i <= cnt; i++)
            dcc[i].clear();
        for (int i = 1; i <= n; i++)
            e[i].clear();
        while (!stk.empty())
            stk.pop();

        // 重置计数器
        tot = cnt = n = 0;
        memset(cut, 0, sizeof(cut));
        memset(dfn, 0, sizeof(dfn));

        // 读入 m 条边
        while (m--)
        {
            int a, b;
            cin >> a >> b;
            n = max(n, a);          // 更新最大节点编号
            n = max(n, b);
            e[a].push_back(b);      // 添加无向边
            e[b].push_back(a);
        }

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

        // ========= 统计答案 =========
        int res = 0;            // 最少需要放置的消防站数量
        ULL num = 1;            // 方案数

        // 遍历每个点双连通分量
        for (int i = 1; i <= cnt; i++)
        {
            int tmp = 0;        // 当前分量中割点的数量

            // 统计当前分量中割点的个数
            for (int j = 0; j < dcc[i].size(); j++)
                if (cut[dcc[i][j]])
                    tmp++;

            // 情况1:分量中没有割点
            if (tmp == 0)
            {
                // 孤立点:需要放 1 个消防站
                if (dcc[i].size() == 1)
                    res += 1;
                // 非孤立点:需要放 2 个消防站,方案数为 C(size, 2)
                else
                {
                    res += 2;
                    num *= (ULL)dcc[i].size() * (dcc[i].size() - 1) / 2;
                }
            }
            // 情况2:分量中有 1 个割点
            else if (tmp == 1)
            {
                res++;                              // 需要在非割点中放 1 个消防站
                num *= dcc[i].size() - 1;           // 方案数为非割点个数
            }
            // 情况3:分量中有 2 个及以上割点,无需放置消防站
        }

        // 输出结果
        printf("Case %d: %d %llu\n", T++, res, num);
    }

    return 0;
}

【运行结果】

9
1  3
4  1
3  5
1  2
2  6
1  5
6  3
1  6
3  2
Case 1: 2 4
6
1  2
1  3
2  4
2  5
3  6
3  7
Case 2: 4 1
0
posted @ 2026-06-17 17:24  团爸讲算法  阅读(9)  评论(0)    收藏  举报