形式化题目

给定一棵 nn 个节点的无根树,以节点 11 为根,每个节点有黑白两色,初始全为白。按顺序处理 mm 次操作:

  1. 把节点 ii 的颜色取反;
  2. 查询根 11 到节点 vv 的路径上深度最小的黑点,输出其编号;若路径上没有黑点,输出 −1-1。

关于「第一个」的方向

题面原文是「询问 11 到 vv 的路径上的第一个黑点」(SPOJ 原题:the first black node on the path from node 1 to node v)。路径是从 11 往 vv 读的,所以「第一个」指离根最近、深度最小的黑点,而不是离 vv 最近的那个。

这个区别很容易读反,而且读反后在多数数据上仍然能过(只有当 vv 本身和它的某个祖先同时为黑时才暴露)。用题面样例就能确认方向:

用例 路径 1→v1\to v 上的黑点 深度最小(本文采用) 离 vv 最近(不是本题)
让 88 与 66 同时为黑,查询 1 6 {8,6}\{8,6\} 88 66

样例自带的 1 6 答案为 88(当时只有 88 是黑点),但把 66 也染黑后,题面要求的仍然是 88 —— 因为 88 比 66 更靠近根 11。本文四个实现与 12 篇公开题解在此用例上均输出 88,与题面一致。

另一个常见的混淆点:当 vv 本身为黑、且它的某个祖先也为黑时,答案仍是最浅的那个祖先,不是 vv。例如

text
3 3
1 2
1 3
0 2
0 1
1 2

节点 1,21,2 同时为黑,查询 1 2:路径 1→21\to2 上的黑点是 {1,2}\{1,2\},最浅的是 11,所以答案是 11(而不是 22)。本文四个实现与 11 篇公开题解都输出 11。

另外,题面没有把 vv 自身排除在外:路径包含 vv,所以 vv 自己是黑点、且它的祖先都不是黑点时,答案就是 vv 本身。本文的实现均正确包含了这一点(已用 200 组「只把 vv 自己染黑」的数据专项验证,全部输出 vv)。

注意不要和另一道题混淆:P4092 询问的是「离 uu 最近的打了标记的祖先」,那是相反的方向(深度最大)。两题的题意不能互相套用。

解法总览

本题的查询对象不是「路径上的某个值」,而是「路径上满足某条件的、深度最小的那个点」,所以要解决的是如何把「uu 是 vv 的祖先」这个条件翻译成数据结构能维护的形式。围绕这一点有两种独立完整、都可直接提交的正解:

  1. 解法一:双 dfs 序 + 线段树二分。给每个节点两个时间戳,使「uu 是 vv 的祖先」等价于两个偏序条件同时成立。于是「vv 的黑点祖先」变成「下标不超过 dfnv\mathrm{dfn}_v、值不超过 rdfnv\mathrm{rdfn}_v 的位置」,用线段树二分一次查询直接得到最小下标。单次操作 O(log⁡n)O(\log n)。
  2. 解法二:重链剖分 + 每条重链一个黑点集合。重链内 dfn\mathrm{dfn} 与深度同序,所以一条链上最浅的黑点就是链内最小 dfn\mathrm{dfn},用有序集合维护即可。查询时从 vv 沿链头向上跳,共 O(log⁡n)O(\log n) 条链,每条链取集合最小元素是 O(1)O(1),所以单次查询 O(log⁡n)O(\log n);取反是一次 set 插入/删除,也是 O(log⁡n)O(\log n)。
解法 核心观察 复杂度 适用场景 对应代码
解法一(正式主解):双 dfs 序 + 线段树二分 uu 是 vv 的祖先   ⟺  dfnu⩽dfnv\iff \mathrm{dfn}_u\leqslant\mathrm{dfn}_v 且 rdfnu⩽rdfnv\mathrm{rdfn}_u\leqslant\mathrm{rdfn}_v,一次线段树二分得到最小 dfn\mathrm{dfn} 时间 O((n+m)log⁡n)O((n+m)\log n),空间 O(n)O(n) 想用最短代码换到最优常数;不需要剖分 @include-code(./main.cpp, cpp),另有同一算法的 Python 版 @include-code(./main.py, python)
解法二:重链剖分 + 每条重链一个黑点集合 重链内 dfn\mathrm{dfn} 随深度递增,链上最浅黑点 = 链内最小 dfn\mathrm{dfn} 时间 O(n+mlog⁡n)O(n+m\log n),空间 O(n)O(n) 只需要会树链剖分,不需要额外的时间戳技巧 @include-code(./hld_set.cpp, cpp)
解法二的线段树变体 同上,但把每条链的集合换成「整棵线段树维护黑点最小 dfn\mathrm{dfn}」 时间 O((n+m)log⁡2n)O((n+m)\log^2 n),空间 O(n)O(n) 想复用树剖 + 线段树这一套模板 @include-code(./hld.cpp, cpp)

