天天爱跑步

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

把玩家路径按 lca 拆成上坡、下坡两段,观察到时转化为两个深度常量等式,用 Tarjan 离线 LCA + 树上差分(子树内桶计数)统计每个观察点看到的键值数。

OJ: roj

题目 ID: 3639

难度:提高+/省选-

标签:树上差分LCADFS序NOIP

创建: 2026-10-02 12:11

更新: 2026-10-10 11:30

形式化题目

给一棵 nn 个点的树,每个点 jj 有一个观察时刻 WjW_j。有 mm 个玩家,第 ii 个玩家第 00 秒从 SiS_i 出发,沿树上唯一简单路径每秒走一条边跑向 TiT_i,恰好到达 TiT_i 时停止(不能提前停在终点等待)。

点 jj 的观察员能看到玩家 ii,当且仅当玩家 ii 在第 WjW_j 秒恰好位于点 jj。求每个点能看到多少个玩家。

样例 1 的树与三条路径(点旁括号内是 WjW_j):

graph TD
    A["1(W=0)"] --> B["2(W=2)"]
    B --> D["3(W=5)"]
    A --> C["4(W=1)"]
    C --> E["5(W=2)"]
    C --> F["6(W=3)"]

玩家 1 走 1→4→51 \to 4 \to 5,玩家 2 走 1→2→31 \to 2 \to 3,玩家 3 走 2→1→4→62 \to 1 \to 4 \to 6。 逐点核对(格子 = 玩家到达该点的时刻,× 表示不在路径上):

点 jj(WjW_j) 玩家 1:1→51\to5 玩家 2:1→31\to3 玩家 3:2→62\to6 答案
1 (0) 0 ✓ 0 ✓ 1 2
2 (2) × 1 0 0
3 (5) × 2 × 0
4 (1) 1 ✓ × 2 1
5 (2) 2 ✓ × × 1
6 (3) × × 3 ✓ 1

表格的行是观察点、列是玩家,格子是到达时刻;时刻恰好等于行首 WjW_j 才打 ✓。 观察重点:点 3 虽在玩家 2 路径上,但 W3=5>2W_3 = 5 > 2,玩家早就跑过去了;点 1 的两个 ✓ 都是"起点即观察点、W=0W=0"的边界情形。

正解

思路

朴素做法与瓶颈

最直接的做法:对每个玩家沿路径逐点走一遍,在每个点 uu 检查"到达 uu 的时刻是否等于 WuW_u"。单条路径长度可达 O(n)O(n),总代价 O(nm)O(nm);本题 n,m⩽3×105n, m \leqslant 3 \times 10^5,约 9×10109 \times 10^{10} 次操作,完全不可行。

瓶颈在于:每个玩家把贡献平摊到路径上每个点,而询问(每个观察点)却只要一个总数。要让"发贡献"变成 O(1)O(1),就必须让每个点主动来认领属于自己的贡献。

关键转化:把"时刻相等"写成两条深度等式

玩家路径经过折点 L=lca(S,T)L=\mathrm{lca}(S,T),拆成两段。设 depdep 为深度(根深度 0),玩家走一条边花 1 秒,于是:

  • 上坡段 S→LS \to L(含两端):点 uu 在这一段上的时刻是 dep[S]−dep[u]dep[S] - dep[u]。“时刻等于 WuW_u” 即
dep[u]+Wu=dep[S].dep[u] + W_u = dep[S].
  • 下坡段 L→TL \to T(不含 LL):设 D=dep[S]+dep[T]−2 dep[L]D = dep[S]+dep[T]-2\,dep[L] 为全程长度,点 uu 的时刻是 D−(dep[T]−dep[u])D - (dep[T]-dep[u]),化简后“时刻等于 WuW_u” 即
Wu−dep[u]=dep[S]−2 dep[L].W_u - dep[u] = dep[S] - 2\,dep[L].

两个等式的形状完全一致:左边是只跟点 uu 有关的一个数(键),右边是只跟玩家路径有关的一个数(桶)。于是转化完成:

点 uu 被玩家看到 ⟺ uu 在路径上,且点键 dep[u]+Wudep[u]+W_u(上坡)或 Wu−dep[u]W_u-dep[u](下坡)出现在该路径放进某个桶里的标记中。

graph LR
    subgraph 路径与两段
    S["S 发:键 dep[S]"] -->|"上坡 S..L 含两端"| L2["L"]
    L2 -->|"下坡 L..T 不含 L"| T2["T 发:键 dep[S]-2dep[L]"]
    end

