[蓝桥杯 2023 省 B] 砍树

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

砍掉一条边会把树分成两半,所以数对 (a,b) 被分开的充要条件就是这条边在 a 到 b 的路径上;问题变成“哪些边被全部 m 条路径跨过”,用 LCA 加树上点差分一次统计。

OJ: luogu

题目 ID: P9246

难度:普及+/提高-

标签:树LCA树上差分倍增图论

创建: 2026-10-02 15:07

更新: 2026-10-07 12:15

形式化题目

给定一棵 nn 个结点的树(边按输入顺序编号 1∼n−11 \sim n-1)和 mm 个无序数对 (ai,bi)(a_i, b_i)。这些数对满足

  • a1,…,ama_1, \ldots, a_m 互不相同,b1,…,bmb_1, \ldots, b_m 互不相同;
  • ai≠bja_i \neq b_j 对任意 i,ji, j 成立,也就是说两个集合 A={ai}A = \{a_i\}、B={bi}B = \{b_i\} 不相交。

要求选出一条边砍断,使得每一对 ai,bia_i, b_i 都不再连通。若有多条边可行,输出编号最大的一条;一条都没有则输出 −1-1。

关键点在于“不连通”在树上的含义。树中删掉一条边后只剩两个连通块,所以:

aia_i 和 bib_i 被分开   ⟺  \iff 被砍的那条边属于 aia_i 到 bib_i 的唯一路径。

于是题目重新表述为:

求所有满足“被全部 mm 条树上路径覆盖”的边中,编号最大的那一条。

样例给出的树结构以及两对 (3,6)(3,6)、(4,5)(4,5) 的路径如下(边旁数字是输入编号):

graph TD
    n1((1)) ---|1| n2((2))
    n2 ---|2| n3((3))
    n3 ---|3| n4((4))
    n2 ---|4| n5((5))
    n5 ---|5| n6((6))

(3,6)(3,6) 的路径是 3→2→5→63 \to 2 \to 5 \to 6,(4,5)(4,5) 的路径是 4→3→2→54 \to 3 \to 2 \to 5。两条路径的公共边只有编号 22 和编号 44,其中编号更大的是 44,所以答案是 44。观察这个例子可以立刻得出:答案必须同时出现在每一条路径上,这正是后面的统计目标。

暴力解法

思路

完全照着题面做,不需要任何结构上的洞察:

  1. 枚举砍掉哪一条边(n−1n-1 种可能);
  2. 把剩下的 n−2n-2 条边用并查集连起来,得到砍边后的两个连通块;
  3. 检查每一对 (ai,bi)(a_i, b_i) 是否都不连通;
  4. 全部合法就更新答案,最后输出编号最大的那条。

第 1 层的选择就是“砍哪一条边”,一共 n−1n-1 种选择,全部枚举一遍就不会漏掉答案。代码里把“检查一种砍法”写成一个独立函数 check_after_cut(cut),枚举和判定分得很干净,容易确认正确性。

代码

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-03 11:50
 * update_at: 2026-10-03 11:50
 */
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// 思路:直接实现题面。枚举砍掉哪一条边,把剩下的 n-2 条边用并查集连起来,
//       再逐对检查 (a_i, b_i) 是否都不连通;可行的边都试一遍,最后输出编号最大的。
//       完全不利用树的结构,只依赖“删一条边把图分成两半”的直观事实,正确性最直接。
// 规模:每条边都要重建一次并查集,复杂度 O(n^2 α(n)),只适合 n <= 3000 的小数据。
#include <iostream>
using namespace std;

const int MAXN = 3005;

int n, m;
int eu[MAXN], ev[MAXN]; // 树的 n-1 条边,从 1 号下标开始
int qa[MAXN], qb[MAXN]; // m 个待分离的数对
int fa[MAXN];           // 并查集

int find_root(int x) {
    while (fa[x] != x) {
        fa[x] = fa[fa[x]]; // 路径减半,防止链状数据退化
        x = fa[x];
    }
    return x;
}

