[NOIP 2016 提高组] 天天爱跑步

把观察条件改写为深度等式,按 LCA 拆两段路径,用桶与树上差分在 DFS 中统计每个观察员看到的人数。

OJ: luogu

题目 ID: P1600

难度:省选/NOI-

标签:LCA树形差分事件计数倍增

日期: 2026-07-17 02:00

形式化题目

有一棵 nn 个点的树,根为 11mm 个玩家同时从第 00 秒出发,玩家 iisis_i 沿唯一最短路径以每秒一条边的速度走向 tit_i,到达终点立即消失。每个点 uu 有一个观察时刻 wuw_u,观察员只在第 wuw_u 秒看一次:玩家恰在这一秒位于 uu 点(若 uu 是终点则为"恰好在这一秒到达")就被看到。

对每个点 uu,求能被看到的玩家数量。

思路

先看一个可以直接验证想法的朴素解:

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-08-12 23:00
 * update_at: 2026-08-12 23:01
 */
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// 做法:对每个玩家,先在树上 DFS 搜出 s -> t 的唯一路径,
// 再逐秒走过这条路径给每个点“打点”(记录到达时刻);
// 最后对每个观察员 u 统计“到达时刻恰好等于 w[u]”的玩家数。
// 复杂度 O(m * n),只适合 n 很小的小数据。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 25;

int n, m;
vector<int> g[MAXN];
int w[MAXN];

int path[MAXN];          // 搜出的 s -> t 路径,按经过顺序存放
int path_len;            // 路径上点的个数
int arrive_time[MAXN];   // arrive_time[u]:当前玩家到达 u 的时刻,-1 表示没经过
int answer[MAXN];        // answer[u]:u 点观察员看到的人数

// 在树上搜 u -> target 的路径,找到后把整条路径填进 path[]。
// fa 是 u 的父亲,用来防止往回走。树上路径唯一,一定能找到。
bool dfs_find_path(int u, int fa, int target) {
    path[path_len++] = u;
    if (u == target) {
        return true;
    }
    for (size_t i = 0; i < g[u].size(); i++) {
        int v = g[u][i];
        if (v == fa) {
            continue;
        }
        if (dfs_find_path(v, u, target)) {
            return true;
        }
    }
    path_len--;  // 这条分支没有 target,撤销 u
    return false;
}

// 模拟一个玩家从 s 跑到 t:逐时刻打点,再统计能被观察到的观察员。
void simulate_player(int s, int t) {
    path_len = 0;
    dfs_find_path(s, 0, t);  // path[0] = s, path[path_len-1] = t

    for (int u = 1; u <= n; u++) {
        arrive_time[u] = -1;
    }
    // 逐时刻打点:第 i 秒到达 path[i]。
    for (int i = 0; i < path_len; i++) {
        arrive_time[path[i]] = i;
    }

    // 对观察员计数:到达时刻恰好等于观察时刻 w[u]。
    for (int u = 1; u <= n; u++) {
        if (arrive_time[u] == w[u]) {
            answer[u]++;
        }
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }
    for (int i = 1; i <= n; i++) {
        cin >> w[i];
    }

    for (int i = 1; i <= m; i++) {
        int s, t;
        cin >> s >> t;
        simulate_player(s, t);
    }

    for (int u = 1; u <= n; u++) {
        if (u > 1) {
            cout << ' ';
        }
        cout << answer[u];
    }
    cout << '\n';

    return 0;
}

brute.cpp 对每个玩家先 DFS 搜出唯一路径,再逐秒走过路径逐时刻打点,最后对每个观察员统计"到达时刻恰好等于 wuw_u"的玩家数。单个玩家 O(n)O(n),总复杂度 O(nm)O(nm)3×1053 \times 10^5 的数据完全不可行。

关键观察:把到达时刻写成深度等式,路径问题就退化成"特征值匹配"。p=lca(s,t)p = \mathrm{lca}(s,t),玩家第 00 秒从 ss 出发,到达某点的时间就是"从 ss 走到该点的边数"。按 pp 把路径拆成两段:

  • 上行段 sps \to p:点 uu 的到达时刻是 depth[s]depth[u]depth[s] - depth[u],观察员看到它当且仅当