解法一是正式主解,代码最短、复杂度最优,是本题的推荐写法。解法二与它的线段树变体思路相同,区别只在「链内最浅黑点」用一个独立的 std::set 维护还是用一棵全局线段树维护:set 版本少一个线段树模板、写法更直白;线段树版本则是树链剖分题解里最常见的形态,便于和其它树剖题统一。两者都不依赖额外的时间戳技巧,适合只想复习剖分的读者。

下面先用朴素方法把瓶颈暴露出来,再分别展开两个正解。

暴力解法

思路

直接维护颜色数组。取反是 O(1)O(1) 的单点修改;查询时从 vv 沿父指针一路爬到根,遇到黑点就记录,最后一次记录的黑点最靠近根,就是答案。

代码

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 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;
}

复杂度

  • 预处理求父亲:O(n)O(n)。
  • 单次取反 O(1)O(1),单次查询 O(depth)O(\mathrm{depth})。
  • 总时间 O(nm)O(nm),空间 O(n)O(n)。

瓶颈

链形树上一棵 nn 个点的树只有一条路径,从链底查询要爬 nn 步。n=105n=10^5、m=105m=10^5 时最坏 101010^{10} 次操作,必然超时。真正的浪费在于:每次查询都把整条祖先链重走一遍,而其中绝大多数节点的颜色根本没变。要消除这个浪费,就需要一个能直接回答「这条路径上最浅的黑点是谁」的结构,而不是逐点检查。

解法一(正式主解):双 dfs 序 + 线段树二分

关键观察

对每个节点记录两个时间戳:

  • dfnu\mathrm{dfn}_u:第一次 DFS 进入 uu 的时刻,按进入顺序编号;
  • rdfnu\mathrm{rdfn}_u:把每个节点的儿子列表翻转后再做一遍 DFS 时的进入时刻。

性质(祖先判定):uu 是 vv 的祖先,当且仅当

dfnu⩽dfnv且rdfnu⩽rdfnv.\mathrm{dfn}_u\leqslant\mathrm{dfn}_v \quad\text{且}\quad \mathrm{rdfn}_u\leqslant\mathrm{rdfn}_v .

直观理由:若 uu 是 vv 的祖先,两遍 DFS 都从 uu 走到 vv,进入时刻自然都是 uu 更早。若 u,vu,v 没有祖先关系,设 l=lca(u,v)l=\mathrm{lca}(u,v),则二者落在 ll 的两个不同儿子的子树里;第一遍 DFS 谁先被遍历取决于儿子顺序,翻转儿子顺序后这一先后关系必然颠倒,所以两个不等式不可能同时成立。

说明:上面的推理是直观论证。本项目另外用独立脚本对 500 棵随机树(链、菊花、完全二叉树、随机树,n⩽13n\leqslant 13)做了逐点对穷举验证:祖先判定性质成立,并且 rdfnu\mathrm{rdfn}_u 确实等于翻转儿子顺序的第二遍 DFS 的先序号。但这不是形式化证明。

注意 rdfn\mathrm{rdfn} 不是子树区间右端 out\mathrm{out} 的翻转:把上面的样例树代进去就能看出,节点 22 的 rdfn=3\mathrm{rdfn}=3,而 n−out2+1=9−9+1=1n-\mathrm{out}_2+1=9-9+1=1。可以记成 rdfnu=n−退出计数u+1\mathrm{rdfn}_u=n-\text{退出计数}_u+1(退出计数是后序编号),但这个式子的唯一用处是说明「一遍 DFS 也能算出来」,定义本身请按「翻转儿子顺序的第二遍 DFS 的先序号」理解。