// 砍掉编号为 cut 的边后,检查是不是每一对 (a_i, b_i) 都不连通。
bool check_after_cut(int cut) {
    for (int i = 1; i <= n; i++) fa[i] = i;
    for (int i = 1; i <= n - 1; i++) {
        if (i == cut) continue; // 这条边被砍掉了,不参与合并
        int ru = find_root(eu[i]);
        int rv = find_root(ev[i]);
        if (ru != rv) fa[ru] = rv;
    }
    for (int i = 1; i <= m; i++) {
        if (find_root(qa[i]) == find_root(qb[i])) return false; // 还有连通的点对
    }
    return true;
}

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

    cin >> n >> m;
    for (int i = 1; i <= n - 1; i++) {
        cin >> eu[i] >> ev[i];
    }
    for (int i = 1; i <= m; i++) {
        cin >> qa[i] >> qb[i];
    }

    int ans = -1;
    for (int cut = 1; cut <= n - 1; cut++) {
        if (check_after_cut(cut)) ans = cut; // 编号递增,最后留下的就是最大编号
    }

    cout << ans << '\n';
    return 0;
}

复杂度

枚举 n−1n-1 条边,每次重建并查集需要 O(nα(n))O(n \alpha(n)),总时间复杂度 O(n2α(n))O(n^2 \alpha(n)),空间复杂度 O(n)O(n)。

瓶颈

瓶颈是“每换一条边就把整棵树重新合并一次”。n=105n = 10^5 时这个做法要合并 101010^{10} 量级的边,完全不可行。之所以浪费,是因为砍不同的边时,绝大多数路径的形态是不变的,但我们每次都重新判定了一遍。要打破这个瓶颈,就要把 n−1n-1 种砍法一次性处理掉。

正解

思路

第一步:把“能否分开”翻译成“路径是否跨过这条边”

ai,bi 被分开  ⟺  被砍的边在 ai→bi 的唯一路径上a_i, b_i \text{ 被分开} \iff \text{被砍的边在 } a_i \to b_i \text{ 的唯一路径上}

这个等价是整道题的支点。它把“删边以后检查连通性”这个要跑 nn 次的动态过程,变成了每条边上的一个静态计数:

记 cnt(e)cnt(e) 为“路径经过边 ee”的数对个数。答案就是所有满足 cnt(e)=mcnt(e) = m 的边中编号最大的那条。

因为 (ai,bi)(a_i, b_i) 只有 mm 对,cnt(e)=mcnt(e) = m 表示 mm 条路径全部跨过了 ee;只要有一条不跨过,砍掉 ee 就分不开对应的那一对。

第二步:树边与“子树”一一对应

给树随便定一个根(取 11 号点),对每个非根点 uu,记它的父边为 (u,paru)(u, par_u)。砍掉这条边会把树分成两块:

  • uu 的子树里的点;
  • 子树外的点。

一条路径跨过 (u,paru)(u, par_u) 当且仅当它的两个端点恰有一个落在 uu 的子树内。于是:

cnt(u, par_u) = $ 子树 $u 内“被路径端点选中”的标记和。

每个路径的两个端点 ai,bia_i, b_i 都在自己所在的子树里留下一次标记,所以只要把“以 uu 为子树的所有标记”加起来,就能得到跨过父边的路径数。这一步把“路径”这个一维对象压成了“点权”这个零维对象,是能线性统计的关键。

第三步:用点差分一次性打标记

想直接给“某条路径经过的每条边都加一”是很贵的(一条路径可以长达 O(n)O(n))。差分技巧的做法是:只在端点上做加减,让子树求和的自然过程去还原每条边上的真实值。

对每个数对 (ai,bi)(a_i, b_i),设 w=LCA(ai,bi)w = \mathrm{LCA}(a_i, b_i),执行:

diff[ai]+=1,diff[bi]+=1,diff[w]−=2.\mathrm{diff}[a_i] \mathrel{+}= 1,\qquad \mathrm{diff}[b_i] \mathrel{+}= 1,\qquad \mathrm{diff}[w] \mathrel{-}= 2.

为什么是 −2-2?因为从根往下看,aia_i 到 bib_i 的路径可以拆成 ai→wa_i \to w 和 bi→wb_i \to w 两段,两段的“顶端”都是 ww:

  • 对 aia_i 加 11,会让 ww 到根的整条链都多出 11,其中 ai→wa_i \to w 这一段正是我们要的;
  • 对 bib_i 加 11,同样会让 bi→wb_i \to w 这一段多出 11;
  • 但 ww 到根这一段被多加了两次,而这条路径根本没走到那里,所以要用 diff[w]−=2\mathrm{diff}[w] -= 2 抵掉。

做完差分后,把每个点的值自底向上累加到父亲,累加结束后 uu 上的值就等于“跨过父边 (u,paru)(u, par_u) 的路径数”,也就是 cnt(u,paru)cnt(u, par_u)。

