P11127 题解
P11127 [ROIR 2024] 二叉树的遍历 (Day 2) 题解总结
一、题意简述
给定一棵 n 个节点的二叉树,节点编号 1 ~ n,根为 1。
每个节点有一个值 x ∈ {-1, 0, 1},决定遍历时输出该节点的时机:
- x = -1:在左右子树之前输出(前序)
- x = 0:在左子树之后、右子树之前输出(中序)
- x = 1:在左右子树之后输出(后序)
初始所有节点 x = -1。
需要支持 q 次操作:
- 将节点编号区间 [l, r] 的 x 改为指定值;
- 查询节点 i 在当前遍历中的位置。
数据范围:n, q ≤ 10^5。
二、核心思路
1. 位置分解公式
对于任意节点 u,它在遍历中的位置可以拆成三部分:
pos(u) = 1 + lef[u] + D(u) + self(u)
其中:
- lef[u]:静态贡献,只与树结构有关;
- D(u):动态贡献,由所有祖先节点的 x 值决定;
- self(u):自身贡献,由 u 自己的 x 值决定。
2. 静态贡献 lef[u]
在 DFS 过程中计算:
- 若 u 是父节点 p 的左孩子:
lef[u] = lef[p] - 若 u 是父节点 p 的右孩子:
lef[u] = lef[p] + sz[ls[p]]
含义:从根走到 u 的过程中,每次向右走,都要把左兄弟的整棵子树排在前面。
3. 动态贡献 D(u)
遍历所有祖先 v,累加贡献:
- 若 x_v = -1:只要 u 在 v 的子树中,贡献 +1;
- 若 x_v = 0 且 u 在 v 的右子树中,贡献 +1;
- 其他情况贡献 0。
用 DFS 序表示:
- x_v = -1:对区间 [dfn[v]+1, tout[v]] 加 1;
- x_v = 0:对区间 [dfn[rs[v]], tout[rs[v]]] 加 1。
注意:第一个区间从 dfn[v]+1 开始,是为了排除 v 自己。
4. 自身贡献 self(u)
- 若 x_u ≠ -1:加上左子树大小 sz[ls[u]];
- 若 x_u = 1:再加上右子树大小 sz[rs[u]]。
三、关键转化
转化 1:把“遍历位置”转化为“祖先贡献 + 自身贡献”
原本遍历顺序依赖整棵树,直接模拟不可行。
通过分析每个祖先对后代位置的影响,把全局顺序拆成独立的祖先贡献,使得动态修改 x 时只需要维护每个祖先对后代的区间贡献。
转化 2:把“祖先贡献”转化为“DFS 序上的区间加”
在 DFS 序中,一个节点的子树是一段连续区间。
因此:
- x_v = -1 对整棵子树贡献 +1;
- x_v = 0 只对右子树贡献 +1。
这样就把“树上的祖先关系”转化成了“DFS 序上的区间加、单点查询”。
转化 3:按节点编号分块
修改操作是对节点编号区间 [l, r] 赋值,而查询依赖 DFS 序。
两个维度不一致,直接用线段树很难同时维护。
于是按节点编号分块,每块大小 B ≈ √n。
每个块维护:
- tag[b]:块统一标记;
- val[u]:块不统一时每个节点的实际值。
这样修改时可以:
- 整块直接改 tag;
- 散块暴力下推后修改。
转化 4:预处理整块贡献数组 f, g
对于每个块 b,预处理:
- f[b][i]:假设块内所有节点 x = -1,对 DFS 序为 i 的节点的祖先贡献总和;
- g[b][i]:假设块内所有节点 x = 0,对 DFS 序为 i 的节点的祖先贡献总和。
计算方式:块内每个节点做区间加,最后前缀和。
查询时整块贡献可以 O(1) 查表。
转化 5:用值域分块维护散块贡献,避免退化
如果很多块变成“不统一”,查询时暴力扫描这些块会退化成 O(n)。
于是引入值域分块(差分思想)维护所有散块节点的贡献:
- vcnt[i]:DFS 序位置 i 的差分值;
- vsum[b]:值域块 b 的差分和。
操作:
- 区间加 O(1);
- 单点查询 O(√n)。
修改时散块节点从值域结构中删旧值、加新值;整块若原本不统一,也先把贡献移出值域结构,再改 tag。
查询时散块贡献由一次 vqry(dfn[pos]) 得到,不再暴力扫描。
四、算法流程
1. 预处理
- 读入左右孩子 ls[u], rs[u];
- 从根节点 1 开始 DFS:
- 计算 dfn[u]:DFS 序;
- 计算 tout[u]:子树结束位置;
- 计算 sz[u]:子树大小;
- 计算 lef[u]:静态贡献。
2. 分块初始化
- 按节点编号分块,块长 B = √n;
- 对每个块:
- 设置 st[b], en[b];
- 标记 tag[b] = -1;
- 调用 build_block(b) 计算 f、g 数组。
- 初始化 val[u] = -1;
- 初始化值域分块。
3. 区间修改 [l, r] → x
- 若 l, r 在同一块:
- 下推该块标记;
- 暴力修改 val[l..r];
- 将该块标记为不统一 tag = -2;
- 同时更新值域分块中的散块贡献。
- 若跨块:
- 处理左散块:下推、暴力修改、标记不统一、更新值域结构;
- 处理右散块:同理;
- 中间整块:若原本不统一,先把块内节点贡献移出值域结构,再直接 tag = x。
4. 单点查询 pos
- 初始化:
ans = 1 + lef[pos] - 获取 pos 当前 x 值:
- 若所在块 tag != -2,取 tag;
- 否则取 val[pos]。
- 加上自身贡献:
- 若 x_pos ≠ -1,加 sz[ls[pos]];
- 若 x_pos = 1,加 sz[rs[pos]]。
- 加上散块动态贡献:
- ans += vqry(dfn[pos])。
- 遍历所有块:
- 若 tag[b] == -1:ans += f[b][dfn[pos]];
- 若 tag[b] == 0:ans += g[b][dfn[pos]];
- 若 tag[b] == 1:无贡献;
- 若 tag[b] == -2:散块贡献已由值域分块处理,不再扫描。
- 输出 ans。
五、注意事项
-
lef 必须计算
缺少 lef 会导致静态贡献丢失,查询结果错误。 -
build_block 中区间从 dfn[i]+1 开始
排除节点自己,否则会把自身也当作祖先。 -
暴力检查祖先时用 dfn[j] < dfn[pos]
严格小于,排除自己。 -
判断右子树时用 dfn[rs[j]] <= dfn[pos]
包含右孩子本身,因为 x=0 时右孩子本身也在右子树中。 -
值域分块防止退化
不统一块如果暴力扫描,最坏 O(n)。用值域分块后,散块贡献查询 O(√n),整体稳定。 -
f、g 数组转置
存成 f[dfn][块] 而非 f[块][dfn],查询时 f[dp][i] 连续访问,缓存友好。 -
块长选择
B = √n 通常足够。若查询偏多,可适当调大块长(如 1.2√n)减少块数。 -
内存控制
f、g 各约 n × (n/B) 个 short,总内存约 130 MB。若用 int 会超内存。 -
快读快写
大数据量下 cin/cout 可能超时,建议用 fread / fwrite 手写读写。 -
严格祖先与自身贡献不能重复
自身贡献单独加,祖先贡献只统计严格祖先。
六、复杂度
| 步骤 | 复杂度 |
|---|---|
| DFS 预处理 | O(n) |
| 分块预处理 f、g | O(n√n) |
| 区间修改 | O(√n) |
| 单点查询 | O(√n) |
| 总时间 | O((n+q)√n) |
| 空间 | O(n√n),约 130 MB |
在 n, q ≤ 10^5 下,约 3 × 10^7 次操作,可稳定通过。
七、代码实现要点
- 用 short 存 f、g,节省内存;
- val 和 tag 可用 signed char;
- 热点函数加 inline 或 attribute((always_inline));
- 查询时提前取出 short *fd = f[dp], *gd = g[dp];
- 值域分块和节点分块使用同一块长,简化边界;
- 修改散块时,先 modify(i, -1) 删旧值,改 val,再 modify(i, 1) 加新值;
- 整块从不统一变为统一时,先把块内所有节点贡献移出值域结构。
八、总结
本题的关键在于:
- 把遍历位置拆解为静态贡献、动态贡献、自身贡献;
- 把祖先贡献转化为 DFS 序区间加;
- 按节点编号分块,预处理整块贡献数组 f、g;
- 用值域分块维护散块贡献,避免不统一块暴力扫描导致的退化;
- 卡常优化:数组转置、快读快写、强制内联、short 省内存。
掌握这五个关键转化,即可在 O((n+q)√n) 时间内解决本题。

浙公网安备 33010602011771号