图中每个箭头是一段路径:上坡段的贡献由 SS 发出(键 dep[S]dep[S]),下坡段的贡献由 TT 发出(键 dep[S]−2 dep[L]dep[S]-2\,dep[L]); 观察重点:贡献的发出位置只有每段的一个端点,这正是下一步能做差分的原因。

从"路径逐点"到"子树统计"

把视角反过来:每个玩家只做两次"发标记"——

  • 上坡标记(键 k1=dep[S]k_1 = dep[S])挂在点 SS;
  • 下坡标记(键 k2=dep[S]−2 dep[L]k_2 = dep[S] - 2\,dep[L])挂在点 TT。

则"点 uu 在路径上且键匹配"恰好等价于"标记挂在 uu 的子树内"(子树 = 先序连续区间):

  • 上坡:u∈[S..L]u \in [S..L] ⟺ SS 在 uu 子树内,且 LL 不是(fa[L]fa[L] 在子树外);折点 LL 自身的到达时刻属于下坡等式,所以上坡段在 LL 处要扣掉——把一个键同为 k1k_1 的"减标记"挂在 fa[L]fa[L]。LL 为根时没有 fa[L]fa[L],标记自然丢失,无需特判。
  • 下坡:u∈(L..T]u \in (L..T] ⟺ TT 在 uu 子树内且 LL 也在 uu 子树内——把"减标记"直接挂在 LL,同一次子树统计顺便完成"且"条件。

于是每个观察点的答案就是子树内四个桶的二分计数:

ansu=#{S∈sub(u), dep[S]=dep[u]+Wu}−#{fa[L]∈sub(u), dep[S]=dep[u]+Wu}+#{T∈sub(u), k2=Wu−dep[u]}−#{L∈sub(u), k2=Wu−dep[u]}.ans_u = \#\{S \in sub(u),\, dep[S] = dep[u]+W_u\} - \#\{fa[L] \in sub(u),\, dep[S] = dep[u]+W_u\} + \#\{T \in sub(u),\, k_2 = W_u-dep[u]\} - \#\{L \in sub(u),\, k_2 = W_u-dep[u]\}.
graph TD
    U["u(观察点)"] --- P["fa[L]:上坡减标记挂这里"]
    U --- L3["L:下坡减标记挂这里"]
    U --- S3["S:上坡加标记挂这里"]
    U --- T3["T:下坡加标记挂这里"]

观察重点:fa[L]fa[L] 画在 uu 子树边界之外、LL 画在边界之内——上坡段的"折点不算"和下坡段的"折点要算、且必须 LL 也在子树内"这两处最容易写错的边界,被两个减标记的位置一次性解决。

LCA 与实现

  • LCA 用 Tarjan 离线:询问挂在两个端点上,DFS 进入点 uu 时检查另一端是否已进入,是则答案为该端当前并查集代表;回溯离开 uu 时把 uu 并入父亲。整个 LCA 求解与 DFS 融为一体,O((n+m)α)O((n+m)\alpha)。
  • Python 无递归深度保障,用邻居迭代器栈写迭代 DFS(栈顶孩子优先 = 真先序),同时拿到先序 order。
  • 子树统计:先序时间戳 tin + 子树大小 size,把子树变成区间 [tin[u], tin[u]+size[u]-1];四个桶内排序 tin 后,每个点两次二分即可,总 O(nlog⁡m)O(n \log m)。
  • 键值范围:dep[u]+Wu∈[1,2n]dep[u]+W_u \in [1, 2n],Wu−dep[u]∈[−(n−1),n]W_u - dep[u] \in [-(n-1), n],桶用 defaultdict(list) 免去手工偏移。

