二分答案 + 倍增 LCA 求路径长度,树上边差分找超标计划的公共边,比较最大公共边权与最长缺口判定可行性。
OJ: roj
题目 ID: 3633
难度:提高
标签:二分答案LCA树上差分倍增python
创建: 2026-10-02 11:58
更新: 2026-10-10 11:30
形式化题目
给定一棵
可以选择恰好一条边改成虫洞(驶过用时变为
样例的树与三条计划如下:
2 --(3)-- 1 --(4)-- 6
|
(7)
|
3
/ \
(6)/ \(5)
4 5| 计划 | 路径 | 用时 |
|---|---|---|
| 1 | 3 → 6 | |
| 2 | 2 → 5 | |
| 3 | 4 → 5 |
若把边 1-3(权
正解
思路
朴素做法与瓶颈。 直接枚举哪条边变虫洞(
第一步:把"选边"变成二分。 时间上限 feasible(T)(代码里的 feasible(limit)):能否让全部计划在
第二步:判定不需要枚举边——虫洞必须是"超标计划"的公共边。 固定 longest,被全部超标计划覆盖的边中最大边权为 best,则
在样例上验证这个判据:取 1-3 的
第三步:两个只做一次(或每轮线性)的落地工具。
-
路径长度用 LCA 变成公式:
。倍增预处理后每条计划 拿到长度;长度与虫洞放哪无关, 条只算一次,二分里直接查表。 -
公共边用树上边差分一次求出:对每条超标计划做三个单点修改
自底向上做子树求和后,
diff[x]恰好等于边被多少条超标计划覆盖:一条路径穿过该边当且仅当两端点恰有一端落在 子树内,此时子树和为 ;两端同侧时 LCA 也在子树内,两个 被 抵消为 。覆盖数等于超标计划数的边就是公共边,汇总时顺手取这些边的最大权 best,与最长超标航程的缺口need = longest - T比较即可。
判定流程的数据流如下:
flowchart LR
A["候选时间上限 T"] --> B["扫 lens 找出<br>长度 > T 的超标计划"]
B --> C["每条超标计划 3 个点差分<br>diff[u]+1, diff[v]+1, diff[lca]-2"]
C --> D["reversed(order)<br>自底向上汇总子树和"]
D --> E{"存在覆盖数 = 超标数<br>且边权 ≥ longest-T 的边?"}
E -->|是| F["feasible = True"]
E -->|否| G["feasible = False"]
观察要点:diff[x] == over 的边即公共边;只要其中最大权 best >= longest - T,最长超标航程就恰好能压到
二分区间还能再收窄(Python 的关键)。 上界取 max_len:此时超标集为空,恒可行。下界设被选边为
代码
Python 落地有三个细节:链形数据深达 diff[v] = 0,判定之间原地清零复用;倍增表逐层列表推导构建,
/**
* 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-10 11:30
* update_at: 2026-10-10 11:30
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
struct Edge{int v,w;};
int lca_node(int u,int v,vector<int>&depth,vector<vector<int> >&up){ if(depth[u]<depth[v]) swap(u,v); int diff=depth[u]-depth[v]; for(int k=0;k<(int)up.size();k++) if((diff>>k)&1) u=up[k][u]; if(u==v) return u; for(int k=(int)up.size()-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; if(!(cin>>n>>m)) return 0; vector<vector<Edge> > g(n+1); for(int i=0;i<n-1;i++){int a,b,t;cin>>a>>b>>t; g[a].push_back({b,t}); g[b].push_back({a,t});} vector<int> parent(n+1),depth(n+1),wpar(n+1),order; vector<ll> dist(n+1); vector<int> stack; stack.push_back(1); while(!stack.empty()){int u=stack.back();stack.pop_back(); order.push_back(u); for(int i=0;i<(int)g[u].size();i++){int v=g[u][i].v,w=g[u][i].w; if(v!=parent[u]){parent[v]=u; depth[v]=depth[u]+1; dist[v]=dist[u]+w; wpar[v]=w; stack.push_back(v);}}} int LOG=1; while((1<<LOG)<=n) LOG++; vector<vector<int> > up(LOG, vector<int>(n+1)); up[0]=parent; for(int k=1;k<LOG;k++) for(int v=0;v<=n;v++) up[k][v]=up[k-1][up[k-1][v]]; vector<int> pu(m),pv(m),pl(m); vector<ll> lens(m); for(int j=0;j<m;j++){int u,v;cin>>u>>v; int w=lca_node(u,v,depth,up); pu[j]=u; pv[j]=v; pl[j]=w; lens[j]=dist[u]+dist[v]-2*dist[w];} vector<int> diff(n+1); ll max_len=0; for(int i=0;i<m;i++) max_len=max(max_len,lens[i]); int maxw=0; for(int i=1;i<=n;i++) maxw=max(maxw,wpar[i]); ll lo=max(0LL,max_len-maxw), hi=max_len; while(lo<hi){ll limit=(lo+hi)/2; int over=0; ll longest=0; for(int j=0;j<m;j++) if(lens[j]>limit){over++; longest=max(longest,lens[j]); diff[pu[j]]++; diff[pv[j]]++; diff[pl[j]]-=2;} bool ok; if(over==0) ok=true; else {ll need=longest-limit; int best=0; for(int idx=(int)order.size()-1; idx>=0; idx--){int v=order[idx], d=diff[v]; diff[v]=0; if(parent[v]){diff[parent[v]]+=d; if(d==over && wpar[v]>best) best=wpar[v];}} ok=best>=need;} if(ok) hi=limit; else lo=limit+1; } cout<<lo<<'\n'; return 0;}#!/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-10-02 11:58
# update_at: 2026-10-02 11:58
import sys
def lca(u: int, v: int, depth: list[int], up: list[list[int]]) -> int:
"""树上最近公共祖先:先把深的点抬到同深度,再从高倍跳级同步上跳。"""
if depth[u] < depth[v]:
u, v = v, u
skip = depth[u] - depth[v]
for k in range(len(up)):
if skip >> k & 1:
u = up[k][u]
if u == v:
return u
for k in range(len(up) - 1, -1, -1): # 最高倍起试,落脚点刚好在 LCA 下方
if up[k][u] != up[k][v]:
u, v = up[k][u], up[k][v]
return up[0][u]
def solve() -> None:
it = iter(map(int, sys.stdin.buffer.read().split()))
n, m = next(it), next(it)
# 邻接表存 (邻居, 边权)
g: list[list[tuple[int, int]]] = [[] for _ in range(n + 1)]
for _ in range(n - 1):
a, b, t = next(it), next(it), next(it)
g[a].append((b, t))
g[b].append((a, t))
# 迭代 DFS:父、深、到根的边权和、到父的边权,order 为前序(父先于子)
parent = [0] * (n + 1)
depth = [0] * (n + 1)
dist = [0] * (n + 1)
wpar = [0] * (n + 1)
order: list[int] = []
stack = [1]
while stack:
u = stack.pop()
order.append(u)
for v, w in g[u]:
if v != parent[u]: # 树上唯一会走回头的边就是连向父节点那条
parent[v] = u
depth[v] = depth[u] + 1
dist[v] = dist[u] + w
wpar[v] = w
stack.append(v)
# 倍增表 up[k][v] = v 的 2^k 级祖先
up = [parent]
for _ in range(1, n.bit_length()):
prev = up[-1]
up.append([prev[prev[v]] for v in range(n + 1)])
# 每个运输计划:两端点、LCA、航程长度(LCA 把路径拆成两条上行链)
pu = [0] * m
pv = [0] * m
pl = [0] * m
lens = [0] * m
for j in range(m):
u, v = next(it), next(it)
w = lca(u, v, depth, up)
pu[j], pv[j], pl[j] = u, v, w
lens[j] = dist[u] + dist[v] - 2 * dist[w]
diff = [0] * (n + 1) # 树上差分数组,判定之间原地清零复用
def feasible(limit: int) -> bool:
"""limit 时间内能否完工:找一条被所有超时计划共用、且足够长的虫洞边。"""
over = 0 # 航程超过 limit 的计划数
longest = 0 # 它们当中最长的航程
for j in range(m):
L = lens[j]
if L > limit:
over += 1
if L > longest:
longest = L
diff[pu[j]] += 1 # 路径差分:两端 +1,LCA -2
diff[pv[j]] += 1
diff[pl[j]] -= 2
if over == 0:
return True # 没有超时计划,虫洞放哪都行
need = longest - limit # 公共边至少要省掉这么多时间
best = 0 # 被全部超时计划覆盖的边里的最大边权
for v in reversed(order): # 前序倒着走 = 自底向上汇总子树差分
d = diff[v]
diff[v] = 0 # 顺手清零,下一次判定直接复用
if parent[v]:
diff[parent[v]] += d
if d == over and wpar[v] > best:
best = wpar[v]
return best >= need
# 下界:最长航程无论选哪条边做虫洞都至少剩 max_len - 全局最大边权;上界:不省任何时间
max_len = max(lens)
lo, hi = max(0, max_len - max(wpar)), max_len
while lo < hi:
mid = (lo + hi) // 2
if feasible(mid):
hi = mid
else:
lo = mid + 1
print(lo)
if __name__ == "__main__":
solve()复杂度
- 预处理:迭代 DFS
;倍增表 ; 条计划的 LCA 与长度 。 - 每轮判定:扫计划
+ 差分汇总 ,共 ;二分至多 轮。 - 总时间
,与 C++ 正解同阶;空间 。
用数据仓真实测试验证:20/20 测例输出与标准答案完全一致,最大测例(
总结
- “最小化最大值” + 可行性单调 → 二分答案,把"枚举
条边"压成约 10 轮判定。 - 虫洞必须救活所有超标计划 → 虫洞边必是公共边;"公共边里的最大权
最长超标航程的缺口"就是完整的可行性判据。 - LCA 把"沿路径求和"变成
公式且只算一次;树上边差分把"逐条计划沿路径标边"变成每条 3 个单点修改,每轮汇总 。 - Python 要点:迭代建树防爆栈、判定间差分原地清零、利用边权
收紧二分区间。
图示解析
整道题的推导路线:
朴素:枚举每条边变虫洞,逐条计划重算路径长 O(n*m)
|
| 瓶颈:候选边 n-1 条 × 每条计划重算
v
关键观察
1. 可行性关于时间上限 T 单调 → 二分答案
2. 超标计划必须都被虫洞穿过 → 虫洞 = 超标计划的公共边
3. len = dist[u]+dist[v]-2*dist[lca] → LCA 预处理一次
4. 点差分 + 子树求和 = 每条边覆盖数 → 树上差分找公共边
5. 边权 <= 1000 → 区间宽 <= 1000 → 至多 10 轮判定
|
v
正解(main.py)
迭代 DFS 建树 → 倍增表 → m 条计划的 lca/len 只算一次
feasible(T): 超标计划点差分 → reversed(order) 汇总 + 清零
覆盖数 == 超标数的边取最大权 best
可行 <=> best >= longest - T
|
v
时间 O((n+m) log n),空间 O(n log n + m)观察要点:中间五条观察各自消掉一个瓶颈——二分消掉"枚举边",公共边消掉"逐条判断边",LCA 消掉"沿路径求和",差分消掉"逐条标边",区间收紧消掉"轮数"。串起它们的枢纽是覆盖数:差分统计出的每条边被几条超标计划穿过,覆盖数等于超标数的边才有资格当虫洞,再比较它的权值与缺口即可。
