Codeforces 438D 区间取模 从最大值剪枝到均摊线段树
区间修改并不总能压成一个懒标记。对每个元素取模时,区间和本身不足以告诉我们新的区间和;但如果这一段的最大值小于模数,整段就完全不用修改。
这篇接在线段树三题与子树染色之后,换一个问题:无法整段更新时,能否证明真正发生变化的次数足够少?438D 的关键不是让每次取模都变成 O(log n),而是让整串操作的总成本有上界。
会维护区间和
理解建树、单点赋值和父节点合并即可,不需要预先掌握 Segment Tree Beats。
哪些节点不用访问
最大值判定无变化;减半性质限制真正修改叶子的次数。
解释单次与总成本
既能构造一次 O(n) 的更新,也能证明多次更新的均摊界。
官方题目与操作范围
题目为 Codeforces 438D The Child and Sequence。以下范围、难度与标签于 2026-10-05 根据官方题目页核对,解释为本站原创。
| 项目 | 官方信息 |
|---|---|
| 难度与标签 | 2300;data structures、math |
| 数组长度 n、操作数 m | 均为 1 到 100000 |
| 初始元素、赋值 x、模数 x | 均为 1 到 1000000000 |
1 l r |
查询闭区间内元素之和 |
2 l r x |
对区间内每个元素分别取模 x |
3 k x |
把第 k 个元素赋值为 x |
初始值和赋值是正数,但取模后可以出现 0。区间和最高达到 10^14,需要 long long;不能把“单个值放得下 int”当作和也放得下的理由。
为什么区间和不能直接打取模标记
朴素数组逐项取模,单次最坏 O(n),m 次最坏 O(nm)。先尝试普通区间和线段树:能否把 sum %= x 作为更新?不能。例如 [5,5] 对 4 取模后和为 2,而原和 10 % 4 也为 2 只是巧合;换成 [6,4],原和仍为 10,新和却为 2;再换 [7,3],新和变成 6。
| 原数组 | 原和 | 原和对 4 取模 | 逐项取模后的和 |
|---|---|---|---|
[5,5] |
10 | 2 | 2 |
[6,4] |
10 | 2 | 2 |
[7,3] |
10 | 2 | 6 |
即使额外维护最大值,也不够直接算新和。比较 [7,5,4] 与 [7,6,3]:它们的和都为 16、最大值都为 7,对 4 取模后的和分别为 4 与 8。因此 sum 和 max 不能代表取模所需的完整分布。最大值在这里负责的是判定何时不必修改,而不是算出整段修改结果。
节点只存和与最大值
对节点区间 [L,R] 定义 sum 为所有元素之和,mx 为最大值。两个孩子合并时,和相加、最大值取较大者。查询和、单点赋值与普通线段树相同。
取模更新按以下顺序处理:区间不相交就返回;若 mx < x,说明每个非负元素都小于 x,取模不改变任何值,也返回;否则,叶子直接做 % x,内部节点访问两个孩子并重新合并。
flowchart TD
A[访问节点区间] --> B{与目标区间相交吗}
B -- 否 --> C[直接返回]
B -- 是 --> D{最大值小于模数吗}
D -- 是 --> C
D -- 否 --> E{是叶子吗}
E -- 是 --> F[元素取模并同步和与最大值]
E -- 否 --> G[递归处理两个孩子]
G --> H[重新合并 sum 与 mx]
mx == x 不能剪掉:等于 x 的元素必须变成 0。这个实现没有取模懒标记,不需要下传;所有数值变化都落实到叶子,父节点随回溯更新。
正确性从叶子向上证明
建树后,叶子的和与最大值等于元素,内部节点由合并规则满足定义。单点赋值直接改叶子,沿路径重新合并,因此保持不变量。
对一次取模更新按节点长度归纳。不相交节点不该改变;mx < x 时,全区间都不会改变,剪枝正确。剩下的叶子与目标区间相交,执行精确的 % x。内部节点分别正确更新孩子,再合并正确的和与最大值,所以整个更新正确。查询把目标区间拆成互不相交的节点,返回各段和,相加得到答案。
注意:剪枝条件对部分相交节点也成立。若整个节点都小于 x,它在目标区间内的那部分当然也小于 x。
手算一次修改再一次重置
以下为原创小例子:初始数组 [7,5,4,3]。
| 操作 | 操作后的数组 | 查询输出或观察 |
|---|---|---|
1 1 4 |
[7,5,4,3] |
19 |
2 1 4 4 |
[3,1,0,3] |
原最大值不小于 4,需向下访问 |
1 1 4 |
[3,1,0,3] |
7 |
2 2 4 4 |
[3,1,0,3] |
最大值小于 4,相关节点可剪掉 |
3 3 9 |
[3,1,9,3] |
单点赋值重新抬高局部最大值 |
2 2 3 5 |
[3,1,4,3] |
第二项不变,第三项减小 |
1 2 3 |
[3,1,4,3] |
5 |
第二次取模不需要为所有元素重复计算。单点赋值则提醒我们:数值不是永远只减不增,复杂度证明必须把“重新抬高”算进去。
一次有效取模为什么至少减半
设当前值为 a,模数为 x,且取模确实改变了值。由于 a 非负、x 正,因此 a≥x。分两种情况:
| 模数位置 | 新值的界 |
|---|---|
| x≤a/2 | a % x < x ≤ a/2 |
| x>a/2 | a 只含一个 x,a % x = a-x < a/2 |
无论哪种情况,新值都严格小于 a/2。这不是“通常下降得快”,而是每次有效取模都满足的数值界。
flowchart LR A[初始值或单点赋值 A] --> B[有效取模后小于一半] B --> C[有效取模后再次减半] C --> D[有限次后变为零] D --> E[以后可被最大值剪枝] F[新的单点赋值] --> A
可以取势能 Phi = Σ ceil(log2(a[i]+1))。有效取模使对应项至少下降 1;0 的势能为 0。初始势能 O(n log(A+1)),每次单点赋值最多补入 O(log(A+1)),其中 A 是初始值与所有赋值的最大值。于是有效叶子修改总数 K 为 O((n+s)log(A+1)),s 是单点赋值次数。
单次最坏与均摊界必须分开
一次更新可以修改所有元素:把 n 个 10^9 都对 1 取模,就要访问整棵树,成本 O(n)。所以不能宣称区间取模单次 O(log n)。
若一次更新真正修改 k 个叶子,除边界路径外,每个未剪枝的相交内部节点都通往至少一个发生变化的叶子。把这些路径及其常数个被剪枝兄弟计入,访问数为 O((k+1)log n) 的上界。整个操作序列的成本因此为:
O(n + (m + (n+s)log(A+1))log n),空间 O(n)。
这里用的是非紧的安全上界;实际共享路径可以减少访问。查询与赋值各为 O(log n),建树 O(n)。若没有最大值剪枝,仍会反复扫过已经不变的元素,减半性质就无法转化为程序的总成本保证。
自测 为什么不能把取模当作普通懒标记
先用两个和相同、最大值相同的数组试算。再问自己:节点摘要能算出新和吗,还是只能保证本段完全不变?
本文的职责划分是:sum 回答查询,mx 识别无效修改,叶子执行真正取模。均摊分析证明向下走的总次数,而非替代更新逻辑。
完整 C++17 实现
下标从 1 开始,所有区间使用闭区间。代码仅使用 C++17 标准库。
1 |
|
边界与错误清单
| 边界或错误 | 检查方法 |
|---|---|
| 最大值等于 x | 不可用 mx <= x 剪枝 |
| x=1 | 所有目标元素变成 0 |
| 已经全为 0 | 合法状态,以 mx < x 返回 |
| n=1、l=r | 单叶更新与查询照常成立 |
| 取模之后再赋大值 | 赋值路径必须同时更新最大值 |
| 只更新 sum 不更新 mx | 剪枝会失真,甚至错误跳过更新 |
| 多次不相交区间 | 先检查范围,不误改邻居 |
| 和超过 32 位 | 10^5 个 10^9 的和为 10^14 |
| 忽略单点赋值的势能补充 | 会错误声称整个过程中每个位置只能变 O(log A) 次 |
独立对拍使用直接数组,不复用线段树剪枝或势能模型。大规模验证应同时包含全区间清零、反复无效取模、单点重新抬高和 64 位区间和。
与已有线段树问题比较
| 文章 | 修改如何处理 | 证明重点 |
|---|---|---|
| 52C 区间加 | 标记可整段合成 | 摘要更新与下传一致 |
| 620E 子树染色 | 覆盖位集,后标记替换前标记 | 集合并集与覆盖顺序 |
| 本题区间取模 | 无变化剪枝,其余到叶子 | 有效修改减半与势能补充 |
看见区间修改时,先尝试摘要能否整体变换;若不能,再寻找“何时不变”和“变化多少次”。本题属于值域收缩驱动的均摊线段树,并不等于已经实现了通用的 Segment Tree Beats。