用样例里的 (3,6)(3,6) 和 (4,5)(4,5) 走一遍(根取 11,w1=LCA(3,6)=2w_1 = \mathrm{LCA}(3,6) = 2,w2=LCA(4,5)=2w_2 = \mathrm{LCA}(4,5) = 2):

结点 1 2 3 4 5 6
diff\mathrm{diff} 0 −4-4 +1+1 +1+1 +1+1 +1+1
子树求和(= 父边被跨过次数) — 0 1 1 1 1

对照输入编号:边 1=(1,2)1=(1,2) 对应 cnt=0cnt=0,边 2=(2,3)2=(2,3) 对应 cnt=1cnt=1,边 3=(3,4)3=(3,4) 对应 cnt=1cnt=1,边 4=(2,5)4=(2,5) 对应 cnt=1cnt=1,边 5=(5,6)5=(5,6) 对应 cnt=1cnt=1。m=2m=2 时只有编号 22 和编号 44 满足 cnt=mcnt=m,取编号最大得 44,与样例一致。

第四步:LCA 与实现细节

LCA\mathrm{LCA} 用倍增求:先预处理 up[j][u]up[j][u] 表示 uu 的第 2j2^j 级祖先,然后先把深的点抬到同一深度,再一起往上跳。

两个实现上的边界值得注意:

  • 不能写递归 DFS 求深度。nn 可达 10510^5,输入可以是一条链,深度就是 10510^5,递归会爆系统栈。改成用 queue 做一次迭代 BFS,顺便得到“父亲一定排在儿子前面”的 bfs 序。
  • 自底向上求和不走第二遍 DFS。把 bfs 序倒着扫一遍就是自底向上的顺序:处理到 uu 时,它的所有儿子都已经把自己的值推给了 uu,于是 diff[u]\mathrm{diff}[u] 已经是最终值,直接记下来再推给父亲即可。
  • 每条边归属哪个端点。编号为 ii 的边 (x,y)(x, y) 在根树上一定有且只有一个端点是另一个的父亲,那个端点就是要查 cntcnt 的儿子点。
  • 输出最大编号。按 i=1∼n−1i = 1 \sim n-1 递增检查,每次满足条件就覆盖 ans,循环结束时留下的自然就是最大编号,不需要额外比较。

代码

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-03 11:50
 * update_at: 2026-10-03 11:50
 */
// P9246 [蓝桥杯 2023 省 B] 砍树
// 思路:
//   1. 砍掉一条边以后树只剩两个连通块,所以一对 (a, b) 被砍开的充要条件是
//      “这条边在 a 到 b 的树上路径上”。于是每条边只需要一个信息:
//      有多少条给定路径跨过它;被全部 m 条路径跨过的边才是可行答案。
//   2. 砍掉边 (u, par[u]) 会把树分成 u 的子树和子树外两部分,
//      跨过它的路径数 cross[u] 恰好等于“子树内标记和”,用点差分一次求出:
//      对每条路径 (a, b) 做 diff[a] += 1、diff[b] += 1、diff[lca(a,b)] -= 2,
//      自底向上把子树内的标记加起来就是 cross[u]。
//   3. LCA 用倍增求;自底向上求和用 bfs 序倒扫代替递归,避免链状数据爆栈。
//   4. 按输入顺序扫一遍所有边,cross == m 就更新答案,最后留下的自然是最大编号。
#include <iostream>
using namespace std;

const int MAXN = 100005;
const int MAXE = 200005;
const int LOG = 18; // 2^17 > 1e5,足够表示任意深度差

int n, m;

int head[MAXN], nxt[MAXE], to[MAXE], ecnt; // 链式前向星存树
int eu[MAXN], ev[MAXN];                    // 第 i 条输入边的两个端点

int par[MAXN];          // par[u]:以 1 为根时 u 的父亲
int dep[MAXN];          // dep[u]:u 的深度,根的深度为 1
int ord[MAXN], ordcnt;  // bfs 序,父亲一定排在儿子前面
int up[LOG][MAXN];      // up[j][u]:u 的第 2^j 级祖先,倍增求 LCA
int diff[MAXN];         // 点差分标记:路径端点 +1、LCA -2
int cross_[MAXN];       // cross_[u]:跨过 (u, par[u]) 这条边的路径条数