代码

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;
int find_set(vector<int>&fa,int x){while(fa[x]!=x){fa[x]=fa[fa[x]];x=fa[x];}return x;}
int count_in_bucket(map<int, vector<int> > &buckets,int key,int lo,int hi){auto it=buckets.find(key); if(it==buckets.end()) return 0; vector<int>&v=it->second; return upper_bound(v.begin(),v.end(),hi)-lower_bound(v.begin(),v.end(),lo);}
int main(){ios::sync_with_stdio(false); cin.tie(nullptr); int n,m; if(!(cin>>n>>m)) return 0; vector<vector<int> > adj(n+1); for(int i=0;i<n-1;i++){int u,v;cin>>u>>v; adj[u].push_back(v); adj[v].push_back(u);} vector<int> weight(n+1); for(int i=1;i<=n;i++) cin>>weight[i]; vector<int>s(m),t(m),lca(m); vector<vector<int> > at_s(n+1), at_t(n+1); for(int i=0;i<m;i++){cin>>s[i]>>t[i]; at_s[s[i]].push_back(i); at_t[t[i]].push_back(i);} vector<int> parent(n+1), depth(n+1), anc(n+1), order; vector<char> vis(n+1); for(int i=0;i<=n;i++) anc[i]=i; vector<int> it(n+1); vis[1]=1; order.push_back(1); for(int z=0;z<(int)at_s[1].size();z++){int i=at_s[1][z]; if(vis[t[i]]) lca[i]=find_set(anc,t[i]);} for(int z=0;z<(int)at_t[1].size();z++){int i=at_t[1][z]; if(vis[s[i]]) lca[i]=find_set(anc,s[i]);}
    vector<int> st; st.push_back(1); while(!st.empty()){int u=st.back(); if(it[u]<(int)adj[u].size()){int v=adj[u][it[u]++]; if(!vis[v]){vis[v]=1; parent[v]=u; depth[v]=depth[u]+1; order.push_back(v); for(int z=0;z<(int)at_s[v].size();z++){int i=at_s[v][z]; if(vis[t[i]]) lca[i]=find_set(anc,t[i]);} for(int z=0;z<(int)at_t[v].size();z++){int i=at_t[v][z]; if(vis[s[i]]) lca[i]=find_set(anc,s[i]);} st.push_back(v);}} else {st.pop_back(); if(u!=1) anc[find_set(anc,u)]=parent[u];}}
    vector<int> tin(n+1), sz(n+1,1); for(int i=0;i<(int)order.size();i++) tin[order[i]]=i; for(int i=(int)order.size()-1;i>=0;i--){int u=order[i]; if(u!=1) sz[parent[u]]+=sz[u];}
    map<int, vector<int> > up_add,up_sub,down_add,down_sub; for(int i=0;i<m;i++){int d0=depth[s[i]]; int k=d0-2*depth[lca[i]]; up_add[d0].push_back(tin[s[i]]); down_add[k].push_back(tin[t[i]]); if(parent[lca[i]]) up_sub[d0].push_back(tin[parent[lca[i]]]); down_sub[k].push_back(tin[lca[i]]);} for(map<int,vector<int> >::iterator itb=up_add.begin();itb!=up_add.end();++itb) sort(itb->second.begin(),itb->second.end()); for(map<int,vector<int> >::iterator itb=up_sub.begin();itb!=up_sub.end();++itb) sort(itb->second.begin(),itb->second.end()); for(map<int,vector<int> >::iterator itb=down_add.begin();itb!=down_add.end();++itb) sort(itb->second.begin(),itb->second.end()); for(map<int,vector<int> >::iterator itb=down_sub.begin();itb!=down_sub.end();++itb) sort(itb->second.begin(),itb->second.end());
    for(int u=1;u<=n;u++){int lo=tin[u], hi=lo+sz[u]-1, du=depth[u]; int ans=count_in_bucket(up_add,du+weight[u],lo,hi)+count_in_bucket(down_add,weight[u]-du,lo,hi)-count_in_bucket(up_sub,du+weight[u],lo,hi)-count_in_bucket(down_sub,weight[u]-du,lo,hi); if(u>1) cout<<' '; cout<<ans;} cout<<'\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 12:11
# update_at: 2026-10-02 12:11

import sys
from bisect import bisect_left, bisect_right
from collections import defaultdict


def find(anc: list[int], x: int) -> int:
    """并查集查找(路径折半):x 当前能摸到的最高"已结束子树"代表。"""
    while anc[x] != x:
        anc[x] = anc[anc[x]]
        x = anc[x]
    return x


def subtree_count(buckets: defaultdict[int, list[int]], key: int, lo: int, hi: int) -> int:
    """buckets[key] 里 tin 落在 [lo, hi] 的个数:u 的子树内有多少个 key 类标记。"""
    marks = buckets.get(key)
    if marks is None:
        return 0
    return bisect_right(marks, hi) - bisect_left(marks, lo)


def solve() -> None:
    data = iter(sys.stdin.buffer.read().split())
    n = int(next(data)); m = int(next(data))

    adj: list[list[int]] = [[] for _ in range(n + 1)]
    for _ in range(n - 1):
        u = int(next(data)); v = int(next(data))
        adj[u].append(v); adj[v].append(u)

    weight = [0] + [int(next(data)) for _ in range(n)]  # W_j:结点 j 的观察时刻

    # 每条路径拆成三段事件:上坡差分 +,折点父亲差分 -;下坡差分 +,折点差分 -
    s = [0] * m; t = [0] * m; lca = [0] * m
    at_start: list[list[int]] = [[] for _ in range(n + 1)]  # Tarjan 询问挂在两端点
    at_term: list[list[int]] = [[] for _ in range(n + 1)]
    for i in range(m):
        a = int(next(data)); b = int(next(data))
        s[i] = a; t[i] = b
        at_start[a].append(i); at_term[b].append(i)

    # 迭代 DFS 得真先序 order;回溯时把子树并入并查集(Tarjan 离线 LCA)
    parent = [0] * (n + 1)
    depth = [0] * (n + 1)
    anc = list(range(n + 1))
    visited = [False] * (n + 1)
    it: list = [None] * (n + 1)  # 每个点自己的邻居迭代器,代替递归
    order: list[int] = []

    def walk(u: int) -> None:
        """进入 u(真先序);若某条询问的另一端已进入,立刻取 LCA。"""
        order.append(u)
        for i in at_start[u]:
            other = t[i]
            if visited[other]:
                lca[i] = find(anc, other)
        for i in at_term[u]:
            other = s[i]
            if visited[other]:
                lca[i] = find(anc, other)

    visited[1] = True
    walk(1)
    it[1] = iter(adj[1])
    stack = [1]
    while stack:
        u = stack[-1]
        pushed = False
        for v in it[u]:
            if not visited[v]:
                visited[v] = True
                parent[v] = u
                depth[v] = depth[u] + 1
                it[v] = iter(adj[v])
                walk(v)
                stack.append(v)
                pushed = True
                break
        if not pushed:  # u 的子树结束:把 u 向父亲收拢
            stack.pop()
            if u != 1:
                anc[find(anc, u)] = parent[u]

    tin = [0] * (n + 1)  # 先序时间戳,子树 = 一段连续区间
    for idx, u in enumerate(order):
        tin[u] = idx
    size = [1] * (n + 1)
    for u in reversed(order):  # 逆先序保证算父亲时孩子已齐
        if u != 1:
            size[parent[u]] += size[u]

    # 两类差分,键是"观察等式"里的常量,桶里存路径端点的 tin:
    # 上坡(S..L,含两端)标记 dep[S];下坡((L..T])标记 dep[S] - 2*dep[L]
    up_add: defaultdict[int, list[int]] = defaultdict(list)
    up_sub: defaultdict[int, list[int]] = defaultdict(list)
    down_add: defaultdict[int, list[int]] = defaultdict(list)
    down_sub: defaultdict[int, list[int]] = defaultdict(list)
    for i in range(m):
        d = depth[s[i]]
        k = d - 2 * depth[lca[i]]  # 下坡等式的常量
        up_add[d].append(tin[s[i]])
        down_add[k].append(tin[t[i]])
        if parent[lca[i]]:  # 上坡去掉折点:减在折点父亲处(根没有父亲,标记自然丢失)
            up_sub[d].append(tin[parent[lca[i]]])
        down_sub[k].append(tin[lca[i]])

    for buckets in (up_add, up_sub, down_add, down_sub):
        for marks in buckets.values():
            marks.sort()  # 排序后子树计数变成一次二分

    ans = [0] * (n + 1)
    for u in range(1, n + 1):
        du = depth[u]
        lo = tin[u]; hi = lo + size[u] - 1
        ans[u] = (
            subtree_count(up_add, du + weight[u], lo, hi)
            + subtree_count(down_add, weight[u] - du, lo, hi)
            - subtree_count(up_sub, du + weight[u], lo, hi)
            - subtree_count(down_sub, weight[u] - du, lo, hi)
        )

    print(' '.join(map(str, ans[1:])))


if __name__ == "__main__":
    solve()

复杂度

  • 时间复杂度:DFS + Tarjan 离线 LCA O((n+m)α(n))O\bigl((n+m)\alpha(n)\bigr),差分标记 O(m)O(m),子树统计 O(nlog⁡m)O(n \log m),总计 O((n+m)log⁡m)O\bigl((n+m)\log m\bigr),与 C++ 正解同阶。
  • 空间复杂度:O(n+m)O(n+m)(邻接表、并查集、四个标记桶);本实现 Python 对象开销较大(最大组实测约 350 MB),按约定允许 MLE;官方 1 s 限时下 Python 侧可能 TLE(本地最大组 0.87 s),算法阶数不受影响。

总结

  • 看到"路径上点的到达时刻是否等于点权",先按 lca 把路径拆成上坡、下坡两段,各写出一个左边只依赖点、右边只依赖路径的等式,统计问题立刻变成"点的键是否出现在路径桶里"。
  • 路径逐点贡献 → 树上差分:加标记挂段端点,减标记挂段边界外一格(fa[L]fa[L] 与 LL),"折点算不算"这类边界由挂点位置自然表达。
  • 询问"子树内有多少个某键标记"可以用 DFS 进出场合差分,也可以用先序 tin + 子树区间 + 桶内二分;后者在 Python 里更省状态。