rdfn\mathrm{rdfn} 不必真的再 DFS 一遍:翻转儿子列表的 DFS 的进入序,正好等于原 DFS 退出序的逆序。所以在一次 DFS 里,进入时给 dfn\mathrm{dfn} 递增编号,退出时给 rdfn\mathrm{rdfn} 从 nn 递减编号,就能同时得到两个序。

有了这条性质,把黑点 uu 写成平面上的点 (dfnu,rdfnu)(\mathrm{dfn}_u,\mathrm{rdfn}_u),「uu 是 vv 的祖先且 uu 是黑点」就变成「点落在 vv 的左下角矩形 [1,dfnv]×[1,rdfnv][1,\mathrm{dfn}_v]\times[1,\mathrm{rdfn}_v] 里」(后面的平面图画的就是这个矩形的形状)。而路径上最浅的黑点,正是 dfn\mathrm{dfn} 最小的那个 —— 即这个矩形里横坐标最小的点。

思路

整条思路分三步走:先把两遍 DFS 的编号画回树上和平面,看清「祖先」在坐标系里到底是什么形状;再把二维的矩形条件降成一维线段上的「第一个满足值」;最后把这件事交给线段树二分。

第一步:把编号画回树上和平面,看清「祖先」的形状

以样例树为例,两遍 DFS 得到的两个序如下(第一遍 DFS 的儿子顺序按输入顺序):

节点 1 2 3 4 5 6 7 8 9
dfn\mathrm{dfn} 1 2 9 3 5 8 6 7 4
rdfn\mathrm{rdfn} 1 3 2 9 8 6 7 5 4

这两行就是 main.cpp 跑样例时的实际编号:dfn\mathrm{dfn} 是按输入顺序的先序(1,2,4,9,5,7,8,6,31,2,4,9,5,7,8,6,3),rdfn\mathrm{rdfn} 是翻转儿子顺序后的先序(1,3,2,9,8,6,7,5,41,3,2,9,8,6,7,5,4)。

用这张表可以直接验证祖先判定:例如 22 是 99 的祖先,(dfn,rdfn)=(2,3)⩽(4,4)(\mathrm{dfn},\mathrm{rdfn})=(2,3)\leqslant(4,4) 成立;而 33 与 99 无祖先关系,(9,2)(9,2) 与 (4,4)(4,4) 的第一维、第二维大小关系相反,两个不等式不同时成立。

光看数字不容易建立直觉,下图把这两行编号画回树上:

样例树上每个节点的 dfn 与 rdfn

读这张图只需看两件事。沿红色的祖先链 1→2→9→8→61\to2\to9\to8\to6,两个编号都随深度单调变大(dfn\mathrm{dfn} 是 1,2,4,7,81,2,4,7,8,rdfn\mathrm{rdfn} 是 1,3,4,5,61,3,4,5,6),所以「祖先」这一侧永远满足两条不等式;而旁支 55 与 66 虽然 dfn5=5<dfn6=8\mathrm{dfn}_5=5<\mathrm{dfn}_6=8,却有 rdfn5=8>rdfn6=6\mathrm{rdfn}_5=8>\mathrm{rdfn}_6=6,一维大一维小,两条不等式不可能同时成立。图底部的两行访问序列说明 rdfn\mathrm{rdfn} 是怎么来的:第 2 遍只是把每个节点的儿子顺序整段倒过来,于是子树 22 内 4,94,9 互换、99 的三个儿子 5,7,85,7,8 倒序,子树 22 内的顺序从 2,4,9,5,7,8,62,4,9,5,7,8,6 变成 2,9,8,6,7,5,42,9,8,6,7,5,4。

把这套编号放到平面上,就得到解法一真正依赖的图形:

把祖先判定看成左下角矩形

