题解:AcWing 352 闇の連鎖

【题目来源】

AcWing:352. 闇の連鎖 - AcWing题库

【题目描述】

传说中的暗之连锁被人们称为 Dark。

Dark 是人类内心的黑暗的产物,古今中外的勇者们都试图打倒它。

经过研究,你发现 Dark 呈现无向图的结构,图中有 \(N\) 个节点和两类边,一类边被称为主要边,而另一类被称为附加边。

Dark 有 \(N – 1\) 条主要边,并且 Dark 的任意两个节点之间都存在一条只由主要边构成的路径。

另外,Dark 还有 \(M\) 条附加边。

你的任务是把 Dark 斩为不连通的两部分。

一开始 Dark 的附加边都处于无敌状态,你只能选择一条主要边切断。

一旦你切断了一条主要边,Dark 就会进入防御模式,主要边会变为无敌的而附加边可以被切断。

但是你的能力只能再切断 Dark 的一条附加边。

现在你想要知道,一共有多少种方案可以击败 Dark。

注意,就算你第一步切断主要边之后就已经把 Dark 斩为两截,你也需要切断一条附加边才算击败了 Dark。

【输入】

第一行包含两个整数 \(N\)\(M\)

之后 \(N – 1\) 行,每行包括两个整数 \(A\)\(B\),表示 \(A\)\(B\) 之间有一条主要边。

之后 \(M\) 行以同样的格式给出附加边。

【输出】

输出一个整数表示答案。

【输入样例】

4 1
1 2
2 3
1 4
3 4

【输出样例】

3

【核心思想】

  1. 问题分析:给定一棵由 \(N-1\) 条主要边构成的树,以及 \(M\) 条附加边。需要选择一条主要边切断,再选择一条附加边切断,使得图不连通。求满足条件的方案数。这是一个树上差分 + LCA问题,关键在于利用差分数组统计每条主要边被多少条附加边覆盖,然后根据覆盖数量计算方案数。

  2. 算法选择

    • 树上差分:将路径统计问题转化为单点更新,通过 d[u]++, d[v]++, d[lca]-=2 标记路径
    • LCA(最近公共祖先):使用倍增法在 \(O(\log N)\) 时间内求两个节点的LCA
    • DFS统计:通过后序遍历累加子树差分值,得到每条边被覆盖的次数
  3. 关键步骤

    • 建树:读取 \(N-1\) 条主要边,构建树的邻接表
    • LCA预处理:使用BFS预处理每个节点的深度和倍增祖先数组 fa[i][j]
    • 差分标记(处理 \(M\) 条附加边):
      • 对于每条附加边 \((u, v)\),计算 \(p = lca(u, v)\)
      • d[u]++:路径起点加1
      • d[v]++:路径终点加1
      • d[p] -= 2:LCA处减2(差分思想,抵消重复计算)
    • DFS统计
      • 后序遍历累加子树的差分值:res += d[u]
      • 对于每条主要边(连接父节点 \(fa\) 和子节点 \(u\)):
        • 若子树覆盖次数 \(s = 0\):该边未被任何附加边覆盖,切断后需切断任意一条附加边,贡献 \(M\) 种方案
        • 若子树覆盖次数 \(s = 1\):该边被恰好1条附加边覆盖,切断后需切断那条附加边,贡献 \(1\) 种方案
        • 若子树覆盖次数 \(s \geq 2\):该边被多条附加边覆盖,切断后无法通过切断一条附加边使图不连通,贡献 \(0\) 种方案
    • 累加所有方案数得到答案
  4. 时间/空间复杂度

    • 时间复杂度:\(O((N + M) \log N)\),LCA预处理 \(O(N \log N)\),每条附加边处理 \(O(\log N)\),DFS统计 \(O(N)\)
    • 空间复杂度:\(O(N \log N)\),倍增祖先数组 fa[N][17] 和邻接表
  5. 树上差分的核心思想

    • 路径统计降维:将树路径上的统计问题转化为端点的单点更新,通过 d[u]++, d[v]++, d[lca]-=2 实现 \(O(1)\) 路径标记
    • 差分还原:通过DFS后序遍历累加子树差分值,将差分数组还原为每条边的实际覆盖次数
    • LCA抵消技巧:在LCA处减2是为了抵消从LCA到根路径上的重复累加,确保只有 \(u\)\(v\) 的路径被正确统计
    • 边权转化:通过将边权下放到子节点,将边的问题转化为节点的问题,方便差分处理
    • 适用于树上路径统计、路径覆盖次数查询等问题

【算法标签】

树上差分

【代码详解】

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

// 定义数组最大容量
const int N = 100005, M = 200005 * 2;

