Codeforces 620E 子树染色 从 DFS 序到位集懒标记线段树
如果查询只是“某棵子树里有多少种颜色”,可以扫描子树,用集合去重。加入“把整棵子树改成同一种颜色”后,反复扫描可能在每次操作都碰到整棵树。
本篇把已有的DFS 序与子树区间和懒标记线段树接起来:先证明子树能变成连续区间,再为“颜色集合”和“覆盖染色”设计彼此相容的摘要与标记。它与莫队算法的区别在于,这里的修改和查询必须按时间顺序生效。
官方题目与输入范围
题目为 Codeforces 620E New Year Tree。官方页面核对日期为 2026-10-05;本文使用原创中文概括与推导。
| 项目 | 官方信息 |
|---|---|
| 难度与标签 | 2100;bitmasks、data structures、trees |
| 节点数 n 与操作数 m | 均为 1 到 400000 |
| 树结构 | 无向树,根固定为节点 1 |
| 初始与修改颜色 | 编号 1 到 60 |
| 操作 1 v c | 将 v 的整棵子树,包括 v,覆盖为颜色 c |
| 操作 2 v | 查询 v 子树中不同颜色的数量 |
颜色编号只表示类别,没有加法意义。把颜色 2 覆盖成颜色 5,并不等于给节点加 3;覆盖操作会抹去该范围里原来的所有颜色。
为什么不能只保存颜色数量
左右区间都含两种颜色,它们的并集可能仍为两种,也可能为四种。节点若只保存“不同颜色数”,就不知道两边的重复关系。
| 左集合 | 右集合 | 两边数量 | 合并数量 |
|---|---|---|---|
| {1,2} | {1,2} | 2 与 2 | 2 |
| {1,2} | {2,3} | 2 与 2 | 3 |
| {1,2} | {3,4} | 2 与 2 | 4 |
本题最多 60 种颜色,可以把整个集合放进一个 64 位整数:颜色 c 对应 1ULL << (c-1)。颜色 1 使用第 0 位,颜色 60 使用第 59 位。
合并使用按位 OR。某颜色只要在任一孩子中出现,对应位就为 1;出现多次仍只占一位。查询结束后再计算置位数,不能将孩子的置位数直接相加。按位 XOR 会把两边都有的颜色消掉,也不是集合并集。
先序 DFS 怎样得到子树区间
维护 tin[v] 为首次访问位置,size[v] 为子树节点数。先序遍历会完整处理一个孩子的所有后代后,才进入下一个孩子,因此 v 的子树恰占闭区间 [tin[v], tin[v]+size[v]-1]。
本文树上遍历使用显式栈:弹出一个节点时记录进入位置,把孩子压入栈。后进先出保证最后压入的孩子及其后代会先被完整处理。孩子访问顺序可以不同,只要每棵子树内部连续,就不影响这里的操作。
得到先序数组后,逆序扫描它,将每个节点的 size 累加给父节点。后代的先序位置总在祖先后面,逆序时后代统计已经完整,因而能计算全部子树大小。树上遍历没有递归,链形树也不会产生四十万层调用栈;线段树的递归深度则只有 O(log n)。
flowchart TD A["以节点 1 为根"] --> B["显式栈记录先序 tin"] B --> C["逆序累加子树 size"] C --> D["子树变为连续闭区间"] D --> E["染色变为区间赋值"] D --> F["查询变为区间 OR"]
用原创小树演算:1 的孩子为 2、3;2 的孩子为 4、5;3 的孩子为 6。取先序 [1,2,4,5,3,6]:
| 节点 | tin | size | 子树区间 | 初始颜色 |
|---|---|---|---|---|
| 1 | 1 | 6 | [1,6] | 1 |
| 2 | 2 | 3 | [2,4] | 2 |
| 4 | 3 | 1 | [3,3] | 2 |
| 5 | 4 | 1 | [4,4] | 3 |
| 3 | 5 | 2 | [5,6] | 3 |
| 6 | 6 | 1 | [6,6] | 60 |
实现可能得到另一种合法先序;下表只是固定一种顺序便于手算,不是要求代码按节点编号排序孩子。
区间摘要和覆盖标记分别表示什么
每个线段树节点保存两个 64 位数:mask 表示区间内出现过的颜色;lazy 非零时表示整个区间最近被赋值为这个单色位集,孩子还可能保留旧摘要。0 用作“没有待下传赋值”,因为合法颜色的单色位集永远非零。
若一个区间被完整染成 c,不管原来包含多少节点、多少颜色,都有 mask=bit(c)。同时把 lazy 覆盖为 bit(c)。这里不用乘区间长度:摘要表示是否出现,而非出现次数。
| 原标记 | 后来的完整覆盖 | 合成标记 | 原因 |
|---|---|---|---|
| 无标记 | 染成 2 | 颜色 2 的单色位集 | 全段同色 |
| 染成 2 | 染成 5 | 颜色 5 的单色位集 | 后赋值覆盖前赋值 |
| 染成 60 | 染成 60 | 颜色 60 的单色位集 | 重复赋值仍同色 |
标记组合既不是 OR,也不是累加。染成 2 后 染成 5,集合只剩 {5},而非 {2,5}。
部分访问之前为什么必须下传
父节点有覆盖标记时,父摘要是最新的,但孩子可能仍描述覆盖前的颜色。下一次操作只访问其中一部分,就必须先将父标记应用到两个孩子,再将父 lazy 设为 0。
之后若执行局部修改,递归更新受影响的孩子,再以左右孩子 mask 的 OR 重算父摘要。查询本身不改颜色,但部分查询也要下传,才能读取真实的子区间集合。完整覆盖的查询直接返回父 mask,不必下传。
flowchart TD
A["访问线段树节点"] --> B{"目标是否覆盖整个区间"}
B -->|"是"| C["赋值更新 mask 与 lazy,或直接返回 mask"]
B -->|"否"| D["先把待覆盖标记下传给两个孩子"]
D --> E["递归处理相交孩子"]
E --> F["修改后 OR 回收;查询时 OR 合并"]
手工演算覆盖之后的查询
继续使用上面的六节点树。初始颜色集合为 {1,2,3,60}。
| 时间顺序 | 对应区间 | 最新颜色或查询集合 | 输出 |
|---|---|---|---|
| 查询子树 2 | [2,4] | {2,3} | 2 |
| 将子树 2 染成 60 | [2,4] | 节点 2、4、5 均为 60 | 无 |
| 查询整树 | [1,6] | {1,3,60} | 3 |
| 将根的子树染成 2 | [1,6] | 全树为 2 | 无 |
| 单独将节点 4 染成 3 | [3,3] | 只有节点 4 为 3 | 无 |
| 查询子树 2 | [2,4] | {2,3} | 2 |
| 查询子树 3 | [5,6] | {2} | 1 |
“根覆盖后又改一个叶子”能暴露下传遗漏:如果孩子还拿着旧的颜色 60,后续查询就会把已经不存在的颜色统计回来。
正确性证明
先序连续性已证明每个子树与一个闭区间一一对应,后续只需证明区间操作正确。
构建。 叶子 mask 为节点颜色的单色位集,内部节点 OR 两个孩子。按树高归纳,所有 mask 都恰为各自区间的颜色集合。
覆盖更新。 完整覆盖时全段只有新颜色,单色 mask 与 lazy 正确。部分覆盖前先下传,把父节点此前的赋值落实到孩子,使访问前孩子摘要正确;递归更新相交孩子后,用 OR 恢复父集合。新赋值替换旧标记,符合时间顺序。
查询。 完整包含的区间返回正确集合;部分查询下传后从相交孩子取得集合并做 OR,得到整个目标区间的并集。最终置位数等于颜色种类数,因为颜色与位位置一一对应。
由三者归纳,任意交错的覆盖与查询都得到当前时刻的正确结果。
完整 C++17 实现
1 |
|
__builtin_popcountll 是 Codeforces 常用 GCC 工具链提供的内建函数,不属于 ISO C++17 标准库;使用其他编译器时,可用循环 x &= x-1 逐个清除最低置位来计数。
复杂度与边界清单
DFS、子树大小统计与线段树构建均为 O(n)。每次赋值或查询为 O(log n),OR 与 64 位置位计数视为常数,因此总时间 O(n+m log n),空间 O(n)。
| 边界或错误 | 检查方法 |
|---|---|
| 颜色 60 | 使用 1ULL,避免 32 位左移 |
| 查询单个叶子 | 颜色数量永远为 1 |
| 全树覆盖后修改叶子 | 必须先下传根的最新覆盖 |
| 两个孩子颜色相同 | OR 保留一位,不能相加或 XOR |
| 父子子树连续覆盖 | 后来的赋值覆盖先前标记 |
| 根为节点 1 | 不能把输入中较小编号自动当父节点 |
| 链形树 n=400000 | 树上遍历使用显式栈 |
| 闭区间尾端 | tin+size-1,不是 tin+size |
| 没有待标记 | 0 是哨兵,合法颜色位集不能为 0 |
独立验证应在树上按孩子关系直接重染和集合查询,避免参考程序也使用 DFS 区间与线段树。大规模链与星形树还需要专门检查深度、根覆盖及第 59 位。
怎样推广这组设计
本题能把集合压进单个整数,依赖颜色种类不超过 60。若种类扩大,位集宽度、合并成本和空间都要重新分析。若树的根会改变,某节点子树也不再固定对应同一段区间。
复盘时可以连续回答三句话:为什么一个子树连续,为什么 OR 能代表颜色并集,为什么覆盖标记可以直接替换。把这三个理由拆开,比记住“树上题套线段树”更容易迁移到新问题。