「一本通 4.4 例 2」暗的连锁

GitHub跳转原题关系图返回列表

树上差分统计每条主要边被几条附加边跨越,按 0/1/≥2 三类累加 M/1/0,倍增求 LCA。

OJ: roj

题目 ID: 1553

难度:提高

标签:LCA树上差分倍增树python

创建: 2026-09-30 17:43

更新: 2026-10-07 12:15

形式化题目

给定一个 NN 个节点的无向图,由两类边组成:

  • N−1N-1 条主要边构成一棵树(任意两点间恰有一条只走主要边的路径);
  • MM 条附加边。

按规则切两条边:先切一条主要边,之后主要边无敌、只能再切一条附加边(即使第一步已把图切断,也必须切)。问有多少种"(第一条主要边,第二条附加边)"的有序方案,使最终图不连通。

以样例 N=4, M=1N=4,\ M=1 为例,主要边 (1,2),(2,3),(1,4)(1,2),(2,3),(1,4)、附加边 (3,4)(3,4),样例的树与虚线附加边如下:

graph TD
    n1["1"] --- n2["2"]
    n2 --- n3["3"]
    n1 --- n4["4"]
    n3 -.- n4

切主要边 (1,2)(1,2) 后 {1,4}\{1,4\} 与 {2,3}\{2,3\} 两半只靠附加边 (3,4)(3,4) 相连,切掉它即断开;同理切 (2,3)(2,3)、(1,4)(1,4) 后也都只剩附加边 (3,4)(3,4) 这一条连接,各对应 1 种方案,共 1+1+1=31+1+1=3,与样例输出一致。

正解

思路

朴素做法先暴露瓶颈。 枚举每条主要边 ee:切断它,树断成两半 A,BA,B;再枚举 MM 条附加边数出跨 A,BA,B 的条数 cec_e。关键是第二步切完后图不连通的条件:

  • 切完第一条后两半内部各是一棵树(树边都在各自半边内),所以内部永远连通,第二步切内部边不影响断开;
  • 整图连通当且仅当还剩至少一条跨 A,BA,B 的附加边。

于是对每条主要边 ee 按 cec_e 三分类:

cec_e(跨边数) 第二步可切的附加边 贡献
00 图已断开,题面仍要求切一条,MM 条任选都保持断开 MM
11 只有切掉那条唯一的跨边才能断开 11
⩾2\geqslant 2 切一条至少还剩一条,两半始终相连 00

答案就是 ∑e(ce=0? M: ce=1? 1: 0)\sum_{e} \big(c_e = 0 ?\ M :\ c_e = 1 ?\ 1 :\ 0\big)。

瓶颈与翻转。 朴素做法对每条树边重新数一遍跨边,总 O(N(N+M))O(N(N+M));而所有 cec_e 其实由 MM 条附加边决定——每条附加边 (u,v)(u,v) 会给路径 u→vu \to v 上的每条树边 +1+1。把问题翻转成"枚举附加边、路径加,最后统一收集",就是经典的树上差分:

  • 设 w=lca(u,v)w = \mathrm{lca}(u,v),对差分数组(代码中的 diff)做 diff[u]++, diff[v]++, diff[w]−−2\mathrm{diff}[u] {+}{+},\ \mathrm{diff}[v] {+}{+},\ \mathrm{diff}[w] {-}{-} 2(两个端点的流在 LCA 处抵消);
  • 对树按后序累加(代码中倒序遍历先根列表 reversed(order)),孩子 xx 上累加完的 diff[x] 恰好等于边 (fa[x],x)(\mathrm{fa}[x], x) 被跨越的次数,即这条边的 cec_e;累加时顺手做三分类求和。

每条附加边只花一次 LCA 的 O(log⁡N)O(\log N)(倍增求法:先抬深度,再从高到低同步上跳),全部差分 O(Mlog⁡N)O(M\log N);一次倒序回溯同时完成累加和三分类求和,O(N)O(N)。整体 O((N+M)log⁡N)O((N+M)\log N),与 C++ 正解同阶。

以样例走一遍:附加边 (3,4)(3,4) 的路径是 3→2→1→43 \to 2 \to 1 \to 4,三条主要边各被跨越一次,cec_e 全为 1,按表格每条贡献 1,得 1+1+1=31+1+1=3。

代码

Python 版:

python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-09-30 17:43
# update_at: 2026-09-30 17:43