每个节点是平面上的一个点 (dfnu,rdfnu)(\mathrm{dfn}_u,\mathrm{rdfn}_u),而 v=6v=6 的祖先恰好是左下角虚线框 [1,dfn6]×[1,rdfn6]=[1,8]×[1,6][1,\mathrm{dfn}_6]\times[1,\mathrm{rdfn}_6]=[1,8]\times[1,6] 里的全部点 —— 也就是 1,2,9,8,61,2,9,8,6(含边界)。框外的点一定不是祖先:55 虽然是黑点,但 rdfn5=8>6\mathrm{rdfn}_5=8>6,被挡在框的上方。于是「查最浅黑点」就变成了「在这块框里找横坐标最小的黑点」,问题从树上搬到了平面上,只剩一个二维的框。

第二步:把点投影到 dfn 轴上,二维矩形就塌成一根线段

第一步结束时,问题是在框 [1,dfnv]×[1,rdfnv][1,\mathrm{dfn}_v]\times[1,\mathrm{rdfn}_v] 里找横坐标最小的黑点。可线段树是一维的:只有一根下标轴,每个下标上再存一个值。所以先要回答一个问题 —— 平面上的点,怎么塞进一根轴里?

答案是竖直投影:把每个点压到 dfn\mathrm{dfn} 轴上。

把点投影到 dfn 轴上:平面 → 数组 → 线段树二分

投影只做两件事:

  • 落点位置 =dfnu=\mathrm{dfn}_u:点 (dfnu,rdfnu)(\mathrm{dfn}_u,\mathrm{rdfn}_u) 落到第 dfnu\mathrm{dfn}_u 格;
  • 存下来的值 =rdfnu=\mathrm{rdfn}_u:竖着压会丢掉高度,于是把丢掉的那一维当作这一格存的内容 —— 黑点存它的 rdfn\mathrm{rdfn},白点存 +∞+\infty(表示「这格没有黑点」)。

投影不丢东西,因为 dfn\mathrm{dfn} 是 1..n1..n 的一个排列:每个点都有自己的格子,两个点不会压到同一格。于是「平面上的点集」和「轴上的一排数」是同一份数据的两种画法,上图 9 个点正好落满 9 格。

更关键的是投影保序:横坐标小的点,落下来格子也更靠左。原来的问题问「框里横坐标最小的黑点」,投影之后就成了「最靠左的格子,它的值不超过阈值」。要不要算进答案,只看两件事:

  • 格子够不够靠左:下标 ⩽dfnv\leqslant\mathrm{dfn}_v —— 原矩形横方向的那条边;
  • 存的值够不够小:值 ⩽rdfnv\leqslant\mathrm{rdfn}_v —— 原矩形纵方向的那条边。

两维就这样各归各位:一维当下标(决定位置和先后),一维当值(只拿去和阈值比较)。矩形塌成一维之后,线段树要回答的就是一句话:

text
在下标范围 [1, dfn_v] 里,找第一个值 <= rdfn_v 的格子

这一步为什么能塌,关键在两维落在不同的东西上:一维是格子怎么排,一维是格子存什么。如果两维都得挤在下标上(比如既要 dfn\mathrm{dfn} 落在某段区间、又要 dfn\mathrm{dfn} 满足别的关系),单值投影就无能为力了。

投影之后还剩两个细节要处理。

细节一:把“区间 [1,dfnv][1,\mathrm{dfn}_v] 内第一个”换成“全局第一个 + 事后校验”。

线段树从根下降时如果每层都先判断“这个结点与 [1,dfnv][1,\mathrm{dfn}_v] 相不相交”,代码会多一层递归参数。这里用一个更省事的写法: 干脆在整个 [1,n][1,n] 上找第一个值 ⩽rdfnv\leqslant\mathrm{rdfn}_v 的下标,因为是从最左往右找,拿到的 ii 天然是全局最小的。然后单独检查一句:

text
若 i 不存在,或 i > dfn_v   ->  区间内没有答案,输出 -1

i>dfnvi>\mathrm{dfn}_v 就说明全局第一个满足阈值的点落在 vv 的右边,它只是别处的黑点,不是 vv 的祖先。这一句就是位置边界的全部作用。

细节二:“整段能不能一次排除”这个判据从哪来。

这是投影之后真正让复杂度下降的地方。结点存子树内叶子的 min⁡\min,那么:

  • 若 min⁡>rdfnv\min>\mathrm{rdfn}_v,说明这一段里每个点的 rdfn\mathrm{rdfn} 都超过阈值,整段无解,直接返回,不用往下走;
  • 否则这一段里至少有一个可行点,进入左子树继续找最靠左的。

