两种正解并列:双 dfs 序把祖先判定变成二维偏序、线段树二分 O(log n);重链剖分后每条重链维护黑点集合 O(log^2 n)。
OJ: luogu
题目 ID: P4116
难度:提高+/省选-
标签:树dfs序线段树重链剖分set
创建: 2026-07-17 02:00
更新: 2026-10-29 17:25
目录
形式化题目
给定一棵
- 把节点
的颜色取反; - 查询根
到节点 的路径上深度最小的黑点,输出其编号;若路径上没有黑点,输出 。
关于「第一个」的方向
题面原文是「询问
这个区别很容易读反,而且读反后在多数数据上仍然能过(只有当
| 用例 | 路径 |
深度最小(本文采用) | 离 |
|---|---|---|---|
让 1 6 |
样例自带的 1 6 答案为
另一个常见的混淆点:当
3 3
1 2
1 3
0 2
0 1
1 2节点 1 2:路径
另外,题面没有把
注意不要和另一道题混淆:P4092 询问的是「离
最近的打了标记的祖先」,那是相反的方向(深度最大)。两题的题意不能互相套用。
解法总览
本题的查询对象不是「路径上的某个值」,而是「路径上满足某条件的、深度最小的那个点」,所以要解决的是如何把「
- 解法一:双 dfs 序 + 线段树二分。给每个节点两个时间戳,使「
是 的祖先」等价于两个偏序条件同时成立。于是「 的黑点祖先」变成「下标不超过 、值不超过 的位置」,用线段树二分一次查询直接得到最小下标。单次操作 。 - 解法二:重链剖分 + 每条重链一个黑点集合。重链内
与深度同序,所以一条链上最浅的黑点就是链内最小 ,用有序集合维护即可。查询时从 沿链头向上跳,共 条链,每条链取集合最小元素是 ,所以单次查询 ;取反是一次 set插入/删除,也是。
| 解法 | 核心观察 | 复杂度 | 适用场景 | 对应代码 |
|---|---|---|---|---|
| 解法一(正式主解):双 dfs 序 + 线段树二分 | 时间 |
想用最短代码换到最优常数;不需要剖分 | @include-code(./main.cpp, cpp),另有同一算法的 Python 版 @include-code(./main.py, python) |
|
| 解法二:重链剖分 + 每条重链一个黑点集合 | 重链内 |
时间 |
只需要会树链剖分,不需要额外的时间戳技巧 | @include-code(./hld_set.cpp, cpp) |
| 解法二的线段树变体 | 同上,但把每条链的集合换成「整棵线段树维护黑点最小 |
时间 |
想复用树剖 + 线段树这一套模板 | @include-code(./hld.cpp, cpp) |
解法一是正式主解,代码最短、复杂度最优,是本题的推荐写法。解法二与它的线段树变体思路相同,区别只在「链内最浅黑点」用一个独立的 std::set 维护还是用一棵全局线段树维护:set 版本少一个线段树模板、写法更直白;线段树版本则是树链剖分题解里最常见的形态,便于和其它树剖题统一。两者都不依赖额外的时间戳技巧,适合只想复习剖分的读者。
下面先用朴素方法把瓶颈暴露出来,再分别展开两个正解。
暴力解法
思路
直接维护颜色数组。取反是
代码
/**
* 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 22:59
* update_at: 2026-08-12 22:59
*/
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// 每次查询从 v 沿父链一路爬到根,途中最靠近根的那个黑点就是路径上的第一个黑点。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, q;
vector<int> g[MAXN]; // 邻接表存树
int parent[MAXN]; // parent[u]:u 的父亲(根 1 的父亲为 0)
bool black[MAXN]; // black[u]:节点 u 当前是否为黑点
// 从根 1 做 BFS,求出每个节点的父亲。
void bfs_parent(int root) {
queue<int> qu;
qu.push(root);
parent[root] = 0;
while (!qu.empty()) {
int u = qu.front();
qu.pop();
for (int j = 0; j < (int)g[u].size(); j++) {
int v = g[u][j];
if (v == parent[u]) continue;
parent[v] = u;
qu.push(v);
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> q;
for (int i = 1; i <= n - 1; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
bfs_parent(1);
while (q--) {
int op, x;
cin >> op >> x;
if (op == 0) {
black[x] = !black[x];
} else {
// 沿父链从 x 爬到根,经过的节点正好是根到 x 的整条路径;
// 遇到黑点就记录,最后一次记录的黑点最靠近根,就是第一个黑点。
int ans = -1;
for (int u = x; u != 0; u = parent[u]) {
if (black[u]) ans = u;
}
cout << ans << '\n';
}
}
return 0;
}复杂度
- 预处理求父亲:
。 - 单次取反
,单次查询 。 - 总时间
,空间 。
瓶颈
链形树上一棵
解法一(正式主解):双 dfs 序 + 线段树二分
关键观察
对每个节点记录两个时间戳:
:第一次 DFS 进入 的时刻,按进入顺序编号; :把每个节点的儿子列表翻转后再做一遍 DFS 时的进入时刻。
性质(祖先判定):
直观理由:若
说明:上面的推理是直观论证。本项目另外用独立脚本对 500 棵随机树(链、菊花、完全二叉树、随机树,
)做了逐点对穷举验证:祖先判定性质成立,并且 确实等于翻转儿子顺序的第二遍 DFS 的先序号。但这不是形式化证明。 注意
不是子树区间右端 的翻转:把上面的样例树代进去就能看出,节点 的 ,而 。可以记成 (退出计数是后序编号),但这个式子的唯一用处是说明「一遍 DFS 也能算出来」,定义本身请按「翻转儿子顺序的第二遍 DFS 的先序号」理解。
有了这条性质,把黑点
思路
整条思路分三步走:先把两遍 DFS 的编号画回树上和平面,看清「祖先」在坐标系里到底是什么形状;再把二维的矩形条件降成一维线段上的「第一个满足值」;最后把这件事交给线段树二分。
第一步:把编号画回树上和平面,看清「祖先」的形状
以样例树为例,两遍 DFS 得到的两个序如下(第一遍 DFS 的儿子顺序按输入顺序):
| 节点 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 2 | 9 | 3 | 5 | 8 | 6 | 7 | 4 | |
| 1 | 3 | 2 | 9 | 8 | 6 | 7 | 5 | 4 |
这两行就是 main.cpp 跑样例时的实际编号:
用这张表可以直接验证祖先判定:例如
光看数字不容易建立直觉,下图把这两行编号画回树上:
读这张图只需看两件事。沿红色的祖先链
把这套编号放到平面上,就得到解法一真正依赖的图形:
每个节点是平面上的一个点
第二步:把点投影到 dfn 轴上,二维矩形就塌成一根线段
第一步结束时,问题是在框
答案是竖直投影:把每个点压到
投影只做两件事:
- 落点位置
:点 落到第 格; - 存下来的值
:竖着压会丢掉高度,于是把丢掉的那一维当作这一格存的内容 —— 黑点存它的 ,白点存 (表示「这格没有黑点」)。
投影不丢东西,因为
更关键的是投影保序:横坐标小的点,落下来格子也更靠左。原来的问题问「框里横坐标最小的黑点」,投影之后就成了「最靠左的格子,它的值不超过阈值」。要不要算进答案,只看两件事:
- 格子够不够靠左:下标
—— 原矩形横方向的那条边; - 存的值够不够小:值
—— 原矩形纵方向的那条边。
两维就这样各归各位:一维当下标(决定位置和先后),一维当值(只拿去和阈值比较)。矩形塌成一维之后,线段树要回答的就是一句话:
在下标范围 [1, dfn_v] 里,找第一个值 <= rdfn_v 的格子这一步为什么能塌,关键在两维落在不同的东西上:一维是格子怎么排,一维是格子存什么。如果两维都得挤在下标上(比如既要
投影之后还剩两个细节要处理。
细节一:把“区间
线段树从根下降时如果每层都先判断“这个结点与
若 i 不存在,或 i > dfn_v -> 区间内没有答案,输出 -1细节二:“整段能不能一次排除”这个判据从哪来。
这是投影之后真正让复杂度下降的地方。结点存子树内叶子的
- 若
,说明这一段里每个点的 都超过阈值,整段无解,直接返回,不用往下走; - 否则这一段里至少有一个可行点,进入左子树继续找最靠左的。
一个结点被剪掉只花 [1,5] 这段的
这里和前置题 luogu-U502676 线段树上二分模版 是同一个骨架。那题查「区间内第一个
的数」,结点存 ,剪枝条件是「 就整段无解」;本题查「区间内第一个 的值」,结点存 ,剪枝条件是「 就整段无解」。方向相反,下降方式完全一样。
第三步:落到代码
把线段树的叶子下标设成
- 叶子
:若该节点是黑点,存它的 ;若是白点,存 ; - 内部节点存子树内所有叶子值的最小值。
于是「
- 若不存在这样的位置,或者找到的位置
,说明路径上没有黑点,输出 ; - 否则
就是最浅黑点的 ,用 node_at[i]反查编号输出。
注意必须额外判断
按样例的操作顺序模拟线段树二分:
| 操作 | 黑点叶子( |
二分条件 | 找到的最小下标 | 输出 |
|---|---|---|---|---|
1 3 |
无 | 无 | -1 | |
0 8 |
— | — | — | |
1 6 |
同上 | 7( |
8 | |
1 7 |
同上 | 7,但 |
-1 | |
0 2 |
— | — | — | |
1 9 |
同上 | 2( |
2 | |
0 2 |
只剩 |
— | — | — |
1 9 |
同上 | 无 | -1 |
对比 1 6 与 1 7 两行:1 7 时 1 6(就是上面平面图的用例),
代码
/**
* 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-23 20:05
* update_at: 2026-10-23 21:40
*/
// main.cpp:双 dfs 序 + 线段树二分,O((n+q) log n)(本题目解主解,解法一)。
// 关键性质:u 是 v 的祖先 <=> dfn[u] <= dfn[v] 且 rdfn[u] <= rdfn[v],
// 其中 dfn 是先序进入时间,rdfn 是把儿子列表翻转后再 DFS 的进入时间。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
const int INF = 0x3f3f3f3f;
int n, q;
vector<int> g[MAXN]; // 邻接表存树
int dfn[MAXN]; // dfn[u]:先序 DFS 的进入时间
int rdfn[MAXN]; // rdfn[u]:把每个节点的儿子列表翻转后再 DFS 的进入时间
int node_at[MAXN]; // node_at[i]:dfn 为 i 的节点
int timer; // 先序编号计数器,从 1 递增到 n
int reverse_timer; // 退出编号计数器,从 n 递减到 1
bool black[MAXN]; // black[u]:节点 u 当前是否为黑点
int tree[MAXN * 4]; // tree[p]:该区间内黑点 rdfn 的最小值,INF 表示区间内没有黑点
// 一遍 DFS 同时求两个序:进入时 dfn 正向编号,退出时 rdfn 倒着编号。
// rdfn 倒着编号的依据:翻转儿子列表的 DFS 的进入序,正是原 DFS 退出序的逆序。
void dfs(int u, int fa) {
dfn[u] = ++timer;
node_at[timer] = u;
for (int v : g[u])
if (v != fa) dfs(v, u);
rdfn[u] = reverse_timer--;
}
// 点更新:把位置 pos 的叶子值改成 value,并向上合并区间最小值。
void seg_update(int p, int l, int r, int pos, int value) {
if (l == r) {
tree[p] = value;
return;
}
int mid = (l + r) >> 1;
if (pos <= mid)
seg_update(p << 1, l, mid, pos, value);
else
seg_update(p << 1 | 1, mid + 1, r, pos, value);
tree[p] = min(tree[p << 1], tree[p << 1 | 1]);
}
// 在线段树上找最小的下标 i 使叶子值 <= limit(线段树二分),不存在返回 0。
int find_first(int p, int l, int r, int limit) {
if (tree[p] > limit) return 0; // 整个区间的最小值都超限,区间内没有答案
if (l == r) return l;
int mid = (l + r) >> 1;
if (tree[p << 1] <= limit) return find_first(p << 1, l, mid, limit); // 左半边有答案就进左半边
return find_first(p << 1 | 1, mid + 1, r, limit);
}
// 查询根 1 到 v 的路径上最浅的黑点编号,没有则返回 -1。
int query_first_black(int v) {
int pos = find_first(1, 1, n, rdfn[v]); // 最小的 dfn,其节点 rdfn 不超过 rdfn[v]
if (pos == 0 || pos > dfn[v]) return -1; // 不在 dfn[v] 左侧,说明路径上没有黑点
return node_at[pos];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> q;
for (int i = 1; i <= n - 1; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
timer = 0;
reverse_timer = n;
dfs(1, 0);
fill(tree, tree + 4 * n + 4, INF); // 初始全白,所有叶子的值都是 INF
while (q--) {
int op, x;
cin >> op >> x;
if (op == 0) {
black[x] = !black[x];
seg_update(1, 1, n, dfn[x], black[x] ? rdfn[x] : INF);
} else {
cout << query_first_black(x) << '\n';
}
}
return 0;
}同一算法的 Python 实现(线段树改成迭代写法,避免递归开销):
import sys
def main():
values = list(map(int, sys.stdin.buffer.read().split()))
n = values[0]
q = values[1]
graph = [[] for _ in range(n + 1)]
pos = 2
for _ in range(n - 1):
u = values[pos]
v = values[pos + 1]
pos += 2
graph[u].append(v)
graph[v].append(u)
# 一遍 DFS 同时求两个序:dfn 是进入序(正着编号),rdfn 是退出序的逆序。
# 用显式栈是因为 Python 默认递归深度只有 1000,1e5 的链会直接崩。
dfn = [0] * (n + 1)
rdfn = [0] * (n + 1)
node_at = [0] * (n + 1)
enter = 0
leave = 0
stack = [(1, 0, 0)] # (节点, 父亲, 状态),状态 0 表示进入,1 表示退出
while stack:
u, parent, state = stack.pop()
if state == 0:
enter += 1
dfn[u] = enter
node_at[enter] = u
stack.append((u, parent, 1))
for v in graph[u]:
if v != parent:
stack.append((v, u, 0))
else:
leave += 1
rdfn[u] = n - leave + 1
size = 1
while size < n:
size <<= 1
infinity = n + 1
tree = [infinity] * (2 * size) # 黑点叶子存 rdfn,白点叶子存无穷大
black = bytearray(n + 1) # black[u]:节点 u 当前是否为黑点
answers = []
for i in range(pos, len(values), 2):
op = values[i]
x = values[i + 1]
if op == 0:
# 单点取反:叶子在 rdfn 与无穷大之间切换,然后向上合并区间最小值
black[x] ^= 1
p = size + dfn[x] - 1
tree[p] = rdfn[x] if black[x] else infinity
p >>= 1
while p:
left = tree[p << 1]
right = tree[p << 1 | 1]
value = left if left < right else right
if tree[p] == value:
break
tree[p] = value
p >>= 1
else:
# 线段树二分:找最小的下标,使它的叶子值不超过 rdfn[x]
limit = rdfn[x]
if tree[1] > limit:
answers.append("-1")
continue
p = 1
left = 1
right = size
while left < right:
mid = (left + right) >> 1
if tree[p << 1] <= limit:
p <<= 1
right = mid
else:
p = p << 1 | 1
left = mid + 1
answers.append("-1" if left > dfn[x] else str(node_at[left]))
sys.stdout.write("\n".join(answers) + ("\n" if answers else ""))
main()复杂度
- 预处理:一次 DFS 求两个序,
。 - 单次取反:线段树单点更新
。 - 单次查询:一次线段树二分,
。 - 总时间
,空间 。
解法二:重链剖分 + 每条重链一个黑点集合
关键观察
重链剖分把树拆成若干条从链头往下的重链,并按「先重儿子、后轻儿子」的顺序重新编号。这个编号顺序保证了两件事:
- 同一条重链上的
连续且随深度递增。所以「链上最浅的黑点」就是「链内 最小的黑点」; - 根到
的路径可以拆成 段重链区间,并且从 沿链头向上跳时,越往后跳到的链整体越浅。
样例树剖分后的结果(「重儿子」一行中的「—」表示该节点是叶子,没有重儿子):
| 节点 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|
| 重儿子 | 2 | 9 | — | — | — | — | — | 6 | 8 |
| 链头 |
1 | 1 | 3 | 4 | 5 | 1 | 7 | 1 | 1 |
| 1 | 2 | 9 | 8 | 6 | 5 | 7 | 4 | 3 |
重链
思路
给每条重链开一个有序集合 chain_black[链头],存这条链上所有黑点的
-
取反:翻转颜色后,把
插入或从 chain_black[top[x]]删除,。 -
查询:从
开始,每次看 所在链的集合: - 若集合非空且最小元素
,说明这条链上有 的祖先变黑,候选答案是它; - 否则这条链上没有任何
的祖先变黑,整条链跳过。
然后
跳到上一条链。因为越跳越浅,最后记录的候选答案就是全局最浅的黑点。 - 若集合非空且最小元素
注意「最小元素
下图把样例树和 0 8),它在数组里对应下标
看这张图就能直接读出两件事:
- 查询
1 6时,路径整段落在链 1 的区间,段内最小黑点下标是 ,反查得节点 。 - 查询
1 7时,链 7 为空;链 1 的区间是,而黑点下标 已经越出右端点,说明这个黑点比 更深,不是祖先,所以输出 。
按样例模拟:
| 操作 | 各链黑点集合( |
查询过程 | 输出 |
|---|---|---|---|
1 3 |
全空 | 链 3:空;链 1 区间 |
-1 |
0 8 |
链 1: |
— | — |
1 6 |
链 1: |
链 1 区间 |
8 |
1 7 |
链 1: |
链 7:空;链 1 区间 |
-1 |
0 2 |
链 1: |
— | — |
1 9 |
链 1: |
链 1 区间 |
2 |
0 2 |
链 1: |
— | — |
1 9 |
链 1: |
链 1 区间 |
-1 |
1 7 一行最能说明问题:黑点
代码
/**
* 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-23 21:30
* update_at: 2026-10-23 21:40
*/
// hld_set.cpp:重链剖分 + 每条重链一个 set(解法二)。
// 每条重链内部 dfn 随深度递增,所以 set 的最小元素就是该链最浅的黑点。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, q;
vector<int> g[MAXN]; // 邻接表存树
int parent[MAXN]; // parent[u]:u 的父亲(根的父亲为 0)
int depth[MAXN]; // depth[u]:u 的深度(根为 1)
int sz[MAXN]; // sz[u]:u 的子树大小
int heavy_son[MAXN]; // heavy_son[u]:u 的重儿子,0 表示没有儿子
int top[MAXN]; // top[u]:u 所在重链的链头
int dfn[MAXN]; // dfn[u]:u 剖分后的编号(同一条重链上随深度递增)
int node_at[MAXN]; // node_at[i]:编号 i 对应的原节点
int timer; // 剖分编号计数器
bool black[MAXN]; // black[u]:节点 u 当前是否为黑点
set<int> chain_black[MAXN]; // chain_black[t]:链头为 t 的重链上所有黑点的 dfn,按 dfn 升序
// 第一遍 DFS:求父亲、深度、子树大小和重儿子。
void dfs_size(int u, int fa) {
parent[u] = fa;
depth[u] = depth[fa] + 1;
sz[u] = 1;
heavy_son[u] = 0;
for (int v : g[u]) {
if (v == fa) continue;
dfs_size(v, u);
sz[u] += sz[v];
if (sz[v] > sz[heavy_son[u]]) heavy_son[u] = v;
}
}
// 第二遍 DFS:先给重儿子编号,让同一条重链的 dfn 连续且随深度递增;轻儿子各自开新链。
void dfs_decompose(int u, int chain_top) {
top[u] = chain_top;
dfn[u] = ++timer;
node_at[timer] = u;
if (heavy_son[u] != 0) dfs_decompose(heavy_son[u], chain_top);
for (int v : g[u]) {
if (v == parent[u] || v == heavy_son[u]) continue;
dfs_decompose(v, v);
}
}
// 查询根 1 到 v 的路径上最浅的黑点编号,没有则返回 -1。
// 从 v 所在重链开始向上逐链跳:链上最浅的黑点是 set 的最小 dfn,
// 若它比 dfn[v] 深就说明这条链上没有 v 的祖先被染黑,直接跳过整条链。
int query_first_black(int v) {
int answer = -1;
int u = v;
while (u != 0) {
int chain_top = top[u];
if (!chain_black[chain_top].empty()) {
int shallowest = *chain_black[chain_top].begin();
if (shallowest <= dfn[u]) answer = node_at[shallowest]; // 越往上跳覆盖的答案越浅
}
u = parent[chain_top];
}
return answer;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> q;
for (int i = 1; i <= n - 1; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
dfs_size(1, 0);
timer = 0;
dfs_decompose(1, 1);
while (q--) {
int op, x;
cin >> op >> x;
if (op == 0) {
black[x] = !black[x];
if (black[x])
chain_black[top[x]].insert(dfn[x]);
else
chain_black[top[x]].erase(dfn[x]);
} else {
cout << query_first_black(x) << '\n';
}
}
return 0;
}复杂度
- 预处理:两次 DFS,
。 - 单次取反:
set插入或删除,。 - 单次查询:跳
条链,每条链一次 set取值(取最小元素), 。 - 总时间
,空间 。
同一思路的线段树变体
如果不想为每条链单独开 set,可以把「链内最小
这份实现沿用 rbook 模板 hld 的全局数组 + 普通函数写法,代码分成两段:
- sgt 段:线段树维护
base[1..n],支持单点改与区间最小值 - hld 段:
fa / dep / sz / son / top / dfn描述树结构,node_at[t]把 dfn 反查回原节点
dfs1 求 fa / dep / sz / son,dfs2 按「重儿子优先」分配 dfn 与链头 top,保证链内 dfn 随深度递增;建树时把所有叶子初始化为 INF(全白),取反时把 dfn[x] 位置写成 dfn[x] 或 INF。
/**
* 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-23 20:05
* update_at: 2026-10-29 16:52
*/
#include <bits/stdc++.h>
using namespace std;
// 树链剖分(HLD)+ 线段树,节点与区间均为 1 下标。
// 线段树维护「区间内黑点的最小 dfn」,查询时从根侧往 v 侧逐段查重链段。
// 参考 rbook 模板 hld 的全局数组 + 普通函数写法,只保留本题需要的部分。
const int maxn = 100000 + 5;
const int INF = 0x3f3f3f3f;
int n, q;
/*------------------------ sgt 代码 开始 ------------------------*/
// 线段树:维护数组 base[1..n],支持单点改、区间最小值。
// base[t] = 黑点 ? t : INF;内部节点存左右儿子的较小值。
int base[maxn];
int tree[maxn << 2];
int ls(int p) { return p << 1; } // p 的左儿子 2p
int rs(int p) { return p << 1 | 1; } // p 的右儿子 2p+1
void push_up(int p) {
tree[p] = min(tree[ls(p)], tree[rs(p)]);
}
void seg_build(int p, int l, int r) {
if (l == r) {
tree[p] = base[l];
return;
}
int mid = (l + r) >> 1;
seg_build(ls(p), l, mid);
seg_build(rs(p), mid + 1, r);
push_up(p);
}
// 单点改:把 dfn 位置 pos 的值改成 value。
void point_update(int pos, int value, int p, int l, int r) {
if (l == r) {
tree[p] = value;
return;
}
int mid = (l + r) >> 1;
if (pos <= mid) point_update(pos, value, ls(p), l, mid);
else point_update(pos, value, rs(p), mid + 1, r);
push_up(p);
}
// 区间最小值:不相交的分支返回 INF,不参与 min。
int range_min(int ql, int qr, int p, int l, int r) {
if (qr < l || r < ql) return INF; // 不相交
if (ql <= l && r <= qr) return tree[p]; // 完全包含
int mid = (l + r) >> 1;
int answer = range_min(ql, qr, ls(p), l, mid);
answer = min(answer, range_min(ql, qr, rs(p), mid + 1, r));
return answer;
}
/*------------------------ sgt 代码 结束 ------------------------*/
/*------------------------ hld 代码 开始 ------------------------*/
// 树链剖分:g 存树,6 个核心数组维护树上信息。
vector<int> g[maxn];
int fa[maxn]; // fa[u]:父亲,根的 fa 为 0
int dep[maxn]; // dep[u]:深度,根为 1
int sz[maxn]; // sz[u]:子树大小(含自己)
int son[maxn]; // son[u]:重儿子,叶子为 0
int top[maxn]; // top[u]:所在重链的链头
int dfn[maxn]; // dfn[u]:线段树上的位置(DFS 新编号)
int node_at[maxn]; // node_at[t]:dfn 为 t 的原节点
int timer;
bool black[maxn]; // black[u]:节点 u 当前是否为黑点
void add_edge(int u, int v) {
g[u].push_back(v);
g[v].push_back(u);
}
// 第一遍 DFS:往下求 fa、dep,回溯时汇总 sz 并选出重儿子 son
void dfs1(int u, int father) {
fa[u] = father;
dep[u] = dep[father] + 1; // 根的 father 为 0、dep[0] 恒为 0,故根深度为 1
sz[u] = 1; // 先算上自己
son[u] = 0; // 叶子没有重儿子
for (int v : g[u]) {
if (v == father) continue;
dfs1(v, u);
sz[u] += sz[v];
if (sz[v] > sz[son[u]]) son[u] = v;
}
}
// 第二遍 DFS:重儿子优先分配 dfn,保证重链连续且链内 dfn 随深度递增
void dfs2(int u, int head) {
top[u] = head;
dfn[u] = ++timer;
node_at[timer] = u;
if (son[u]) dfs2(son[u], head);
for (int v : g[u]) {
if (v == fa[u] || v == son[u]) continue;
dfs2(v, v);
}
}
// 预处理入口:加完边之后调用一次。
void build(int root) {
dfs1(root, 0); // 填 fa / dep / sz / son
timer = 0; // 从 1 重新分配 dfn
dfs2(root, root); // 填 top / dfn / node_at
// 初始全白,所有叶子的值都是 INF
for (int i = 1; i <= n; i++) base[i] = INF;
seg_build(1, 1, n);
}
// 查询根 1 到 v 的路径上最浅的黑点编号,没有则返回 -1。
// 先把路径拆成若干重链段(从 v 向根收集),再按根侧到 v 侧的顺序逐段查最小值,
// 第一个出现的黑点就是答案(链内 dfn 与深度同序,区间最小值即最浅黑点)。
int query_first_black(int v) {
vector<pair<int, int>> segments; // 每段是 [链头 dfn, 当前节点 dfn]
int u = v;
while (top[u] != 1) {
segments.push_back(make_pair(dfn[top[u]], dfn[u]));
u = fa[top[u]];
}
segments.push_back(make_pair(dfn[1], dfn[u])); // 最后一段一定包含根 1
for (int i = (int)segments.size() - 1; i >= 0; i--) {
int min_dfn = range_min(segments[i].first, segments[i].second, 1, 1, n);
if (min_dfn != INF) return node_at[min_dfn];
}
return -1;
}
/*------------------------ hld 代码 结束 ------------------------*/
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> q;
for (int i = 1; i <= n; i++) g[i].clear();
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
add_edge(u, v);
}
build(1); // 根固定为 1
while (q--) {
int op, x;
cin >> op >> x;
if (op == 0) {
// 取反颜色:黑点存自己的 dfn,白点存 INF
black[x] = !black[x];
point_update(dfn[x], black[x] ? dfn[x] : INF, 1, 1, n);
} else {
cout << query_first_black(x) << '\n';
}
}
return 0;
}两种写法本质相同,只差「链内最浅黑点」的存储介质:set 版把不同链的数据物理分开,取最小元素是 set 版常数更小、代码更短,本题目解以它作为解法二的正式实现;线段树版则更容易与其它树链剖分题共用模板。
复杂度对比
| 解法 | 单次取反 | 单次查询 | 总时间 | 额外技巧 | 实测( |
|---|---|---|---|---|---|
| 双 dfs 序 + 线段树二分 | 需要翻转儿子列表的第二遍 DFS | 约 52 ms | |||
重链剖分 + 每链 set |
只需标准剖分 | 约 63 ms | |||
| 重链剖分 + 线段树 | 只需标准剖分 | 约 69 ms |
三者的实测时间都很小,本题的数据量对任何
考场策略:第一次在赛场上见到这类题,优先走解法二。重链剖分是背得下来的模板,从「路径拆成
总结
- 本题的难点不在数据结构,而在把「是祖先」翻译成可维护的条件。解法一用两个时间戳把祖先关系变成二维偏序,再用线段树二分取最小下标;解法二用剖分把路径拆成有序区间,再在每条链内取最小
。 - 两种解法都依赖同一个事实:在「同一段连续编号」内,
的大小顺序与深度顺序一致。解法一里这段编号是整个 DFS 序(但要用 排除非祖先),解法二里这段编号是一条重链(天然只含祖先链上的一段)。 - 「查询路径上第一个 / 最浅的某种点」是树剖的典型应用场景;而「两个 DFS 序做二维偏序」是一个适用范围更广的技巧,遇到「
是 的祖先」这类条件时值得优先考虑。
图示解析
这张图对比两条正解路线:
问题:根到 v 的路径上最浅的黑点
|
+---------------------+---------------------+
| |
解法一:双 dfs 序 解法二:重链剖分
祖先判定 -> 二维偏序 路径 -> O(log n) 段重链区间
(dfn_u <= dfn_v) 且 (rdfn_u <= rdfn_v) 段内 dfn 随深度递增
| |
线段树叶子存 rdfn,内部存 min 每条链一个 set,存黑点 dfn
线段树二分找最小 dfn 取链内最小 dfn,逐链上跳
| |
O(log n) 单次查询 O(log^2 n) 单次查询
| |
+---------------------+---------------------+
|
都需要:判断「最浅黑点」是否真是 v 的祖先
(解法一判 i <= dfn_v,解法二判 min dfn <= dfn_u)图中上半部分展示两条路线的分岔点:解法一把「祖先」变成两个不等式的合取,解法二把「路径」变成有序区间序列。下半部分标出两条路线共同的坑 —— 找到的「最浅黑点」可能并不在
参考与致谢
本题解法评估阶段阅读了洛谷题解区的公开题解,下表区分「哪些思路来自原题解」与「哪些是本篇自行补的论证、代码与验证」。
| 来源与作者 | 题解标题 / 链接 | 本篇采纳的内容 | 本篇独立完成的内容 |
|---|---|---|---|
| CYFqwq(uid 1512824) | 题解:P4116 Qtree3 | 解法一的核心技巧:翻转儿子列表的第二遍 DFS 得到 |
重新推导并写出完整证明思路;补出「 |
| Zcus(uid 33339) | 题解 P4116 【Qtree3】 | 解法二的核心观察:每条重链维护一个黑点集合,集合按深度取最小,查询时沿链头向上跳并检查该黑点是否比 |
用 std::set 存 set<节点>;补写「最小元素可能比 |
| 81179332_(uid 53994) | 题解 P4116 【Qtree3】 | 与上篇相同的「每链一个集合」路线,以及「不必维护 |
同上;另说明为何本仓库实现仍使用 |
| Treaker(uid 153898)、yu__xuan(uid 142110)、wxgwxg(uid 36103) | 7bkhqrs3、tkmwuwe9、22gbxp0c | 「黑点存 |
作为解法二的线段树变体保留,用于对比 set 与线段树两种存储介质的取舍 |
| wrpwrp(uid 139012)、long__long(uid 1164409)、kririae(uid 63667)、what_can_I_do(uid 658973)、starowo(uid 1253524) | tef0j1u0、wxn8f28o、3xzpawuw、obmpkv47、fio0aqka | 无(仅作为解法谱系的横向参考:LCT、树状数组倍增、分块、带修树上莫队) | 本篇未采纳这些路线。评估结论见下 |
评估结论(本篇自查,供读者参考,不代表对原作者的评判):上述 12 篇公开题解中,本篇对每篇都编译后与独立暴力对拍(365 组小数据,3xzpawuw(分块)在「0 2 两次后再查 1 3」这类「同一节点被反复取反」的用例上出错,fio0aqka(带修树上莫队)在链形小数据上答案与暴力不符。这两篇的路线实现复杂度也明显高于本篇采用的两个解法,因此未纳入正文。