// 加一条无向边
void add_edge(int u, int v) {
    ecnt++;
    to[ecnt] = v;
    nxt[ecnt] = head[u];
    head[u] = ecnt;
    ecnt++;
    to[ecnt] = u;
    nxt[ecnt] = head[v];
    head[v] = ecnt;
}

// 以 1 为根,用迭代 bfs 求父亲、深度、bfs 序,并建好倍增表。
// 不用递归 dfs 是因为 n 可以达到 1e5,链状数据会爆系统栈。
void build_tree() {
    int qh = 0, qt = 0;
    par[1] = 0;
    dep[1] = 1;
    ord[qt++] = 1;
    while (qh < qt) {
        int u = ord[qh++];
        for (int i = head[u]; i != 0; i = nxt[i]) {
            int v = to[i];
            if (v == par[u]) continue; // 树上除父亲外的邻居都是儿子
            par[v] = u;
            dep[v] = dep[u] + 1;
            ord[qt++] = v;
        }
    }
    ordcnt = qt;
    for (int v = 1; v <= n; v++) up[0][v] = par[v];
    // bfs 序保证父亲先于儿子,逐层递推即可
    for (int j = 1; j < LOG; j++) {
        for (int k = 0; k < ordcnt; k++) {
            int v = ord[k];
            up[j][v] = up[j - 1][up[j - 1][v]];
        }
    }
}

// 倍增求 LCA
int lca(int u, int v) {
    if (dep[u] < dep[v]) {
        int t = u;
        u = v;
        v = t;
    }
    int d = dep[u] - dep[v];
    for (int j = 0; j < LOG; j++) {
        if ((d >> j) & 1) u = up[j][u];
    }
    if (u == v) return u;
    for (int j = LOG - 1; j >= 0; j--) {
        if (up[j][u] != up[j][v]) {
            u = up[j][u];
            v = up[j][v];
        }
    }
    return up[0][u];
}

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

    cin >> n >> m;
    for (int i = 1; i <= n - 1; i++) {
        cin >> eu[i] >> ev[i];
        add_edge(eu[i], ev[i]);
    }

    build_tree();

    // 读入 m 个数对,直接打成点差分标记
    for (int i = 1; i <= m; i++) {
        int a, b;
        cin >> a >> b;
        int w = lca(a, b);
        diff[a]++;
        diff[b]++;
        diff[w] -= 2;
    }

    // 逆 bfs 序累加子树标记:处理到 u 时,它的儿子都已经把值推给了 u
    for (int k = ordcnt - 1; k >= 0; k--) {
        int u = ord[k];
        cross_[u] = diff[u];
        if (u != 1) diff[par[u]] += diff[u];
    }

    // 依次检查每条边,编号递增所以最后记下的就是最大编号
    int ans = -1;
    for (int i = 1; i <= n - 1; i++) {
        int u = eu[i], v = ev[i];
        int child; // 这条边在 bfs 树上的儿子端点
        if (par[u] == v) {
            child = u;
        } else {
            child = v;
        }
        if (cross_[child] == m) ans = i;
    }

    cout << ans << '\n';
    return 0;
}

复杂度

BFS 与建倍增表都是 O(nlog⁡n)O(n \log n);每个数对求一次 LCA 是 O(log⁡n)O(\log n),共 O(mlog⁡n)O(m \log n);差分打标记是 O(m)O(m),自底向上求和与枚举边都是 O(n)O(n)。总时间复杂度 O((n+m)log⁡n)O((n + m) \log n),空间复杂度 O(nlog⁡n)O(n \log n)(主要是倍增表)。n=105n = 10^5 时运行时间在毫秒级。

总结

这道题的难度不在数据结构,而在**把“删边后检查连通性”改写成“边被路径覆盖”**这一步:

  1. 树删一条边只剩两个连通块,所以分离一对点等价于把这条边放在它们的路径上;
  2. 于是要给每条边统计“被多少条路径覆盖”,答案就是覆盖数等于 mm 的边里编号最大的那条;
  3. 树上的“路径加、单点查”用点差分转成“端点加、子树求和”,配合 LCA 就能做到 O((n+m)log⁡n)O((n+m)\log n)。

差分的关键永远是先想清楚朴素做法在做哪种区间加法,再找能抵消重复部分的位置。这里重复的位置就是 LCA\mathrm{LCA},所以减去 22。写代码时再顺手处理掉两处 O(n)O(n) 递归的爆栈隐患(求深度、求子树和),就能安全通过 n=105n = 10^5 的规模。