一个结点被剪掉只花 O(1)O(1),不剪的才会把两个孩子带进下一层,所以一次查询只沿 O(log⁡n)O(\log n) 个结点走。上一张图右下角的红色虚线框就是这个判据在工作:[1,5] 这段的 min⁡=8>6\min=8>6,5 个格子一次丢掉。

这里和前置题 luogu-U502676 线段树上二分模版 是同一个骨架。那题查「区间内第一个 >k>k 的数」,结点存 max⁡\max,剪枝条件是「max⁡⩽k\max\leqslant k 就整段无解」;本题查「区间内第一个 ⩽Y\leqslant Y 的值」,结点存 min⁡\min,剪枝条件是「min⁡>Y\min>Y 就整段无解」。方向相反,下降方式完全一样。

第三步:落到代码

把线段树的叶子下标设成 dfn\mathrm{dfn}:

  • 叶子 dfn=i\mathrm{dfn}=i:若该节点是黑点,存它的 rdfn\mathrm{rdfn};若是白点,存 +∞+\infty;
  • 内部节点存子树内所有叶子值的最小值。

于是「[1,dfnv][1,\mathrm{dfn}_v] 范围内有没有 vv 的祖先变黑」就变成「下标区间 [1,dfnv][1,\mathrm{dfn}_v] 内有没有值 ⩽rdfnv\leqslant \mathrm{rdfn}_v」。线段树本身有序,可以直接做线段树二分:从根往下走,只要左子树的最小值 ⩽rdfnv\leqslant \mathrm{rdfn}_v 就进左子树,否则进右子树,O(log⁡n)O(\log n) 走到第一个满足条件的位置 ii。

  • 若不存在这样的位置,或者找到的位置 i>dfnvi>\mathrm{dfn}_v,说明路径上没有黑点,输出 −1-1;
  • 否则 ii 就是最浅黑点的 dfn\mathrm{dfn},用 node_at[i] 反查编号输出。

注意必须额外判断 i⩽dfnvi\leqslant\mathrm{dfn}_v:线段树二分是在整棵树范围内找最小下标,找到的点可能落在 vv 的右侧(此时它只是某个不相关子树里的黑点)。

按样例的操作顺序模拟线段树二分:

操作 黑点叶子(dfn→rdfn\mathrm{dfn}\to\mathrm{rdfn}) 二分条件 找到的最小下标 输出
1 3 无 rdfn3=2\mathrm{rdfn}_3=2,全为 ∞\infty 无 -1
0 8 dfn=7→rdfn=5\mathrm{dfn}=7\to\mathrm{rdfn}=5 — — —
1 6 同上 rdfn6=6\mathrm{rdfn}_6=6,5⩽65\leqslant 6 成立 7(⩽dfn6=8\leqslant\mathrm{dfn}_6=8) 8
1 7 同上 rdfn7=7\mathrm{rdfn}_7=7,5⩽75\leqslant 7 成立 7,但 7>dfn7=67>\mathrm{dfn}_7=6 -1
0 2 dfn=2→3\mathrm{dfn}=2\to3,dfn=7→5\mathrm{dfn}=7\to5 — — —
1 9 同上 rdfn9=4\mathrm{rdfn}_9=4,3⩽43\leqslant 4 成立 2(⩽dfn9=4\leqslant\mathrm{dfn}_9=4) 2
0 2 只剩 dfn=7→5\mathrm{dfn}=7\to5 — — —
1 9 同上 rdfn9=4\mathrm{rdfn}_9=4,5⩽45\leqslant 4 不成立 无 -1

对比 1 6 与 1 7 两行:88 一直是黑点,但查 1 7 时 dfn8=7>dfn7=6\mathrm{dfn}_8=7>\mathrm{dfn}_7=6,88 落在 77 的右边,靠最后那句 i⩽dfnvi\leqslant\mathrm{dfn}_v 才被挡掉。而 rdfn\mathrm{rdfn} 那条不等式管的是另一类漏网者:把 55 也染黑后查 1 6(就是上面平面图的用例),55 的 dfn=5\mathrm{dfn}=5 比 dfn6=8\mathrm{dfn}_6=8 小,位置校验会让它通过,但 rdfn5=8>rdfn6=6\mathrm{rdfn}_5=8>\mathrm{rdfn}_6=6 说明它在框外,于是被「叶子值 ⩽rdfnv\leqslant \mathrm{rdfn}_v」这个条件排除,答案仍然是 88。两个条件各挡一类,缺一个都会答错。