depth[s]depth[u]=wu    depth[s]=depth[u]+wudepth[s] - depth[u] = w_u \iff depth[s] = depth[u] + w_u
  • 下行段 ptp \to t(不含 pp:点 uu 的到达时刻是 depth[s]2depth[p]+depth[u]depth[s] - 2 \cdot depth[p] + depth[u],观察员看到它当且仅当
depth[s]2depth[p]+depth[u]=wu    depth[s]2depth[p]=wudepth[u]depth[s] - 2 \cdot depth[p] + depth[u] = w_u \iff depth[s] - 2 \cdot depth[p] = w_u - depth[u]

两个等式左边都只与路径有关(路径特征值:depth[s]depth[s]depth[s]2depth[p]depth[s] - 2 \cdot depth[p]),右边只与观察点有关depth[u]+wudepth[u] + w_uwudepth[u]w_u - depth[u])。于是"谁看到谁"变成"路径特征值是否等于点的固定 key"。

最终做法:树上差分 + 桶 + 一次 DFS 汇总。

  1. 对每条路径求 p=lca(s,t)p = \mathrm{lca}(s,t),挂四个事件:

    特征值 key 挂点
    上行 sps \to p depth[s]depth[s] s:+1s: +1parent(p):1parent(p): -1p1p \ne 1 时)
    下行 ptp \to t depth[s]2depth[p]+offsetdepth[s] - 2 \cdot depth[p] + offset t:+1t: +1p:1p: -1

    +1+11-1 保证该特征值只对路径覆盖的那一段祖先链生效(树上差分的标准写法)。

  2. 显式栈做后序 DFS:进入点 uu 的子树前,记下两个桶中"目标 key"(depth[u]+wudepth[u] + w_uwudepth[u]+offsetw_u - depth[u] + offset)的读数;处理完整棵子树(把 uu 子树内所有事件加入桶)后再读一次。两次读数之差就是只来自 uu 子树的命中数,相加即 answer[u]answer[u]

以样例 1 逐玩家推演(树:23,12,14,45,462-3,1-2,1-4,4-5,4-6w=(0,2,5,1,2,3)w = (0,2,5,1,2,3)):

玩家 路径 各点到达时刻 命中(时刻 = w)
151 \to 5 1-4-5 1:0, 4:1, 5:2 点 1(0=0)、点 4(1=1)、点 5(2=2)
131 \to 3 1-2-3 1:0, 2:1, 3:2 点 1(0=0)
262 \to 6 2-1-4-6 2:0, 1:1, 4:2, 6:3 点 6(3=3)

每列点数正好是答案 2 0 0 1 1 12\ 0\ 0\ 1\ 1\ 1,与样例输出一致。

代码

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-08-12 23:00
 * update_at: 2026-08-12 23:01
 */
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 300000 + 5;
const int LOG = 20;  // 2^19 > 3e5,20 层倍增足够

int n, m;
vector<int> g[MAXN];  // 树的邻接表
int w[MAXN];          // w[u]:u 点观察员的观察时刻

int depth_arr[MAXN];       // depth_arr[u]:u 的深度,根 1 的深度为 0
int up[MAXN][LOG];         // up[u][j]:u 向上跳 2^j 步到达的点

// 第一类事件:上升段 s -> lca 的 (key, delta)
// key = depth[s],在 s 处 +1,在 parent(lca) 处 -1
vector<pair<int, int>> event_up[MAXN];
// 第二类事件:下降段 lca -> t 的 (key, delta)
// key = depth[s] - 2*depth[lca] + offset,在 t 处 +1,在 lca 处 -1
vector<pair<int, int>> event_down[MAXN];

const int BUCKET_SIZE = 2 * MAXN + 10;  // 两类 key 平移后最大约 2n
int cnt_up[BUCKET_SIZE];                // 第一类 key 的计数桶
int cnt_down[BUCKET_SIZE];              // 第二类 key 的计数桶
int offset_value;                       // 第二类 key 的平移量

int answer[MAXN];  // answer[u]:u 点观察员看到的人数