// 全局变量声明
int n;                  // 节点数量
int m;                  // 操作次数(查询次数)
int ans;                // 最终答案
int h[N];               // 邻接表头结点
int e[M];               // 存储边的终点
int ne[M];              // 存储下一条边的索引
int idx;                // 边的编号计数器
int d[N];               // 差分数组,记录节点被访问次数的变化
int dep[N];             // 节点深度
int fa[N][17];          // 倍增法求LCA的祖先数组,fa[i][j]表示节点i的第2^j个祖先
queue<int> q;           // BFS队列

// 添加一条无向边 a <-> b
void add(int a, int b)
{
    e[idx] = b;         // 存储边的终点
    ne[idx] = h[a];     // 头插法
    h[a] = idx++;       // 更新头结点
}

// 预处理节点深度和祖先信息(BFS)
void bfs(int root)
{
    // 初始化深度数组为无穷大
    memset(dep, 0x3f, sizeof(dep));

    dep[0] = 0;         // 虚拟节点0的深度为0
    dep[root] = 1;      // 根节点深度为1

    q.push(root);       // 根节点入队

    // BFS遍历整棵树
    while (!q.empty())
    {
        int t = q.front();  // 取出队首节点
        q.pop();

        // 遍历当前节点的所有邻接节点
        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];   // 邻接节点
            // 如果节点j尚未计算深度
            if (dep[j] > dep[t] + 1)
            {
                dep[j] = dep[t] + 1;  // 更新深度
                q.push(j);             // 入队
                fa[j][0] = t;          // j的第2^0=1个祖先是其父节点

                // 倍增预处理:计算j的所有2^k级祖先
                for (int k = 1; k <= 16; k++)
                {
                    // j的第2^k级祖先 = j的第2^(k-1)级祖先的第2^(k-1)级祖先
                    fa[j][k] = fa[fa[j][k - 1]][k - 1];
                }
            }
        }
    }
}

// LCA倍增算法:计算节点x和节点y的最近公共祖先
int lca(int x, int y)
{
    // 保证x是深度较大的节点
    if (dep[x] < dep[y])
        swap(x, y);

    // 步骤1:将节点x和节点y调整到同一深度
    // 例如深度相差11,先向上走2^3=8,再走2^1=2,最后走2^0=1
    for (int k = 16; k >= 0; k--)  // 从最大的跳跃步数开始
        if (dep[fa[x][k]] >= dep[y])
            x = fa[x][k];

    // 如果调整后x和y重合,说明y就是LCA
    if (x == y)
        return x;

    // 步骤2:两个节点同时向上跳,直到它们的父节点相同
    // 例如向上跳11,先向上走2^3=8,再走2^1=2,最后走2^0=1
    for (int k = 16; k >= 0; k--)
    {
        // 如果向上跳2^k后不相同,说明还没有到达LCA的下一层
        if (fa[x][k] != fa[y][k])
        {
            x = fa[x][k];
            y = fa[y][k];
        }
    }

    // 返回最近公共祖先(x或y的父节点)
    return fa[x][0];
}

// DFS遍历,计算子树中被覆盖的节点数并返回给父节点
int dfs(int u, int fa)
{
    int res = d[u];     // 当前节点的覆盖次数

    // 遍历所有子节点
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int v = e[i];   // 子节点
        if (v == fa)    // 跳过父节点,防止回到上一层
            continue;

        int s = dfs(v, u);  // 递归计算子树
        if (s == 0)         // 如果子树中没有被覆盖的节点
            ans += m;       // 需要新建一个集合覆盖该子树
        else if (s == 1)    // 如果子树中只有1个被覆盖的节点
            ans++;          // 需要额外的一条边
        res += s;           // 累加到父节点
    }

    return res;         // 返回当前子树总的覆盖次数
}

// 主函数入口
int main()
{
    // 读取节点数和操作次数
    cin >> n >> m;

    // 初始化邻接表
    memset(h, -1, sizeof(h));

    // 读取n-1条边,构建树
    for (int i = 1; i < n; i++)
    {
        int u, v;
        cin >> u >> v;
        add(u, v);     // 添加无向边
        add(v, u);
    }

    // 从节点1开始BFS,预处理深度和祖先信息
    bfs(1);

    // 处理m次操作,使用差分思想
    for (int i = 1; i <= m; i++)
    {
        int u, v;
        cin >> u >> v;
        int p = lca(u, v);  // 计算最近公共祖先
        d[u]++;             // 路径起点加1
        d[v]++;             // 路径终点加1
        d[p] -= 2;          // 公共祖先处减2(差分思想)
    }

    // DFS遍历整棵树,计算结果
    dfs(1, 0);

    // 输出最终答案
    cout << ans << endl;

    return 0;
}

【运行结果】

4 1
1 2
2 3
1 4
3 4
3
posted @ 2026-06-27 14:20  团爸讲算法  阅读(8)  评论(0)    收藏  举报