树上差分统计每条主要边被几条附加边跨越,按 0/1/≥2 三类累加 M/1/0,倍增求 LCA。
OJ: roj
题目 ID: 1553
难度:提高
标签:LCA树上差分倍增树python
创建: 2026-09-30 17:43
更新: 2026-10-07 12:15
前置题目
luogu P3258[JLOI2014] 松鼠的新家B 的树上差分(main.py 中 diff[u]++、diff[v]++、diff[w]-=2 加 reversed(order) 后序累加求父边跨越次数)正是 A 教的“求 lca 后对两端点打差分、自底向上累加统计路径经过次数”这一步,只是把 LCA 的减标记从点差分改成边差分 -=2,再叠加 c_e 的 0/1/≥2 三分类计数。luogu P3128[USACO15DEC] Max Flow PB 的树边跨越计数直接复用 A 教的树上差分:路径加一先化成差分端点并在 LCA 处抵消,再统一后序累加,只是把点差分换成边差分并叠加三分类统计。luogu P9246[蓝桥杯 2023 省 B] 砍树B 的树上差分逐字复用 A 教的「diff[u]++、diff[v]++、diff[w]-=2」端点打标记与自底向上累加求父边跨越次数这一步,只是把统计对象换成附加边路径,再叠加 c_e 的 0/1/>=2 三分类计数。
后置题目
roj 3189雨天的尾巴B 处理每条路径的第一步直接复用 A 教的「求 LCA 后对路径端点打差分、在 LCA 处抵消再子树汇总」这一树上差分步骤,只是把 A 的边差分 diff[w]-=2 换成点差分 lca 与 father(lca) 各 -1,再叠加权值线段树合并与众数查询。luogu P1600[NOIP 2016 提高组] 天天爱跑步B 的 main.cpp 把每条玩家路径拆成两段后挂 event_up/event_down 的 +1/-1 事件,正是 A 教的「用 LCA 做端点加、LCA 处抵消的树上差分,再后序累加」这一步,只是把数值累加换成两个桶的前后读数之差,并叠加 A 未教的「到达时刻写成深度等式→特征值入桶」维度,故为 提高→省选/NOI- 的台阶式前置。luogu P3833[SHOI2012] 魔法树B 的 add_path 直接复用 A 教的「先求 LCA、再在端点处打差分标记让路径加在 LCA 抵消」这一步,只是把 A 的边差分 diff[w]-=2 改写成点差分 diff[p]-=d、diff[parent(p)]-=d,再叠加子树和展开与两棵树状数组。
形式化题目
给定一个
条主要边构成一棵树(任意两点间恰有一条只走主要边的路径); 条附加边。
按规则切两条边:先切一条主要边,之后主要边无敌、只能再切一条附加边(即使第一步已把图切断,也必须切)。问有多少种"(第一条主要边,第二条附加边)"的有序方案,使最终图不连通。
以样例
graph TD
n1["1"] --- n2["2"]
n2 --- n3["3"]
n1 --- n4["4"]
n3 -.- n4
切主要边
正解
思路
朴素做法先暴露瓶颈。 枚举每条主要边
- 切完第一条后两半内部各是一棵树(树边都在各自半边内),所以内部永远连通,第二步切内部边不影响断开;
- 整图连通当且仅当还剩至少一条跨
的附加边。
于是对每条主要边
| 第二步可切的附加边 | 贡献 | |
|---|---|---|
| 图已断开,题面仍要求切一条, |
||
| 只有切掉那条唯一的跨边才能断开 | ||
| 切一条至少还剩一条,两半始终相连 |
答案就是
瓶颈与翻转。 朴素做法对每条树边重新数一遍跨边,总
- 设
,对差分数组(代码中的 diff)做(两个端点的流在 LCA 处抵消); - 对树按后序累加(代码中倒序遍历先根列表
reversed(order)),孩子上累加完的 diff[x]恰好等于边被跨越的次数,即这条边的 ;累加时顺手做三分类求和。
每条附加边只花一次 LCA 的
以样例走一遍:附加边
代码
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;
}复杂度
- 时间复杂度:
。建图与两次遍历 ,倍增表 , 次 LCA 差分 。 - 空间复杂度:
,倍增表 加邻接表与差分数组;实测峰值约 83.7 MB,在 128 MB 限制内。
总结
- 三分类是本题的核心观察:
贡献 (图已断但必须再切一条,切哪条都行)、 贡献 、 贡献 ;正确性依赖"两半内部是树、永远连通"这一性质。 - “按边枚举、查跨边"要翻转成"按附加边枚举、路径加”:树上差分
配一次后序累加, 完成一条附加边的全部贡献。 - LCA 用倍增、遍历用迭代栈,规避 Python 递归深度限制;实测 10 组真实数据全部一致,最大一组 0.512 s、峰值 83.7 MB,本地相对 20 s 放宽限时毫无压力(OJ 限时 1000 ms 下 Python 有 TLE 风险,按约定允许)。