// 非递归遍历整棵树,预处理深度和倍增祖先表(避免栈溢出)。
void build_lca() {
    vector<int> st;
    st.push_back(1);
    for (int j = 0; j < LOG; j++) {
        up[1][j] = 1;  // 根的祖先是它自己
    }

    while (!st.empty()) {
        int u = st.back();
        st.pop_back();

        for (size_t i = 0; i < g[u].size(); i++) {
            int v = g[u][i];
            if (v == up[u][0]) {
                continue;  // 跳过父亲
            }

            depth_arr[v] = depth_arr[u] + 1;
            up[v][0] = u;
            for (int j = 1; j < LOG; j++) {
                up[v][j] = up[up[v][j - 1]][j - 1];
            }
            st.push_back(v);
        }
    }
}

// 求 a 和 b 的最近公共祖先。
int lca(int a, int b) {
    if (depth_arr[a] < depth_arr[b]) {
        swap(a, b);
    }

    // 先把 a 提到和 b 同一深度。
    int diff = depth_arr[a] - depth_arr[b];
    for (int j = 0; j < LOG; j++) {
        if (diff & (1 << j)) {
            a = up[a][j];
        }
    }

    if (a == b) {
        return a;
    }

    // 从大到小尝试一起往上跳,最后跳到的点再往上一步就是 lca。
    for (int j = LOG - 1; j >= 0; j--) {
        if (up[a][j] != up[b][j]) {
            a = up[a][j];
            b = up[b][j];
        }
    }
    return up[a][0];
}

// 往桶里加入一个事件,key 必须落在桶范围内才生效。
void add_to_bucket(int cnt[], int key, int delta) {
    if (0 <= key && key < BUCKET_SIZE) {
        cnt[key] += delta;
    }
}

// 显式栈模拟后序遍历:记录进入每个点子树前两个桶的读数。
struct Frame {
    int u;             // 当前点
    int child_idx;     // 下一个要访问的孩子下标
    int before_up;     // 进入 u 子树前,第一类目标 key 的桶值
    int before_down;   // 进入 u 子树前,第二类目标 key 的桶值
};

void solve() {
    offset_value = n + 2;

    // 把每条玩家路径拆成两段,分别挂事件。
    for (int i = 1; i <= m; i++) {
        int s, t;
        cin >> s >> t;
        int p = lca(s, t);

        // 上升段 s -> lca:u 在上升段当且仅当被观察到时满足
        // depth[s] - depth[u] == w[u],即 depth[s] == depth[u] + w[u]。
        // 在 s 处 +1,在 parent(lca) 处 -1(lca 是根时不用减)。
        event_up[s].push_back(make_pair(depth_arr[s], 1));
        if (p != 1) {
            event_up[up[p][0]].push_back(make_pair(depth_arr[s], -1));
        }

        // 下降段 lca -> t(不含 lca):u 在下降段当且仅当被观察到时满足
        // depth[s] - 2*depth[lca] + depth[u] == w[u],
        // 即 depth[s] - 2*depth[lca] == w[u] - depth[u]。
        // key 可能为负,整体平移 offset_value 后再入桶。
        int key = depth_arr[s] - 2 * depth_arr[p] + offset_value;
        event_down[t].push_back(make_pair(key, 1));
        event_down[p].push_back(make_pair(key, -1));
    }

    // 显式栈做后序遍历:进入点 u 前记下桶值,处理完 u 的整棵子树后
    // 再读一次桶值,两者的差就是 u 子树对 u 的贡献。
    vector<Frame> st;
    Frame root_frame;
    root_frame.u = 1;
    root_frame.child_idx = 0;
    root_frame.before_up = cnt_up[depth_arr[1] + w[1]];
    root_frame.before_down = cnt_down[w[1] - depth_arr[1] + offset_value];
    st.push_back(root_frame);

    while (!st.empty()) {
        int u = st.back().u;
        int child_idx = st.back().child_idx;

        if (child_idx < (int)g[u].size()) {
            // 遍历 u 的下一个孩子 v。
            st.back().child_idx++;
            int v = g[u][child_idx];
            if (v == up[u][0]) {
                continue;  // 跳过父亲
            }

            Frame child_frame;
            child_frame.u = v;
            child_frame.child_idx = 0;
            child_frame.before_up = cnt_up[depth_arr[v] + w[v]];
            child_frame.before_down = cnt_down[w[v] - depth_arr[v] + offset_value];
            st.push_back(child_frame);
        } else {
            // u 的所有孩子都处理完:先把挂在 u 上的事件加入桶。
            for (size_t i = 0; i < event_up[u].size(); i++) {
                add_to_bucket(cnt_up, event_up[u][i].first, event_up[u][i].second);
            }
            for (size_t i = 0; i < event_down[u].size(); i++) {
                add_to_bucket(cnt_down, event_down[u][i].first, event_down[u][i].second);
            }

            // 目标 key:depth[u] + w[u] 与 w[u] - depth[u] + offset。
            int cur_up = cnt_up[depth_arr[u] + w[u]];
            int cur_down = cnt_down[w[u] - depth_arr[u] + offset_value];
            answer[u] = (cur_up - st.back().before_up) + (cur_down - st.back().before_down);
            st.pop_back();
        }
    }

    for (int i = 1; i <= n; i++) {
        if (i > 1) {
            cout << ' ';
        }
        cout << answer[i];
    }
    cout << '\n';
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }
    for (int i = 1; i <= n; i++) {
        cin >> w[i];
    }

    build_lca();
    solve();

    return 0;
}

