砍掉一条边会把树分成两半,所以数对 (a,b) 被分开的充要条件就是这条边在 a 到 b 的路径上;问题变成“哪些边被全部 m 条路径跨过”,用 LCA 加树上点差分一次统计。
OJ: luogu
题目 ID: P9246
难度:普及+/提高-
标签:树LCA树上差分倍增图论
创建: 2026-10-02 15:07
更新: 2026-10-07 12:15
形式化题目
给定一棵
互不相同, 互不相同; 对任意 成立,也就是说两个集合 、 不相交。
要求选出一条边砍断,使得每一对
关键点在于“不连通”在树上的含义。树中删掉一条边后只剩两个连通块,所以:
和 被分开 被砍的那条边属于 到 的唯一路径。
于是题目重新表述为:
求所有满足“被全部
条树上路径覆盖”的边中,编号最大的那一条。
样例给出的树结构以及两对
graph TD
n1((1)) ---|1| n2((2))
n2 ---|2| n3((3))
n3 ---|3| n4((4))
n2 ---|4| n5((5))
n5 ---|5| n6((6))
暴力解法
思路
完全照着题面做,不需要任何结构上的洞察:
- 枚举砍掉哪一条边(
种可能); - 把剩下的
条边用并查集连起来,得到砍边后的两个连通块; - 检查每一对
是否都不连通; - 全部合法就更新答案,最后输出编号最大的那条。
第 1 层的选择就是“砍哪一条边”,一共 check_after_cut(cut),枚举和判定分得很干净,容易确认正确性。
代码
/**
* 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;
}复杂度
枚举
瓶颈
瓶颈是“每换一条边就把整棵树重新合并一次”。
正解
思路
第一步:把“能否分开”翻译成“路径是否跨过这条边”
这个等价是整道题的支点。它把“删边以后检查连通性”这个要跑
记
为“路径经过边 ”的数对个数。答案就是所有满足 的边中编号最大的那条。
因为
第二步:树边与“子树”一一对应
给树随便定一个根(取
的子树里的点; - 子树外的点。
一条路径跨过
cnt(u, par_u) = $ 子树 $u 内“被路径端点选中”的标记和。
每个路径的两个端点
第三步:用点差分一次性打标记
想直接给“某条路径经过的每条边都加一”是很贵的(一条路径可以长达
对每个数对
为什么是
- 对
加 ,会让 到根的整条链都多出 ,其中 这一段正是我们要的; - 对
加 ,同样会让 这一段多出 ; - 但
到根这一段被多加了两次,而这条路径根本没走到那里,所以要用 抵掉。
做完差分后,把每个点的值自底向上累加到父亲,累加结束后
用样例里的
| 结点 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| 0 | ||||||
| 子树求和(= 父边被跨过次数) | — | 0 | 1 | 1 | 1 | 1 |
对照输入编号:边
第四步:LCA 与实现细节
两个实现上的边界值得注意:
- 不能写递归 DFS 求深度。
可达 ,输入可以是一条链,深度就是 ,递归会爆系统栈。改成用 queue做一次迭代 BFS,顺便得到“父亲一定排在儿子前面”的 bfs 序。 - 自底向上求和不走第二遍 DFS。把 bfs 序倒着扫一遍就是自底向上的顺序:处理到
时,它的所有儿子都已经把自己的值推给了 ,于是 已经是最终值,直接记下来再推给父亲即可。 - 每条边归属哪个端点。编号为
的边 在根树上一定有且只有一个端点是另一个的父亲,那个端点就是要查 的儿子点。 - 输出最大编号。按
递增检查,每次满足条件就覆盖 ans,循环结束时留下的自然就是最大编号,不需要额外比较。
代码
/**
* 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 与建倍增表都是
总结
这道题的难度不在数据结构,而在**把“删边后检查连通性”改写成“边被路径覆盖”**这一步:
- 树删一条边只剩两个连通块,所以分离一对点等价于把这条边放在它们的路径上;
- 于是要给每条边统计“被多少条路径覆盖”,答案就是覆盖数等于
的边里编号最大的那条; - 树上的“路径加、单点查”用点差分转成“端点加、子树求和”,配合 LCA 就能做到
。
差分的关键永远是先想清楚朴素做法在做哪种区间加法,再找能抵消重复部分的位置。这里重复的位置就是