Codeforces 600E 子树众数 从频次合并到小并大证明
每个顶点都要回答:自己的子树里,哪些颜色出现得最多?把这些颜色的编号相加。对子树反复遍历会做许多重复工作,但直接把孩子的答案相加也不对;真正需要复用的是颜色频次。
这篇接在树上 DFS、子树区间与子树染色之后。620E 有在线修改,600E 的树和颜色完全静态,因此可以自底向上合并袋子;我们选择迭代遍历,避免十万点长链带来的递归栈风险。
会划分父子关系
子树互不重叠,父节点的统计由自己的颜色与各孩子的统计组成。
频次不能丢
众数答案不是可直接相加的摘要;小袋里的计数必须进入大袋。
说清谁在翻倍
被收费的是源子树中的顶点,而不是可能发生碰撞的颜色键。
官方题目与范围
题目为 Codeforces 600E Lomsat gelral,以下信息于 2026-10-09 按官方题目页核对。题意只做概括,下面的推导、例子和代码为本站原创。
| 项目 | 官方信息 |
|---|---|
| 难度 | 2300 |
| 标签 | data structures、dfs and similar、dsu、trees |
| 顶点数 n | 1 到 100000 |
| 颜色编号 | 1 到 n |
| 结构 | n−1 条无向边构成树,根为 1 |
| 输出 | 按顶点编号输出每棵子树的所有最高频颜色编号之和 |
| 限制 | 2 秒;256 MB |
并列最高频的颜色全部计入,每个颜色编号只加一次,不乘出现次数。答案可能超过 32 位:当所有颜色 1 到 100000 各出现一次,根的答案为 100000×100001/2 = 5000050000。频次可以用 int,答案使用 long long。
只保存孩子的众数会漏掉什么
朴素做法对每个顶点重新扫描整棵子树。长链的工作量为 n+(n−1)+…+1,最坏 O(n²)。
但把每个孩子的“最高频次与众数和”当作节点摘要也不够。设 n 至少为 7,父节点自己的颜色为 7:
| 子树 | 各颜色频次 | 子树的最高频颜色 |
|---|---|---|
| 孩子 A | 1 出现 3 次;3 出现 2 次 | 1 |
| 孩子 B | 2 出现 3 次;3 出现 2 次 | 2 |
| 合并 A、B 和父节点 | 1:3;2:3;3:4;7:1 | 3 |
颜色 3 在两个孩子里都不是众数,在父节点却成为唯一众数。仅保留两个孩子的答案会永久丢失它的频次。与区间异或的固定 20 位摘要不同,这里保留一个 颜色 → 次数 的完整映射。
袋子保存什么,怎样增量维护答案
每个当前袋子维护三个量:freq[c] 为颜色 c 的次数;best 为最高频次;sum 为达到 best 的所有颜色编号之和。空袋 best=0, sum=0。
把颜色 c 的次数增加 delta,设更新后的频次为 k。其他颜色没有变化:
| k 与旧 best 的关系 | 新 best | 新 sum |
|---|---|---|
| k 小于 best | 不变 | 不变 |
| k 等于 best | 不变 | 加上 c |
| k 大于 best | 改为 k | 重置为 c |
delta 必须为正。第二行不会重复加入同一颜色:次数严格增加,更新前它还没有达到这个最高频次。若它原先就处于最高频次,增加后必然进入第三行。
合并整个孩子时,对孩子袋中的每个 (c,count) 调用一次增量更新,不是只增加 1。不能把两个袋子的 best 取最大、sum 相加,因为相同颜色在两边的频次会叠加。
先接管最大子树,再合并其余袋子
这里的“大”按袋子代表的顶点数衡量,具体就是孩子的子树大小,不是 map.size()。即使一棵大子树只有一种颜色,仍然可以直接接管它的袋子。
- 先得到每个孩子的袋子与答案。
- 找子树顶点数最多的孩子,移动其袋子的所有权给父节点,不复制映射。
- 加入父节点自己的颜色一次。
- 遍历其余孩子的颜色频次,合并到当前袋子;释放这些已被吸收的袋子。
- 保存当前
sum为父节点答案。以后袋子可能继续变化,但这个数值快照不变。
flowchart TD A[父子顺序的逆序处理] --> B[孩子袋子已经完成] B --> C[接管顶点数最多的孩子袋子] C --> D[加入自己的颜色] D --> E[把其他孩子的完整频次并入] E --> F[保存当前众数编号和] F --> G[袋子继续交给祖先复用]
本实现属于 small-to-large 容器合并。官方标签里的 dsu 不意味着一定要写 find/unite;它也不等同于另一种“保留重子树、清空轻子树贡献”的 sack 写法。两种方法都复用树上统计,但代码与复杂度证明应分别说明。
为什么重复合并总量可控
设一个轻孩子的子树有 s 个顶点。合并它时,目标袋子至少含有最大孩子的全部顶点,最大孩子大小不小于 s,因此合并后的袋子至少代表 2s 个顶点。
一次遍历源袋的条目数不超过 s:不同颜色数不会超过它代表的顶点数。我们把这次遍历的成本上界收费到源子树的 s 个顶点,每个顶点收取常数费用。某个顶点再次作为源袋被收费时,所在袋子质量已至少翻倍;从 1 增长到 n,它至多被收费 O(log n) 次。因此总遍历条目数为 O(n log n)。
这份证明不声称“每个颜色键迁移后,不同颜色数一定翻倍”。相同颜色会碰撞,例如两个大小为 100 的单色子树合并后仍只有一个键。翻倍的是代表的顶点数;按源顶点收费正好绕开键碰撞问题。父节点自己的一次插入,另计 n 次。
代码使用 std::map,单次颜色查找与更新 O(log n),所以总时间 **O(n log² n)**,不是 O(n log n)。unordered_map 的查找有期望界而非同样的确定性最坏界,本文不据此改写复杂度。
活跃袋子代表的顶点集合互不重叠。合并期间源映射与目标中的新条目短暂并存,总条目仍为 O(n);合并后立即释放源袋。邻接表、遍历顺序、大小和答案数组也为 O(n),总额外空间 O(n)。
flowchart LR A[轻子树有 s 个顶点] --> B[目标至少已有 s 个顶点] B --> C[合并后质量至少为二倍] C --> D[每个顶点被收费对数次] D --> E[条目更新总数为 n log n] E --> F[有序映射再乘 log n]
手工合并一次并列众数
树边为 1−2、1−3、2−4、2−5,颜色依次为 [2,1,3,3,1]。子树 2 有三个顶点,是根的最大孩子;它的袋子为 {1:2,3:1},best 为 2,sum 为 1。
| 处理动作 | 当前频次 | best | sum |
|---|---|---|---|
| 根接管子树 2 | 1:2;3:1 | 2 | 1 |
| 加入根的颜色 2 | 1:2;2:1;3:1 | 2 | 1 |
| 并入子树 3 的颜色 3 一次 | 1:2;2:1;3:2 | 2 | 4 |
最终按顶点编号输出 4 1 3 3 1。子树 2 的答案仍是 1,尽管它原来的袋子已经被根接管并修改。把所有节点的答案存成指向袋子的引用会破坏这个快照。
正确性:频次、最高频与遍历顺序
对处理顺序归纳。叶子的空袋加入自身颜色后,频次和答案显然正确。假设一个节点的所有孩子袋子均正确;每个孩子的顶点集合互不重叠,也不含父节点。接管一个孩子、加入父节点、并入其余孩子,恰好把父子树的所有顶点计入一次,所以最终每种颜色的频次正确。
每次增量只改变一种颜色;上述三种比较分别覆盖全部可能,并正确维护最高频次与众数和。因此合并完成时 sum 就是目标答案。
代码先从根构造父节点早于孩子的 order。逆序处理保证每个孩子已经完成;这里不依赖孩子之间的顺序,也不要求 order 是先序 DFS。所有权移动只改变数据存放位置,不改变孩子已经保存的答案。于是每个顶点的输出正确。
完整 C++17 实现
使用显式顺序替代递归,链形树也不会增加调用栈深度。unique_ptr 的 move 是袋子所有权转移,reset 释放的是内存,不涉及任何文件操作。
1 |
|
边界与错误清单
| 情况 | 需要检查 |
|---|---|
| n=1 | 没有边,答案为唯一颜色编号 |
| 全部同色 | 众数和为该编号,不是编号乘子树大小 |
| 全部不同色 | 所有颜色并列;根答案可能超过 int |
| 新颜色超过旧最高频 | sum 必须重置,不能保留旧众数 |
| 大小相同的孩子 | 任意选择一个接管,不影响答案与翻倍下界 |
| 长链 | 不使用递归树遍历;不要复制最大孩子的 map |
| 保存历史答案 | 存数值快照,不能随着袋子复用继续改变 |
| 静态限制被改变 | 若加入改色操作,本解不能原样在线维护所有祖先 |
验证脚本位于 tests/verify-subtree-modes-article.cjs:提取本文唯一 C++17 程序,无警告编译,使用独立的逐子树遍历计数核验小树,并测试十万点链、星形树和颜色碰撞。压力测试通过不等于承诺所有评测机器的墙钟时间;复杂度仍按上面的确定性界解释。
复盘:如果只想知道不同颜色数量呢?
完整频次仍可合并,但摘要的增量规则可以简化。先确定查询需要的信息,再区分接管成本、轻袋遍历成本和字典查找成本;不要看到“小并大”就直接写 O(n log n)。
继续比较620E 在线子树染色和86D 离线窗口频次:三题都统计颜色或数值,复用单位却分别是子树袋子、区间摘要和可移动窗口。