复杂度

  • 时间:预处理 O(nlogn)O(n \log n),每个玩家一次 lcalca O(mlogn)O(m \log n),汇总 DFS O(n+m)O(n + m),总 O((n+m)logn)O((n+m)\log n)
  • 空间:倍增表 O(nlogn)O(n \log n),邻接表与事件表 O(n+m)O(n + m),桶 O(n)O(n)

总结

"天天爱跑步"是树上差分的进阶题:路径覆盖之外还叠加了"时刻匹配"这一维度。套路是把到达时刻写成深度等式,让路径的贡献退化成两个只与路径有关的特征值,再用 +1/1+1/-1 事件 + 桶计数离线汇总。关键公式是上行段的 depth[s]=depth[u]+wudepth[s] = depth[u] + w_u 与下行段的 depth[s]2depth[p]=wudepth[u]depth[s] - 2 \cdot depth[p] = w_u - depth[u]:左边是路径特征值,右边是点的 key,相等即命中。实现上利用倍增 LCA 拆路径、显式栈避免深递归,是 O((n+m)logn)O((n+m)\log n) 的满分做法。rbook 的《倍增求 LCA》讲解了本解使用的 lca-binary-lifting 倍增 LCA 模板:先让深的点提到同一层,再从大到小一起跳。

图示解析

这张 ASCII 图展示整道题的解题路线:

text
朴素模拟(brute.cpp)
  逐玩家 DFS 搜出 s -> t 路径,逐秒打点记录到达时刻   O(nm)
        |
        | 瓶颈:每个玩家扫描整条路径,路径重叠被反复走
        v
关键观察(差分转换)
  上行段 s -> lca:   到达时刻 = depth[s] - depth[u]
                     命中条件: depth[s] = depth[u] + w[u]
  下行段 lca -> t:   到达时刻 = depth[s] - 2*depth[lca] + depth[u]
                     命中条件: depth[s] - 2*depth[lca] = w[u] - depth[u]
  左边是路径特征值,右边是点的 key,相等即命中
        |
        v
树上差分 + 桶 + 后序 DFS(main.cpp)
  上行: s +1, parent(lca) -1   特征值 depth[s]
  下行: t +1, lca -1           特征值 depth[s] - 2*depth[lca] + offset
  显式栈后序遍历:进入子树前记桶值,子树处理完再读,
  差值 = 该点子树内命中的玩家数 = answer[u]
        |
        v
复杂度 O((n + m) log n),空间 O(n log n + m)

图中三条主线对应"暴力慢在哪"“路径贡献如何被压缩成两个深度等式”“桶 + 树上差分如何把命中匹配批量完成”。核心是:时刻匹配维度被吸收进深度等式后,"观察员看到谁"就只剩特征值相等一个判断,配合 +1/1+1/-1 事件即可一次 DFS 统计所有点。