[NOIP 2016 提高组] 天天爱跑步
把观察条件改写为深度等式,按 LCA 拆两段路径,用桶与树上差分在 DFS 中统计每个观察员看到的人数。
OJ: luogu
题目 ID: P1600
难度:省选/NOI-
标签:LCA树形差分事件计数桶倍增
日期: 2026-07-17 02:00
形式化题目
有一棵
对每个点
思路
先看一个可以直接验证想法的朴素解:
/**
* 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 搜出唯一路径,再逐秒走过路径逐时刻打点,最后对每个观察员统计"到达时刻恰好等于
关键观察:把到达时刻写成深度等式,路径问题就退化成"特征值匹配"。 设
- 上行段
:点 的到达时刻是 ,观察员看到它当且仅当
- 下行段
(不含 ):点 的到达时刻是 ,观察员看到它当且仅当
两个等式左边都只与路径有关(路径特征值:
最终做法:树上差分 + 桶 + 一次 DFS 汇总。
-
对每条路径求
,挂四个事件: 段 特征值 key 挂点 上行 , ( 时) 下行 , 与 保证该特征值只对路径覆盖的那一段祖先链生效(树上差分的标准写法)。 -
显式栈做后序 DFS:进入点
的子树前,记下两个桶中"目标 key"( 与 )的读数;处理完整棵子树(把 子树内所有事件加入桶)后再读一次。两次读数之差就是只来自 子树的命中数,相加即 。
以样例 1 逐玩家推演(树:
| 玩家 | 路径 | 各点到达时刻 | 命中(时刻 = w) |
|---|---|---|---|
| 1-4-5 | 1:0, 4:1, 5:2 | 点 1(0=0)、点 4(1=1)、点 5(2=2) | |
| 1-2-3 | 1:0, 2:1, 3:2 | 点 1(0=0) | |
| 2-1-4-6 | 2:0, 1:1, 4:2, 6:3 | 点 6(3=3) |
每列点数正好是答案
代码
/**
* 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;
}复杂度
- 时间:预处理
,每个玩家一次 ,汇总 DFS ,总 。 - 空间:倍增表
,邻接表与事件表 ,桶 。
总结
"天天爱跑步"是树上差分的进阶题:路径覆盖之外还叠加了"时刻匹配"这一维度。套路是把到达时刻写成深度等式,让路径的贡献退化成两个只与路径有关的特征值,再用 lca-binary-lifting 倍增 LCA 模板:先让深的点提到同一层,再从大到小一起跳。
图示解析
这张 ASCII 图展示整道题的解题路线:
朴素模拟(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)图中三条主线对应"暴力慢在哪"“路径贡献如何被压缩成两个深度等式”“桶 + 树上差分如何把命中匹配批量完成”。核心是:时刻匹配维度被吸收进深度等式后,"观察员看到谁"就只剩特征值相等一个判断,配合
