形式化题目

给定一棵 nn 个点、n−1n-1 条带权边的树,边权 0⩽t⩽10000 \leqslant t \leqslant 1000;以及 mm 条运输计划,第 jj 条计划给出端点 (uj,vj)(u_j, v_j),飞船沿两点间唯一路径飞行,用时为路径边权之和。

可以选择恰好一条边改成虫洞(驶过用时变为 00)。mm 个计划同时出发,全部完成的时刻等于各计划用时的最大值。求在最优选择下,这个最大值的最小值。

样例的树与三条计划如下:

text
2 --(3)-- 1 --(4)-- 6
           |
          (7)
           |
           3
          / \
      (6)/   \(5)
        4     5
计划 路径 用时
1 3 → 6 7+4=117 + 4 = 11
2 2 → 5 3+7+5=153 + 7 + 5 = 15
3 4 → 5 6+5=116 + 5 = 11

若把边 1-3(权 77)改成虫洞,三条计划的用时变成 4,8,114, 8, 11,最大值为 1111;可以验证任何选择都无法把最大值压到 1111 以下,所以答案是 1111。

正解

思路

朴素做法与瓶颈。 直接枚举哪条边变虫洞(n−1n-1 种选择),每种选择下逐条计划重算路径长度、取最大值,最后在所有选择里取最小——最坏从 O(nm)O(nm) 起步,在 n,m=3×105n, m = 3 \times 10^5 的规模下完全不可行。瓶颈有两处:候选边太多,路径求和太慢。下面三步分别拆掉它们。

第一步:把"选边"变成二分。 时间上限 TT 越大越容易完工,可行性关于 TT 单调,答案就是最小的可行 TT;且 0⩽T⩽max⁡jlenj0 \leqslant T \leqslant \max_j len_j——不放虫洞时每条计划用时都不超过最长航程。于是只需实现判定 feasible(T)(代码里的 feasible(limit)):能否让全部计划在 TT 内完成。

第二步:判定不需要枚举边——虫洞必须是"超标计划"的公共边。 固定 TT 后,原长度 ⩽T\leqslant T 的计划无论如何已经达标;原长度 >T> T 的超标计划必须被虫洞救活——一条路径不经过虫洞边,长度就不变,所以虫洞边必须被所有超标计划共同经过。而虫洞对每条超标计划的减时相同(都是 wew_e),因此只要最长的那条降到 TT 以内即可。设超标计划的最长航程为 longest,被全部超标计划覆盖的边中最大边权为 best,则

可行  ⟺  超标计划数=0或best⩾longest−T可行 \iff 超标计划数 = 0 \quad\text{或}\quad best \geqslant longest - T

在样例上验证这个判据:取 T=11T = 11,只有计划 2 超标,缺口 =15−11=4= 15 - 11 = 4,它的路径 2 ⁣− ⁣1 ⁣− ⁣3 ⁣− ⁣52\!-\!1\!-\!3\!-\!5 上最大边权是边 1-3 的 7⩾47 \geqslant 4,可行;取 T=10T = 10,三条计划全超标,但计划 1 的路径边集 {3 ⁣− ⁣1, 1 ⁣− ⁣6}\{3\!-\!1,\, 1\!-\!6\} 与计划 3 的 {4 ⁣− ⁣3, 3 ⁣− ⁣5}\{4\!-\!3,\, 3\!-\!5\} 交集为空,公共边不存在,不可行。