代码

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-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 实现(线段树改成迭代写法,避免递归开销):

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 求两个序,O(n)O(n)。
  • 单次取反:线段树单点更新 O(log⁡n)O(\log n)。
  • 单次查询:一次线段树二分,O(log⁡n)O(\log n)。
  • 总时间 O((n+m)log⁡n)O((n+m)\log n),空间 O(n)O(n)。

解法二:重链剖分 + 每条重链一个黑点集合

关键观察

重链剖分把树拆成若干条从链头往下的重链,并按「先重儿子、后轻儿子」的顺序重新编号。这个编号顺序保证了两件事:

  1. 同一条重链上的 dfn\mathrm{dfn} 连续且随深度递增。所以「链上最浅的黑点」就是「链内 dfn\mathrm{dfn} 最小的黑点」;
  2. 根到 vv 的路径可以拆成 O(log⁡n)O(\log n) 段重链区间,并且从 vv 沿链头向上跳时,越往后跳到的链整体越浅。

样例树剖分后的结果(「重儿子」一行中的「—」表示该节点是叶子,没有重儿子):

节点 1 2 3 4 5 6 7 8 9
重儿子 2 9 — — — — — 6 8
链头 top\mathrm{top} 1 1 3 4 5 1 7 1 1
dfn\mathrm{dfn} 1 2 9 8 6 5 7 4 3

重链 1→2→9→8→61\to2\to9\to8\to6 拿到了连续的 dfn=1..5\mathrm{dfn}=1..5,另外四条链(链头分别为 3、4、5、7)各只有一个节点。

思路

给每条重链开一个有序集合 chain_black[链头],存这条链上所有黑点的 dfn\mathrm{dfn}。因为链内 dfn\mathrm{dfn} 随深度递增,集合的最小元素就是这条链上最浅的黑点。

  • 取反:翻转颜色后,把 dfnx\mathrm{dfn}_x 插入或从 chain_black[top[x]] 删除,O(log⁡n)O(\log n)。

  • 查询:从 u=vu=v 开始,每次看 uu 所在链的集合:

    • 若集合非空且最小元素 ⩽dfnu\leqslant\mathrm{dfn}_u,说明这条链上有 uu 的祖先变黑,候选答案是它;
    • 否则这条链上没有任何 uu 的祖先变黑,整条链跳过。

    然后 u←parent[top[u]]u\leftarrow \mathrm{parent}[\mathrm{top}[u]] 跳到上一条链。因为越跳越浅,最后记录的候选答案就是全局最浅的黑点。

注意「最小元素 ⩽dfnu\leqslant\mathrm{dfn}_u」这个判断不能省:集合里的最小黑点可能位于 uu 的下方(同一条链但更深),它不是 uu 的祖先。

下图把样例树和 dfn\mathrm{dfn} 数组画在一起:红色粗边是重边,串成链 1→2→9→8→61\to2\to9\to8\to6,它们占住 dfn=1..5\mathrm{dfn}=1..5 这段连续下标;其余四个轻儿子各开一条只有自己的链。节点 88 已被染黑(执行过 0 8),它在数组里对应下标 44。

样例树的重链剖分与 dfn 数组

看这张图就能直接读出两件事:

  • 查询 1 6 时,路径整段落在链 1 的区间 [1,5][1,5],段内最小黑点下标是 44,反查得节点 88。
  • 查询 1 7 时,链 7 为空;链 1 的区间是 [1,3][1,3],而黑点下标 44 已经越出右端点,说明这个黑点比 77 更深,不是祖先,所以输出 −1-1。

按样例模拟:

操作 各链黑点集合(dfn\mathrm{dfn}) 查询过程 输出
1 3 全空 链 3:空;链 1 区间 [1,1][1,1]:空 -1
0 8 链 1:{4}\{4\} — —
1 6 链 1:{4}\{4\} 链 1 区间 [1,5][1,5],最小 4⩽54\leqslant 5,对应节点 8 8
1 7 链 1:{4}\{4\} 链 7:空;链 1 区间 [1,3][1,3],最小 4>34>3 不成立 -1
0 2 链 1:{2,4}\{2,4\} — —
1 9 链 1:{2,4}\{2,4\} 链 1 区间 [1,3][1,3],最小 2⩽32\leqslant 3,对应节点 2 2
0 2 链 1:{4}\{4\} — —
1 9 链 1:{4}\{4\} 链 1 区间 [1,3][1,3],最小 4>34>3 不成立 -1

1 7 一行最能说明问题:黑点 88 与 77 同在链 1,但 dfn8=4>dfn7=3\mathrm{dfn}_8=4>\mathrm{dfn}_7=3,88 比 77 深,不是祖先,所以答案仍是 −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-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,O(n)O(n)。
  • 单次取反:set 插入或删除,O(log⁡n)O(\log n)。
  • 单次查询:跳 O(log⁡n)O(\log n) 条链,每条链一次 set 取值(O(1)O(1) 取最小元素),O(log⁡n)O(\log n)。
  • 总时间 O(n+mlog⁡n)O(n+m\log n),空间 O(n)O(n)。

同一思路的线段树变体

如果不想为每条链单独开 set,可以把「链内最小 dfn\mathrm{dfn}」交给一棵覆盖全树的线段树:叶子 dfn=i\mathrm{dfn}=i 存「黑点 ? ii : +∞+\infty」,内部节点存最小值。查询时把根到 vv 的路径拆成 O(log⁡n)O(\log n) 段重链区间,从根侧往 vv 侧逐段查询区间最小值,第一个非 +∞+\infty 的结果就是答案。

这份实现沿用 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。

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-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 版把不同链的数据物理分开,取最小元素是 O(1)O(1);线段树版把所有链放在同一个数组里,每次取值要多付一个 O(log⁡n)O(\log n) 的区间查询。因此 set 版常数更小、代码更短,本题目解以它作为解法二的正式实现;线段树版则更容易与其它树链剖分题共用模板。

复杂度对比

解法 单次取反 单次查询 总时间 额外技巧 实测(n=m=105n=m=10^5 随机树,三次取中位)
双 dfs 序 + 线段树二分 O(log⁡n)O(\log n) O(log⁡n)O(\log n) O((n+m)log⁡n)O((n+m)\log n) 需要翻转儿子列表的第二遍 DFS 约 52 ms
重链剖分 + 每链 set O(log⁡n)O(\log n) O(log⁡n)O(\log n) O(n+mlog⁡n)O(n+m\log n) 只需标准剖分 约 63 ms
重链剖分 + 线段树 O(log⁡n)O(\log n) O(log⁡2n)O(\log^2 n) O((n+m)log⁡2n)O((n+m)\log^2 n) 只需标准剖分 约 69 ms

三者的实测时间都很小,本题的数据量对任何 O(log⁡2n)O(\log^2 n) 级别做法都足够宽松。选择建议:想要最短代码与最优复杂度就用解法一;正在复习树链剖分、希望把「路径拆段」这一套练熟就用解法二。

考场策略:第一次在赛场上见到这类题,优先走解法二。重链剖分是背得下来的模板,从「路径拆成 O(log⁡n)O(\log n) 段区间」到「每段取最浅黑点」只差一个变形,熟练的话二十分钟能写完并用样例自查;解法一的门槛不在代码(二十行),而在那一步「再造一个与 dfn\mathrm{dfn} 同向的全序」的灵感 —— 只掌握 dfnu⩽dfnv⩽outu\mathrm{dfn}_u\leqslant\mathrm{dfn}_v\leqslant\mathrm{out}_u 的选手会自然走向区间包含,凑不出二维支配关系,考场基本不会自发想到,而且想歪了没有任何部分分兜底。所以合理顺序是:先用解法二拿分,赛后回来把解法一当技巧升级。解法一真正可迁移的资产不是这道题本身,而是「把偏序嵌成二维支配 → 投影降维 → 线段树二分」这条链,再遇到「uu 是 vv 的祖先」这类条件时会反复用到。

