倒序时光倒流将标记撤销转化为并查集向上合并,实现近线性 O((n+q)α(n)) 查询最近已标记祖先;同时提供在线重链剖分与线段树解法对比。
OJ: luogu
题目 ID: P4092
难度:提高+/省选-
标签:并查集时光倒流离线重链剖分线段树
创建: 2026-07-17 02:00
更新: 2026-10-27 11:58
形式化题目
给定一棵以
- 标记操作
C u:将节点加入集合 (可重复标记,多次标记效果等同于已存在); - 询问操作
Q u:查询从节点到根的路径上,距离 最近(即深度最大)的已标记节点( 本身若已标记则为其自身)。
解法总览
本题的核心矛盾在于:正向处理时,每次标记会在原有的树连通结构中增加分割点,将连通块进一步打散;而在询问时,需要沿着祖先链向上寻找第一个进入的标记。
根据是否强制在线,本题有两种经典的处理路线:
- 离线时光倒流 + 并查集(推荐主解,代码极简):题目没有强制在线。如果正难则反,记录所有标记后将时间倒流,原问题的「增加标记」就变成了「删除标记」。在并查集中,连通块的合并比分裂容易得多:每个未标记节点直接指向父亲,集合代表元恰好是当前块顶端有标记的祖先;撤销标记时,代表元向其父亲连边合并。单次操作均摊
,代码仅约 50 行。 - 在线重链剖分 + 线段树(经典在线解法):若题目要求强制在线,则用重链剖分将树上到根路径拆分为
段重链区间,利用线段树维护区间内已标记的最大 dfn 序,沿重链跳跃查询。单次操作复杂度为 。
下面给出并列解法总览表:
| 解法 | 核心观察 | 复杂度 | 适用场景 | 对应代码 |
|---|---|---|---|---|
| 解法一(推荐主解):离线时光倒流 + 并查集 | 正难则反,删标记等价于连通块向上合并 | 时间 |
题目允许离线;追求实现极简与极致常数 | @include-code(./main.cpp, cpp) |
| 解法二:重链剖分 + 线段树 | 祖先路径拆为 |
时间 |
强制在线或动态加边等扩展场景 | @include-code(./hld.cpp, cpp) |
暴力解法
思路
每次询问 Q u 时,从节点 parent[u] 逐点向上移动,直到遇到第一个被标记的节点即为答案。
代码
/**
* 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:小数据暴力解,每次查询从节点沿父亲链向上爬,找最近的已标记祖先。
// 复杂度 O(n) 每次操作,只适合小数据,用来帮助理解题意并辅助对拍。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, q;
int parent[MAXN]; // parent[u] 记录 u 的父亲(输入是 u -> v 的有向边)
bool marked[MAXN]; // marked[u] 记录 u 是否已被标记
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> q;
// 读入 n-1 条有向边 u -> v,表示 u 是 v 的父亲。
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
parent[v] = u;
}
marked[1] = true; // 初始只有根 1 有标记
while (q--) {
char opt;
int u;
cin >> opt >> u;
if (opt == 'C') {
marked[u] = true; // 标记操作:直接置 true(重复标记没有额外效果)
} else {
// 询问:从 u 沿父亲链向上爬,第一个被标记的就是最近的标记祖先。
int cur = u;
while (!marked[cur])
cur = parent[cur];
cout << cur << '\n';
}
}
return 0;
}复杂度与瓶颈
- 时间复杂度:标记单次
,询问单次最坏需要向上遍历整条链,最坏时间复杂度为 ;总时间复杂度为 。 - 空间复杂度:
。 - 瓶颈:当树退化为一条长链且多次在链底发起询问时,逐点上跳产生大量重复扫描。
解法一(推荐主解):离线时光倒流 + 并查集
思路
正向过程中,不断加入标记会将一棵树割裂成越来越多的「连通块」(以每个标记节点为连通块的顶部代表元)。在数据结构中,分裂通常难以在近线性时间内高效维护,而合并却非常适合并查集。
既然本题全部操作在输入中已经给出,我们采用**时光倒流(逆序处理)**的思想。下面按三块分别讲清,每一块都配一张图。
第一块:离线存操作
正序读入时只存不答:把每个操作原样压进 Operation ops[] 数组,结构体只有三个字段:类型 type、节点号 u、以及留给查询答案的格子 ans。
其中下标 ans。顺带在读入时做一件事——只有 C 会让标记数变化,所以边读边累加 mark_cnt[u],读完就得到了「全部操作做完后每个点被标记了几次」。
图右下角逐个验证了 find 的结果:find(4) 走 find(3) 走 find(2) 因为自环直接返回 2(这正好对应题面「节点本身也算自己的祖先」)。指针只朝父亲方向指,所以沿指针只会上行,第一个走不动的点就是最近的标记。
第二块:倒序回溯
倒着走一遍操作序列,两种操作的处理方式完全不同:
- 遇到
Q u:此刻树上的标记状态,恰好就是正序执行到这一步时的状态,直接取代表元find(u)记进ops[i].ans。 - 遇到
C u:时光倒流意味着这次标记要撤销,所以先mark_cnt[u]--。只有当它减到时才说明「在更早的时刻 其实没有标记」,这时以 为根的那一整块才需要向上并进父亲所在的块;否则只减计数,指针一动不动。
第三条是理解这套做法的关键,它解释的是为什么可以只改一个指针、而不用写 merge:撤销 C u 的那一刻,u 挂到 parent[u] 上,效果完全等同于「两块合并」。
最后那个宽面板值得多看一眼:样例里 Q 2 出现了两次(下标 C 2、标记被撤销之后,再遇到的(
第三块:复杂度
- 时间复杂度:
- 构建树结构与统计总操作:
; - 倒序处理中,共计有
次查询和最多 次并查集合并,带路径压缩的并查集单次均摊为 ; - 总时间复杂度为
,在百万级数据范围内运行只需几十毫秒。
- 构建树结构与统计总操作:
- 空间复杂度:并查集数组、操作记录均为一维数组,总空间复杂度为
。
上面两张图都是定格。想把整个过程按代码实际执行的顺序走一遍,可以打开 交互动画:存数组 → 倒序扫 → 并查集查询与合并。
页面只有一条时间轴,顶部的箭头就是执行位置,指着哪一格就处理哪一格:
- 存操作:箭头向右,把每条操作依次填进
ops[](空格子先留虚线,ans留问号),同时只有C会让mark_cnt增长。 - 建森林:一个点一个点地把
dsu_fa指出来 —— 有标记指向自己(画成「自」),没标记指向父亲。 - 倒序扫:箭头转为向左,每退一格处理一条。遇
Q就在树上画出沿指针上行的路径(带序号),命中的祖先填绿并标「答案 k」,同时把答案写回那一格;遇C就撤销标记,若mark_cnt归零则用橙色粗箭头把整块挂到父亲上,虚线框标出被合并的整块。 - 输出:所有
ans填好后正序扫一遍输出。
每一步都能单步、回退、自动播放;询问步与合并步会先让你猜一下再揭示。页面按题面输入格式读数据,四个预设是题面样例、同一点标两次、深链、多分支,也可以自己粘一组进去,mark_cnt / dsu_fa 两张表随步骤同步高亮。
代码
/**
* 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 19:57
* update_at: 2026-10-23 19:57
*/
// main.cpp:离线时光倒流 + 并查集维护树上连通块与最近标记祖先。
// 复杂度 O((n + q) * alpha(n)),空间 O(n)。
#include <iostream>
#include <vector>
using namespace std;
typedef long long ll;
const int MAXN = 100005;
// parent_node[u] 记录树上节点 u 的父亲节点
int parent_node[MAXN];
// dsu_fa[u] 并查集父节点指针,代表当前连通块中最靠上的已标记节点
int dsu_fa[MAXN];
// mark_cnt[u] 统计节点 u 被标记的总次数
int mark_cnt[MAXN];
// 并查集带路径压缩查询代表元
int find_root(int x) {
if (dsu_fa[x] == x)
return x;
return dsu_fa[x] = find_root(dsu_fa[x]);
}
// 记录操作以便离线处理
struct Operation {
char type; // 'C' 或 'Q'
int u; // 操作节点编号
int ans; // 询问操作的答案
};
Operation ops[MAXN];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, q;
std::cin >> n >> q;
// 读入 n - 1 条有向边 u -> v,表示 u 是 v 的父亲
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
parent_node[v] = u;
}
// 节点 1 初始已标记,计数值置为 1(且永不降为 0)
mark_cnt[1] = 1;
// 离线存询问
for (int i = 0; i < q; i++) {
cin >> ops[i].type >> ops[i].u;
if (ops[i].type == 'C') {
mark_cnt[ops[i].u]++;
}
}
// 初始化操作结束时的并查集森林:
// 若点 i 最终被标记(mark_cnt[i] > 0),它自身就是集合的代表元;
// 若点 i 未被标记,直接将其父指针指向 parent_node[i]。
for (int i = 1; i <= n; i++) {
if (mark_cnt[i] > 0) {
dsu_fa[i] = i;
} else {
dsu_fa[i] = parent_node[i];
}
}
// 倒序时光倒流:加标记变成删标记
for (int i = q - 1; i >= 0; i--) {
if (ops[i].type == 'Q') {
ops[i].ans = find_root(ops[i].u);
} else {
mark_cnt[ops[i].u]--;
// 当标记次数归零时,说明在当前操作发生之前,点 ops[i].u 是没有标记的,
// 它所属集合的代表元 ops[i].u 向父亲所在的集合合并。
if (mark_cnt[ops[i].u] == 0) {
dsu_fa[ops[i].u] = parent_node[ops[i].u];
}
}
}
// 按顺序输出所有询问的答案
for (int i = 0; i < q; i++) {
if (ops[i].type == 'Q') {
cout << ops[i].ans << "\n";
}
}
return 0;
}解法二:重链剖分 + 线段树
思路
若题目要求在线处理,我们可以将树剖分并将路径查询转化为区间极值:
- 重链连续性:重链剖分将树上任意节点到根的路径划分为不超过
段连续的重链区间,重链上深度随剖分后的 dfn 序严格单调递增。 - 最近与最大 dfn 的等价性:在同一条重链上,离查询点
最近的已标记祖先,正是该段重链区间 内所有打了标记的节点中 dfn 最大的节点。 - 线段树维护:线段树维护单点标记的 dfn,区间查询最大值。询问时沿重链跳跃:若当前重链区间内存在标记,则取区间内最大 dfn 还原为节点编号即为答案;若无标记,则跨过轻边跳至上一条重链继续查询,直到命中。
代码
/**
* 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
*/
// main.cpp:重链剖分 + 线段树维护区间最大 dfn,查询节点到根路径上最近的已标记祖先。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, q;
vector<int> children[MAXN]; // children[u] 记录 u 的儿子列表(输入是 u -> v 的有向边)
int parent[MAXN]; // parent[u] 记录 u 的父亲
int depth[MAXN]; // depth[u] 记录 u 的深度
int subtree_size[MAXN]; // subtree_size[u] 记录 u 的子树大小
int heavy_son[MAXN]; // heavy_son[u] 记录 u 的重儿子(子树最大的儿子)
int top[MAXN]; // top[u] 记录 u 所在重链的链头
int dfn[MAXN]; // dfn[u] 记录 u 剖分后的新编号
int node_at[MAXN]; // node_at[dfn] 是编号 dfn 对应的原节点
// ---------- 线段树:维护区间内最大的已标记 dfn ----------
int seg_tree[MAXN * 4]; // seg_tree[p] 保存节点 p 区间内最大的已标记 dfn,0 表示该区间没有标记
// 给位置 pos 打标记(标记幂等:重复写同一个 dfn 结果不变)。
void point_mark(int p, int l, int r, int pos) {
if (l == r) {
seg_tree[p] = pos;
return;
}
int mid = (l + r) >> 1;
if (pos <= mid)
point_mark(p << 1, l, mid, pos);
else
point_mark(p << 1 | 1, mid + 1, r, pos);
seg_tree[p] = max(seg_tree[p << 1], seg_tree[p << 1 | 1]);
}
// 查询区间 [ql, qr] 内最大的已标记 dfn,0 表示区间内没有标记。
int range_max(int p, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr)
return seg_tree[p];
int mid = (l + r) >> 1;
int res = 0;
if (ql <= mid)
res = max(res, range_max(p << 1, l, mid, ql, qr));
if (qr > mid)
res = max(res, range_max(p << 1 | 1, mid + 1, r, ql, qr));
return res;
}
// 询问节点 u 到根路径上最近的已标记祖先(u 本身也算祖先)。
int query_marked_ancestor(int u) {
while (top[u] != 1) {
// 先看当前链 [top[u], u] 上有没有标记,有则取 dfn 最大的(就是最近的)。
int got = range_max(1, 1, n, dfn[top[u]], dfn[u]);
if (got != 0)
return node_at[got];
// 当前链没有标记,跨过一条轻边,去上一段重链继续找。
u = parent[top[u]];
}
// u 与根 1 同链:区间 [dfn[1], dfn[u]] 上一定有标记(根 1 永远有标记)。
return node_at[range_max(1, 1, n, dfn[1], dfn[u])];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> q;
// 读入 n-1 条有向边 u -> v,表示 u 是 v 的父亲。
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
children[u].push_back(v);
}
// 从根 1 出发 BFS:确定 parent、depth,并得到从根到叶的访问顺序 order。
int order[MAXN];
int order_len = 0;
int bfs_queue[MAXN];
int head = 0, tail = 0;
bfs_queue[tail++] = 1;
while (head < tail) {
int u = bfs_queue[head++];
order[order_len++] = u;
for (int i = 0; i < (int)children[u].size(); i++) {
int v = children[u][i];
parent[v] = u;
depth[v] = depth[u] + 1;
bfs_queue[tail++] = v;
}
}
// 逆序遍历 order:累计子树大小,选出每个点的重儿子。
for (int i = 1; i <= n; i++)
subtree_size[i] = 1;
for (int i = n - 1; i >= 1; i--) {
int u = order[i];
subtree_size[parent[u]] += subtree_size[u];
if (subtree_size[u] > subtree_size[heavy_son[parent[u]]])
heavy_son[parent[u]] = u;
}
// 重链剖分编号(栈模拟 DFS):重儿子优先连续编号,轻儿子开启新链。
int stk_u[MAXN], stk_t[MAXN]; // 栈:待处理的节点和它的链头
int stk_len = 0;
int timer = 0;
stk_u[++stk_len] = 1;
stk_t[stk_len] = 1;
while (stk_len) {
int u = stk_u[stk_len], t = stk_t[stk_len];
stk_len--;
// 沿重儿子一路走到链尾,整条重链的 dfn 连续。
while (u) {
top[u] = t;
dfn[u] = ++timer;
node_at[timer] = u;
// 轻儿子是新链的链头,先压栈,之后处理。
for (int i = 0; i < (int)children[u].size(); i++) {
int v = children[u][i];
if (v != heavy_son[u]) {
stk_u[++stk_len] = v;
stk_t[stk_len] = v;
}
}
u = heavy_son[u];
}
}
// 初始只有根 1 有标记。
point_mark(1, 1, n, dfn[1]);
while (q--) {
char opt;
int u;
cin >> opt >> u;
if (opt == 'C')
point_mark(1, 1, n, dfn[u]); // 标记:点更新
else
cout << query_marked_ancestor(u) << '\n'; // 询问:沿链查询
}
return 0;
}复杂度
- 时间复杂度:预处理
;单次标记为线段树点更新 ;单次询问最多跳 条链,每条链区间查询 ,单次询问 ;总时间复杂度为 。 - 空间复杂度:线段树及重链剖分相关数组占用
空间。
总结
- 思维模式提升:面对「加标记分割树结构」且不强制在线的题目,「正难则反 / 倒序时光倒流」能将棘手的「分裂问题」转化为经典优雅的「并查集连通块向上合并问题」,从而在代码量和运行效率上双双超越传统高级数据结构。
- 在线 vs 离线:离线并查集代码极短(50 行),时间近乎线性
,是竞赛中最优解题选择;重链剖分 + 线段树则是通用的在线树上路径维护工具,理解两种解法的对立统一有助于加深对树上动态问题的整体认知。
参考与致谢
在解法评估与优化过程中,研读并参考了洛谷公开题解中的精彩解法,特此致谢:
| 来源与作者 | 题解标题 / 链接 | 采纳与参考内容 | 本文独立完成内容 |
|---|---|---|---|
| Sleepp (uid: 77743) | 题解 P4092 【[HEOI2016/TJOI2016]树】 | 学习了将本题离线倒序、利用并查集维护连通块撤销标记的核心思维 | 严谨证明了代表元直接指向父亲进行路径压缩的拓扑正确性;重写了符合规范的现代 C++17 代码;补充了 500 组随机与极限用例独立对拍验证 |
| 现有本地题解作者 | 本地历史版本 | 保留了重链剖分 + 线段树的标准在线实现作为并列解法二 | 补充并列多解法对比表及解法分析,整理格式规范 |