import sys

LOG = 17  # 倍增上跳层数:2^17 = 131072 > N 上限 1e5


def lca(u: int, v: int, depth: list[int], up: list[list[int]]) -> int:
    """倍增求 u、v 的最近公共祖先。"""
    if depth[u] < depth[v]:
        u, v = v, u
    # 先把更深的 u 抬到与 v 同一深度
    d = depth[u] - depth[v]
    for k in range(LOG):
        if d >> k & 1:
            u = up[k][u]
    if u == v:
        return u
    # 两者不同则同步上跳,跳到 LCA 的正下方
    for k in range(LOG - 1, -1, -1):
        if up[k][u] != up[k][v]:
            u, v = up[k][u], up[k][v]
    return up[0][u]


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    n, m = next(data), next(data)

    # 主要边构成树:无向邻接表
    g: list[list[int]] = [[] for _ in range(n + 1)]
    for _ in range(n - 1):
        a, b = next(data), next(data)
        g[a].append(b)
        g[b].append(a)

    # 以 1 为根迭代 DFS(避免深递归):记父亲、深度与先根顺序
    root = 1
    parent = [0] * (n + 1)
    parent[root] = root
    depth = [0] * (n + 1)
    order: list[int] = []
    stack = [root]
    while stack:
        u = stack.pop()
        order.append(u)
        for v in g[u]:
            if v != parent[u]:
                parent[v] = u
                depth[v] = depth[u] + 1
                stack.append(v)

    # 倍增表:up[k][v] 是 v 的 2^k 级祖先;先根顺序保证父亲先于孩子处理
    up = [[0] * (n + 1) for _ in range(LOG)]
    up[0] = parent[:]
    for k in range(1, LOG):
        prev, cur = up[k - 1], up[k]
        for v in order:
            cur[v] = prev[prev[v]]

    # 树上差分:一条附加边 (u, v) 使路径上每条主要边的跨越计数 +1
    diff = [0] * (n + 1)
    for _ in range(m):
        u, v = next(data), next(data)
        w = lca(u, v, depth, up)
        diff[u] += 1
        diff[v] += 1
        diff[w] -= 2

    # 后序累加得到每条主要边的跨越计数 c,按 c 统计方案数
    ans = 0
    for u in reversed(order):
        if u == root:
            continue
        c = diff[u]  # 边 (parent[u], u) 被多少条附加边跨越
        ans += m if c == 0 else (1 if c == 1 else 0)
        diff[parent[u]] += c  # 计数并入父边

    print(ans)


if __name__ == "__main__":
    solve()

C++ 版(同一算法):

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-05 07:50
 * update_at: 2026-10-05 07:50
 */

#include <iostream>
using namespace std;

typedef long long ll;

const int MAXN = 100005; // 节点数上限 1e5
const int LOG = 17;      // 2^17 = 131072 > 1e5,倍增跳 depth 最多用到第 16 位

// 主要边构成一棵树,用链式前向星存无向边(每条边加两次)
int head[MAXN];      // head[u]:u 的第一条出边编号,-1 表示无边
int to[2 * MAXN];    // to[e]:第 e 条边的终点
int nxt[2 * MAXN];   // nxt[e]:第 e 条边的下一条兄弟边
int edge_cnt = 0;

int parent[MAXN]; // parent[v]:v 在根为 1 的树上的父亲,根的父亲为 0
int depth[MAXN];  // depth[v]:v 到根的边数
int order[MAXN];  // order[i]:迭代 DFS 第 i 个出栈的节点(先根顺序)
int order_cnt = 0;

int up[LOG][MAXN]; // up[k][v]:v 向上跳 2^k 步到达的祖先

int diff[MAXN]; // diff[u] 树上差分:后序累加后等于边 (parent[u], u) 被附加边跨越的次数,不超过 M=2e5,用 int 即可

void add_edge(int u, int v) {
    edge_cnt++;
    to[edge_cnt] = v;
    nxt[edge_cnt] = head[u];
    head[u] = edge_cnt;
}

