Codeforces 343D Water Tree 从子树区间到重链剖分
子树灌满,根路径排空,然后询问一个顶点是否有水。两种修改作用在同一棵树上,却对应不同形状:子树适合 DFS 区间,路径未必连续。重链剖分把路径切成少量区间,同时保留子树连续性,于是可以共用一棵覆盖赋值线段树。
这篇从1006E 子树区间、620E 子树染色进入动态路径更新。与600E 小并大的静态合并不同,343D 必须按输入顺序处理修改,不能计算一次子树袋子后结束。
子树与覆盖标记
会把一次赋值理解为替换旧值,而不是累加或翻转。
一套编号,两种区间
子树只有一段,根路径由若干重链片段组成。
树遍历不使用递归
五十万点链也能构造父子关系、子树大小与重链编号。
官方操作与约束
Codeforces 343D Water Tree 官方题目于 2026-10-10 核对。下面为概括与原创推导,不复制整段题面。
| 项目 | 官方信息 |
|---|---|
| 难度与标签 | 2100;data structures、dfs and similar、graphs、trees |
| 顶点 n 与操作 q | 各为 1 到 500000 |
| 根与初态 | 根为 1;所有顶点起初为空 |
1 v |
v 及其全部后代灌满 |
2 v |
v 及其全部祖先排空 |
3 v |
满输出 1,空输出 0 |
| 时间与内存 | 4 秒;256 MB |
无向输入保证是一棵树。先输入 n 与 n−1 条边,再输入 q 与操作。祖先、后代操作都包括 v 本身;排空一个顶点不会自动排空它的全部后代。这里是题目指定的离散模拟,不是自行增加物理联动规则。
为什么普通 DFS 区间还不够
朴素修改逐顶点访问,单次可达 O(n),整串操作最坏 O(nq)。先序 DFS 让子树成为连续段,却不保证任意根路径连续:在访问下一个路径顶点之前,可能已经访问了旁支。
重链剖分选择每个顶点的最大子树孩子为重孩子,其他孩子为轻孩子,沿重边构成重链。选择依据是子树顶点数,不是深度、颜色或编号。大小相等时任选一个即可。
| 数组 | 含义 |
|---|---|
parent[v] |
父节点,根的父节点记为 0 |
size[v] |
包含自身的子树大小 |
heavy[v] |
最大子树孩子,没有孩子则为 0 |
head[v] |
v 所在重链的链头 |
pos[v] |
重孩子优先遍历给出的序号,从 1 开始 |
不需要 LCA:本题另一端始终是根。depth 也不是必需数组;每次越过链头就向父节点移动,直到进入根所在链。
重孩子优先编号同时保留两种连续性
先从根构造父先于子的 order,逆序计算 size 与 heavy。随后显式栈存待访问的轻链头;沿当前重链走到底,依次编号,同时把轻孩子压栈。
这里使用 LIFO 栈而不是队列。较深顶点压入的轻子树会先于祖先旁支处理;每个子树处理完,才会转向子树外的待处理项。于是这仍是重孩子优先的 DFS,虽然没有递归调用。
因此一条重链的编号连续且由上到下递增,节点 v 的整棵子树也恰好是 [pos[v], pos[v]+size[v]−1]。计算 size 的 order 可以是父先于子的其他顺序,但最终 pos 不能直接用那个顺序;把两者混为一谈会破坏子树区间。
flowchart TD A[构造父先于子的顺序] --> B[逆序求子树大小] B --> C[选择最大子树孩子] C --> D[沿重链连续编号] D --> E[轻链头用栈延后处理] E --> F[子树仍连续且每条重链连续]
根路径如何拆成少量片段
从 v 开始。如果 head[v] != head[1],当前链头到 v 对应 [pos[head[v]],pos[v]],将这段赋为 0,然后令 v=parent[head[v]]。这一步离开当前重链,越过一条轻边。进入根链后,更新 [pos[1],pos[v]]。
这些片段首尾衔接、不重复也不漏点:每次恰好覆盖当前位置到当前链头的路径,下一次从链头父节点继续。兄弟子树不在这些区间里。
为什么只有 O(log n) 段?若 p 的轻孩子 v 有大小 s,p 的重孩子大小至少也是 s,故 size[p] >= 1+2s。沿轻边向下时子树大小至少减半,根路径上只能遇到 O(log n) 条轻边。重边再长也不增加片段数。
flowchart LR
A[当前顶点] --> B[处理链头到当前点]
B --> C[跳到链头父节点]
C --> D{进入根所在链了吗}
D -- 否 --> B
D -- 是 --> E[处理根到当前点]
覆盖线段树只需要三个标记状态
节点标记为 0 或 1,表示整段已知统一状态;为 −1,表示需要继续看孩子。初始整棵树全为 0,因此所有数组位置初始化为 0 即可。
完整覆盖一个线段树节点时直接写入新标记。部分覆盖时,如果父标记仍为 0 或 1,先把这个状态赋给两个孩子,再把父标记改为 −1,继续进入相交的孩子。单点查询遇到统一标记即可返回。
这份程序不维护区间和,更新后也不需要 pull。父标记为 −1 时即使两个孩子碰巧相同,查询继续向下仍然正确;只是没有额外做压缩优化。
| 标记组合 | 应采用的规则 |
|---|---|
| 先填满,后排空 | 最终为 0 |
| 先排空,后填满 | 最终为 1 |
| 重复填满 | 仍为 1,不翻转 |
| 旧统一标记后局部修改 | 先下传旧状态,再修改局部 |
与242E 异或标记相比较,赋值是后者替换前者,不是 lazy ^= value。一次较新的整段覆盖也会遮住更旧的孩子状态;以后再局部更新时才通过下传同步。
手工编号与交错修改
树边为 1−2、1−3、2−4、2−5、3−6、6−7。相同大小时本例选 2、4 为重孩子,可得到:
| 顶点 | 1 | 2 | 4 | 5 | 3 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| pos | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| head | 1 | 1 | 1 | 5 | 3 | 3 | 3 |
子树 2 对应 [2,4];根到 7 则拆成 [5,7] 与 [1,1]。区间是 pos,不是原顶点编号。
| 操作 | 按顶点编号 1 到 7 的水状态 |
|---|---|
| 初始 | 0 0 0 0 0 0 0 |
1 1 |
1 1 1 1 1 1 1 |
2 7 |
0 1 0 1 1 0 0 |
1 3 |
0 1 1 1 1 1 1 |
2 5 |
0 0 1 1 0 1 1 |
最后顶点 4 仍为 1:排空 5 只影响 5、2、1,不影响兄弟 4。这个例子可检查“祖先”误写成“子树”的方向错误。
正确性与复杂度
重孩子优先编号保证子树段与重链段准确;路径分段每次覆盖当前链内的那一截,再继续父链,因此恰好覆盖根路径。线段树的统一标记和下传保持每个叶子的最新赋值。对操作序列归纳:初态一致,两类修改恰好替换目标顶点集合,查询读到当前状态,所以每次输出正确。
建图、两次树遍历与编号 O(n)。子树修改和单点查询各 O(log n),根路径修改 O(log² n),总时间 O(n+q log² n),额外空间 O(n)。树遍历无递归;线段树递归深度仍为 O(log n),五十万点链不使它变深。
完整 C++17 实现
1 |
|
边界与复盘
| 边界 | 检查项 |
|---|---|
| n=1、更新根 | 路径与子树均包含根自身 |
| 链与星形 | 不依赖递归树栈,也不依赖原编号顺序 |
| 子树端点 | 右端点为 pos+size−1,不能多一个 |
| 重复同类修改 | 赋值保持状态,不是翻转 |
| 子树填满后路径排空 | 只改变路径,旁支仍保留水 |
| 路径排空后子树填满 | 后来的赋值覆盖旧状态,时间顺序不能重排 |
tests/verify-water-tree-article.cjs 提取本文代码编译,以逐顶点灌水和逐父节点排水独立对拍,覆盖小树操作组合、随机编号、五十万点链与最大操作数。对拍验证实现,重链分段与连续性的证明解释为什么方法成立。
进一步想:如果要维护两点路径的和呢?
需要比较链头深度,先处理更深的一侧,并在最后处理同链区间;线段树也要增加长度、区间和与 pull。先补齐新的查询摘要,不要直接把本题只有单点查询的标记树搬过去。