总结

  • 本题的难点不在数据结构,而在把「是祖先」翻译成可维护的条件。解法一用两个时间戳把祖先关系变成二维偏序,再用线段树二分取最小下标;解法二用剖分把路径拆成有序区间,再在每条链内取最小 dfn\mathrm{dfn}。
  • 两种解法都依赖同一个事实:在「同一段连续编号」内,dfn\mathrm{dfn} 的大小顺序与深度顺序一致。解法一里这段编号是整个 DFS 序(但要用 rdfn\mathrm{rdfn} 排除非祖先),解法二里这段编号是一条重链(天然只含祖先链上的一段)。
  • 「查询路径上第一个 / 最浅的某种点」是树剖的典型应用场景;而「两个 DFS 序做二维偏序」是一个适用范围更广的技巧,遇到「uu 是 vv 的祖先」这类条件时值得优先考虑。

图示解析

这张图对比两条正解路线:

text
                        问题:根到 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)

图中上半部分展示两条路线的分岔点:解法一把「祖先」变成两个不等式的合取,解法二把「路径」变成有序区间序列。下半部分标出两条路线共同的坑 —— 找到的「最浅黑点」可能并不在 vv 的祖先链上,必须额外做一次位置校验,这是本题最容易写错的地方。

参考与致谢

本题解法评估阶段阅读了洛谷题解区的公开题解,下表区分「哪些思路来自原题解」与「哪些是本篇自行补的论证、代码与验证」。

来源与作者 题解标题 / 链接 本篇采纳的内容 本篇独立完成的内容
CYFqwq(uid 1512824) 题解:P4116 Qtree3 解法一的核心技巧:翻转儿子列表的第二遍 DFS 得到 rdfn\mathrm{rdfn},把「uu 是 vv 的祖先」转化为 dfnu⩽dfnv∧rdfnu⩽rdfnv\mathrm{dfn}_u\leqslant\mathrm{dfn}_v\land\mathrm{rdfn}_u\leqslant\mathrm{rdfn}_v,再用线段树二分找最小下标 重新推导并写出完整证明思路;补出「rdfn\mathrm{rdfn} 等于翻转儿子顺序的第二遍 DFS 的先序号、也就是原 DFS 退出序的逆序」,从而一次 DFS 同时求两序;用独立脚本对 500 棵随机树逐点对穷举验证该性质;重写为符合本仓库风格的 C++17 与 Python 实现,并补「找到的最小下标可能 >dfnv>\mathrm{dfn}_v」这一必需校验的说明
Zcus(uid 33339) 题解 P4116 【Qtree3】 解法二的核心观察:每条重链维护一个黑点集合,集合按深度取最小,查询时沿链头向上跳并检查该黑点是否比 uu 浅 用 std::set 存 dfn\mathrm{dfn} 代替自定义比较器的 set<节点>;补写「最小元素可能比 uu 深、必须比较 dfn\mathrm{dfn}」的证明;重写代码并独立对拍
81179332_(uid 53994) 题解 P4116 【Qtree3】 与上篇相同的「每链一个集合」路线,以及「不必维护 dfn\mathrm{dfn} 也能判断链上最浅黑点」的提醒 同上;另说明为何本仓库实现仍使用 dfn\mathrm{dfn} 作为集合键(与剖分编号天然同序,省掉自定义比较器)
Treaker(uid 153898)、yu__xuan(uid 142110)、wxgwxg(uid 36103) 7bkhqrs3、tkmwuwe9、22gbxp0c 「黑点存 dfn\mathrm{dfn}、白点存 +∞+\infty、查询区间最小值」这一维护方式 作为解法二的线段树变体保留,用于对比 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 组小数据,n⩽30n\leqslant 30,覆盖随机树 / 链 / 菊花 / 完全二叉树)。其中 10 篇结果与暴力完全一致;3xzpawuw(分块)在「0 2 两次后再查 1 3」这类「同一节点被反复取反」的用例上出错,fio0aqka(带修树上莫队)在链形小数据上答案与暴力不符。这两篇的路线实现复杂度也明显高于本篇采用的两个解法,因此未纳入正文。