第三步:两个只做一次(或每轮线性)的落地工具。

  • 路径长度用 LCA 变成公式:lenj=dist[uj]+dist[vj]−2⋅dist[lca(uj,vj)]len_j = dist[u_j] + dist[v_j] - 2 \cdot dist[lca(u_j, v_j)]。倍增预处理后每条计划 O(log⁡n)O(\log n) 拿到长度;长度与虫洞放哪无关,mm 条只算一次,二分里直接查表。

  • 公共边用树上边差分一次求出:对每条超标计划做三个单点修改

    diff[u]++,diff[v]++,diff[lca(u,v)]−=  2\text{diff}[u] {+}{+}, \quad \text{diff}[v] {+}{+}, \quad \text{diff}[lca(u,v)] {-}{=}\; 2

    自底向上做子树求和后,diff[x] 恰好等于边 (parent[x],x)(parent[x], x) 被多少条超标计划覆盖:一条路径穿过该边当且仅当两端点恰有一端落在 xx 子树内,此时子树和为 11;两端同侧时 LCA 也在子树内,两个 +1+1 被 −2-2 抵消为 00。覆盖数等于超标计划数的边就是公共边,汇总时顺手取这些边的最大权 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,最长超标航程就恰好能压到 TT,判可行。

二分区间还能再收窄(Python 的关键)。 上界取 max_len:此时超标集为空,恒可行。下界设被选边为 ee、最长计划为 p∗p^*:p∗p^* 不经过 ee 时答案 ⩾max_len\geqslant max\_len;经过 ee 时答案 ⩾max_len−we⩾max_len−max_w\geqslant max\_len - w_e \geqslant max\_len - max\_w,其中 max_wmax\_w 是全局最大边权。两种情况都 ⩾max_len−max_w\geqslant max\_len - max\_w,而边权上界是 10001000,所以二分区间宽度 ⩽1000\leqslant 1000,至多 10 轮收敛——比在 [0,3×108][0, 3 \times 10^8] 上二分的约 29 轮省掉三分之二的判定开销。

代码

Python 落地有三个细节:链形数据深达 3×1053 \times 10^5,建树用显式栈迭代而非递归,避免爆栈;差分数组在每轮汇总时顺手 diff[v] = 0,判定之间原地清零复用;倍增表逐层列表推导构建,mm 条计划的 LCA 与长度只算一次。

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-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;}
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-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 O(n)O(n);倍增表 O(nlog⁡n)O(n \log n);mm 条计划的 LCA 与长度 O(mlog⁡n)O(m \log n)。
  • 每轮判定:扫计划 O(m)O(m) + 差分汇总 O(n)O(n),共 O(n+m)O(n + m);二分至多 ⌈log⁡21001⌉=10\lceil \log_2 1001 \rceil = 10 轮。
  • 总时间 O((n+m)log⁡n)O((n + m) \log n),与 C++ 正解同阶;空间 O(nlog⁡n+m)O(n \log n + m)。

用数据仓真实测试验证:20/20 测例输出与标准答案完全一致,最大测例(n=m=3×105n = m = 3 \times 10^5)本地 2.4s、峰值内存 313MB。OJ 限时 1000ms / 32MB,Python 侧存在 TLE/MLE 风险(按仓库规则允许),算法复杂度与正解同阶。

总结

  • “最小化最大值” + 可行性单调 → 二分答案,把"枚举 n−1n-1 条边"压成约 10 轮判定。
  • 虫洞必须救活所有超标计划 → 虫洞边必是公共边;"公共边里的最大权 ⩾\geqslant 最长超标航程的缺口"就是完整的可行性判据。
  • LCA 把"沿路径求和"变成 O(1)O(1) 公式且只算一次;树上边差分把"逐条计划沿路径标边"变成每条 3 个单点修改,每轮汇总 O(n+m)O(n+m)。
  • Python 要点:迭代建树防爆栈、判定间差分原地清零、利用边权 ⩽1000\leqslant 1000 收紧二分区间。

图示解析

整道题的推导路线:

text
朴素:枚举每条边变虫洞,逐条计划重算路径长        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 消掉"沿路径求和",差分消掉"逐条标边",区间收紧消掉"轮数"。串起它们的枢纽是覆盖数:差分统计出的每条边被几条超标计划穿过,覆盖数等于超标数的边才有资格当虫洞,再比较它的权值与缺口即可。