// 以 1 为根迭代 DFS,求出 parent、depth 和先根序 order(避免递归爆栈)
void build_tree(int n) {
    for (int i = 1; i <= n; i++) {
        parent[i] = 0;
    }
    parent[1] = 0;
    depth[1] = 0;
    order_cnt = 0;

    int stack[MAXN];
    int top = 0;
    stack[++top] = 1;
    while (top > 0) {
        int u = stack[top--];
        order_cnt++;
        order[order_cnt] = u;
        for (int e = head[u]; e != -1; e = nxt[e]) {
            int v = to[e];
            if (v == parent[u]) {
                continue;
            }
            parent[v] = u;
            depth[v] = depth[u] + 1;
            stack[++top] = v;
        }
    }
}

// 在树上预处理倍增表:up[k][v] = up[k-1][ up[k-1][v] ]
void build_up(int n) {
    for (int v = 1; v <= n; v++) {
        up[0][v] = parent[v];
    }
    for (int k = 1; k < LOG; k++) {
        for (int v = 1; v <= n; v++) {
            up[k][v] = up[k - 1][up[k - 1][v]];
        }
    }
}

// 倍增求 u、v 的最近公共祖先
int lca(int u, int v) {
    if (depth[u] < depth[v]) {
        int t = u;
        u = v;
        v = t;
    }
    // 先把更深的 u 抬到与 v 同深度
    int d = depth[u] - depth[v];
    for (int k = 0; k < LOG; k++) {
        if ((d >> k) & 1) {
            u = up[k][u];
        }
    }
    if (u == v) {
        return u;
    }
    // 再让两者同步上跳,停在 LCA 的正下方
    for (int k = LOG - 1; k >= 0; k--) {
        if (up[k][u] != up[k][v]) {
            u = up[k][u];
            v = up[k][v];
        }
    }
    return up[0][u];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    for (int i = 1; i <= n; i++) {
        head[i] = -1;
    }
    for (int i = 1; i <= n - 1; i++) {
        int a, b;
        cin >> a >> b;
        add_edge(a, b);
        add_edge(b, a);
    }

    build_tree(n);
    build_up(n);

    // 每条附加边 (u, v) 会给树上路径 u->v 上的每条主要边 +1,
    // 用树上差分:diff[u]++, diff[v]++, diff[lca(u,v)] -= 2
    for (int i = 1; i <= m; i++) {
        int u, v;
        cin >> u >> v;
        int w = lca(u, v);
        diff[u]++;
        diff[v]++;
        diff[w] -= 2;
    }

    // 按先根序倒序(即后序)累加差分,得到每条主要边的跨越次数 c:
    //   c == 0:第一步已断开,第二步 M 条附加边任选,贡献 M
    //   c == 1:只能切那条唯一的跨边,贡献 1
    //   c >= 2:怎么切都还连通,贡献 0
    ll ans = 0;
    for (int i = order_cnt; i >= 1; i--) {
        int u = order[i];
        if (u == 1) {
            continue;
        }
        int c = diff[u];
        if (c == 0) {
            ans += m;
        } else if (c == 1) {
            ans += 1;
        }
        diff[parent[u]] += c; // 把这条边的跨越次数并入父边
    }

    cout << ans << "\n";

    return 0;
}

复杂度

  • 时间复杂度:O((N+M)log⁡N)O((N+M)\log N)。建图与两次遍历 O(N)O(N),倍增表 O(Nlog⁡N)O(N\log N),MM 次 LCA 差分 O(Mlog⁡N)O(M\log N)。
  • 空间复杂度:O(Nlog⁡N)O(N\log N),倍增表 17(N+1)17(N+1) 加邻接表与差分数组;实测峰值约 83.7 MB,在 128 MB 限制内。

总结

  • 三分类是本题的核心观察:ce=0c_e = 0 贡献 MM(图已断但必须再切一条,切哪条都行)、ce=1c_e = 1 贡献 11、ce⩾2c_e \geqslant 2 贡献 00;正确性依赖"两半内部是树、永远连通"这一性质。
  • “按边枚举、查跨边"要翻转成"按附加边枚举、路径加”:树上差分 d[u]++,d[v]++,d[w]−−2d[u] {+}{+}, d[v] {+}{+}, d[w] {-}{-} 2 配一次后序累加,O(1)O(1) 完成一条附加边的全部贡献。
  • LCA 用倍增、遍历用迭代栈,规避 Python 递归深度限制;实测 10 组真实数据全部一致,最大一组 0.512 s、峰值 83.7 MB,本地相对 20 s 放宽限时毫无压力(OJ 限时 1000 ms 下 Python 有 TLE 风险,按约定允许)。