线段树模板题里,P3373 大概是很多人第一道“背模板也背不明白”的题。倒不是说代码长,而是它同时要求区间加和区间乘,两个懒标记一叠加,很多原本会写线段树 1 的人瞬间不知道下传顺序怎么处理。我第一次交的时候直接在 pushdown 里按直觉先加了再乘,结果样例过了,随手构造一组数据就错得离谱。后来把懒标记合并当成一个数学表达式来推,再回头看这道题,发现它考的就是一件小事:加法标记在乘法面前必须跟着被放大。这篇博客就把我绕过的弯、整理过的模板、以及调这种题时用的验证方法完整写一遍,希望能让同样被 P3373 卡过的人一次想明白。
1. 这道“模板题”的真正难点:不是代码长,而是懒标记的顺序
1.1 题目模型:区间加、区间乘、区间求和、统一取模
P3373 的要求很直接:维护一个长度为 n 的数组,支持三种操作:把某个区间内每个数都乘上一个数;把某个区间内每个数都加上一个数;查询某个区间内所有数的和。所有结果对一个给定的模数 p 取模。注意乘法和加法是交替出现的,没有任何限制说“先所有乘完再所有加”。
这里有几个约束很容易被忽略。第一,n 和操作数都是 1e5 级别,所以必须用 O(log n) 的数据结构,暴力更新不可行。第二,模数 p 不一定是质数,而且可能很大,这意味着你不能靠“乘法的模逆元”之类的技巧来偷懒,老老实实维护懒标记。第三,操作中给的乘数、加数可能超过 int 范围,通常要开 long long 才安全。很多人只盯着线段树本身,结果在类型转换上栽跟头,这类问题我在后面会专门说。
1.2 为什么线段树1的板子在这里直接失效
如果只做区间加,线段树只需要一个加法懒标记。当一个节点被完整覆盖时,把加法标记加进 add,把区间和加上“长度 × 加数”;当需要访问它的子节点时,再把 add 往下传。整个过程是线性的,非常简单。
一旦加入乘法,情况就变了。同一个节点上可能先积累了一个加法标记,然后又来了一个乘法标记,也可能先乘后加、再加再乘、再乘再加……这个节点到底应该怎么表示?如果把 add 和 mul 分开存,那么两个标记谁先谁后必须有一个明确的规则,否则同一个实际区间在不同时刻会得到不同的结果。线段树 1 的板子完全没有“乘法”这个概念,所以直接套上去一定错,而且错得非常隐蔽——小数据可能顺手就过,换一组数据就 WA。
1.3 懒标记到底是什么:延迟更新的“欠账”
我想用一个比喻来解释懒标记:它相当于一个节点对子节点欠了一笔账。区间更新时,如果一个节点对应的区间完全落在操作范围内,我不着急把变化下沉到叶子,而是先把变化记在这个节点的账本上,同时把该节点的区间和立刻修正正确。以后如果查询或更新涉及它的子区间,再把账本里的欠账结算给子节点。这样每个操作最多只走 O(log n) 个节点,复杂度才有保证。
线段树 2 里每个节点的账本有两项:乘了多少、加了多少。难点在于“欠账”不是简单相加,而是会互相影响——新欠一笔乘法账时,之前欠的加法账也要跟着放大;新欠一笔加法账时,之前欠的乘法账保持不动。能不能正确处理这种“账中带账”,就是这道题的核心分水岭。后面我会用公式严格推导这个顺序。
2. 先乘后加:推导懒标记合并公式的完整过程
2.1 用“实际值 = 原值 × 乘法标记 + 加法标记 × 长度”统一表示
假设某个节点代表的区间长度为 len,这个区间里每个元素真正的值,在应用该节点自身的懒标记之前,可以认为是一个“原始值”。这个原始值可能是更下层子节点已经结算完毕的值,也可能是之前操作残留在子节点里的值。我们约定,当前节点的懒标记表示还需要对它的整个区间统一施加一轮“先乘后加”的变换:
实际值 = 原始值 × mul[node] + add[node] × len
注意 add[node] 要乘以长度。为什么?因为区间和是每个元素的和。如果每个元素都加 k,整个区间的和就增加 k × len。而 mul[node] 不需要乘长度,因为每个元素乘 k,区间和自然也乘 k。
这个统一表达式是整个模板的地基。后续所有标记合并都从它出发。我把 len 写进表达式,而不是单独记一个“区间和变化量”,是为了避免在 apply_add 时忘记乘长度。很多新手写线段树 2 的 bug 其实就漏在这个长度上。
2.2 给区间乘一个数时:乘法标记和加法标记分别怎么变
现在考虑对一个已经带有懒标记的节点,整体乘一个数 k。也就是说,该节点的“已承诺”变换是:值 = 原值 × mul + add × len。现在又要在此基础上整体乘以 k,那么新期望是:
新值 = (原值 × mul + add × len) × k = 原值 × (mul × k) + (add × k) × len
所以乘法标记应该变成 mul × k,加法标记应该变成 add × k,区间和 tree 也要变成 tree × k。这里最容易出错的是最后一条:add 也要乘 k。因为原本欠着“每个元素加 add”这笔账,现在所有元素整体又乘了 k,那这笔加法账自然要乘以 k 才行。我见过不少模板在 apply_mul 里只更新 tree 和 mul,不更新 add,结果就是在“乘完再查子区间”的时候答案对不上。
顺带说,如果这个节点没有任何历史加法账(add=0),那 add × k 仍然是 0,看起来“不处理也行”。但只要之前有过区间加操作,add 非 0,这里就躲不过去。P3373 这种混合操作题里,这种情况几乎必然出现。
2.3 给区间加一个数时:只有加法标记变
再来看整体加一个数的情况。基于同样的表达式:
新值 = 原值 × mul + add × len + k × len = 原值 × mul + (add + k) × len
因此加法标记 add 变成 add + k,区间和 tree 增加 k × len,乘法标记 mul 完全不变。理由也很直观:整体加 k 不会改变之前已经乘过的倍数,所以乘法账不动;加法账上再叠一笔新账。
这两个推导合起来就是线段树 2 的核心:乘法操作会同时放大乘法和加法两类标记,加法操作只会增加加法标记。只要记住这个不对称性,代码就不容易写错。
2.4 pushdown 为什么必须先传乘法再传加法
当一个非完整覆盖的更新或查询需要访问子节点时,必须先把当前节点欠的账发给两个子节点。这里的顺序为什么必须是“先传乘法、再传加法”?因为我们在 2.1 里约定的变换本身是“先乘后加”:实际值 = 原值 × mul + add × len。父节点的懒标记用这个顺序定义,下发给子节点时自然也要按这个顺序合并到子节点的账本里。
具体地,设子节点原来的标记是 mul_child、add_child,它代表的实际值是 原值 × mul_child + add_child × len_child。现在父节点的乘法标记 m、加法标记 a 要作用到这个子区间,那么新的期望是:
(原值 × mul_child + add_child × len_child) × m + a × len_child = 原值 × (mul_child × m) + (add_child × m + a) × len_child
所以你如果按顺序调用 apply_mul(child, m),再调用 apply_add(child, a),代码正好实现这个公式:先传乘法让子节点的 mul 和 add 都乘 m,再传加法让子节点的 add 加 a。反过来先加后乘,就变成了 (add_child + a) × m,和正确结果 add_child × m + a 不等价。只在 a=0 时碰巧相等,所以样例能过、数据一大就挂。
提示:不要把“先乘后加”理解成“先执行乘法操作再执行加法操作”。这里指的是懒标记合并公式中的变换顺序。操作历史可能是先加后乘,但合并后统一用“先乘后加”的表达式表示;之所以可以这样统一,是因为乘法分配律。推导时抓住实际值 = 原值 × 乘法标记 + 加法标记 × 长度 这个公式就不会乱。
3. 完整模板逐段拆解:build、pushdown、更新与查询
3.1 数组开多大、为什么乘法标记初始为 1
线段树一般用完全二叉树下标法组织节点:根是 1,左儿子 2×node,右儿子 2×node+1。数组要多开四倍,也就是 N<<2,而不是 N×2。因为满二叉树下标范围可能超过 2N,开到 4N 基本够用。这里 N 是数据的最大长度,不是操作数。很多人把 N 定义成 1e5 后直接开tree[200005],遇到 n 接近 1e5 时会越界。
乘法懒标记的单位元是 1,因为任何数乘 1 等于它自己;加法懒标记的单位元是 0。所以初始化节点时,mul 要赋成 1,add 要赋成 0。如果忘记初始化 mul,全局变量默认是 0,那么 pushdown 里判断mul[node] != 1就会永远成立,每次查询都把子节点清掉,完全乱套。
3.2 build 与初始化
build 的过程就是递归建树。对叶子节点存下a[l] % p,对内部节点存下左右儿子之和再取模。因为叶子节点可能有取模后的值,内部节点求和也要及时取模,否则后面乘法一放大就超出预期。
在 build 的入口处直接设置mul[node] = 1; add[node] = 0;,这样能保证所有被访问到的节点都有正确的初始标记。如果为了省事,也可以靠全局初始化,再在 build 里只对叶子节点赋值,但我推荐显式初始化,因为 pushdown 后 mul 会重置为 1、add 会重置为 0,显式写清楚更容易排查状态。
void build(int node, int l, int r) { mul[node] = 1; add[node] = 0; if (l == r) { tree[node] = a[l] % p; return; } int mid = (l + r) >> 1; build(node << 1, l, mid); build(node << 1 | 1, mid + 1, r); tree[node] = (tree[node << 1] + tree[node << 1 | 1]) % p; }3.3 apply_mul 和 apply_add 两个小函数的含义
我习惯把“对一个节点整体应用乘法/加法”封装成两个小函数,这样 pushdown 和区间更新都能复用,代码短很多。
void apply_mul(int node, long long k) { tree[node] = (tree[node] * k) % p; mul[node] = (mul[node] * k) % p; add[node] = (add[node] * k) % p; } void apply_add(int node, int l, int r, long long k) { tree[node] = (tree[node] + (long long)(r - l + 1) * k) % p; add[node] = (add[node] + k) % p; }apply_mul 里add[node] = (add[node] * k) % p是整份模板里最值得画线的一行。我刚才推导过,整体乘 k 时,节点上欠着的加法账也要放大 k 倍。少了这一行,先加后乘的区间查询结果就可能出错。apply_add 里(r - l + 1)是区间长度,必须转成 long long 再乘 k,否则两个 int 相乘可能溢出。如果 p 很大,甚至tree + len*k也可能超过 int,所以 tree、mul、add、k 都用 long long,这是最稳的写法。
3.4 pushdown 的实现细节
pushdown 的职责是把当前节点的懒标记下传给左右儿子。完整写法是:
void pushdown(int node, int l, int r) { int mid = (l + r) >> 1; int left = node << 1; int right = node << 1 | 1; if (mul[node] != 1) { apply_mul(left, mul[node]); apply_mul(right, mul[node]); mul[node] = 1; } if (add[node] != 0) { apply_add(left, l, mid, add[node]); apply_add(right, mid + 1, r, add[node]); add[node] = 0; } }注意两个细节。第一,先处理乘法标记,再处理加法标记,顺序不能反,原因在 2.4 里已经讲过。第二,传给左儿子和右儿子的 add 是同一个值,但 apply_add 需要各自的 l、r 来计算区间长度。很多人把右儿子的区间写成(mid + 1, r),却传成了(l, r),长度算多一倍,输出就偏大。这类错误在模板题里非常典型。
3.5 区间更新与查询的主流程
区间更新分两种:区间乘和区间加。它们的递归骨架完全一样,区别只在完全覆盖时调用的 apply 函数不同。我建议把乘和加分开写,虽然代码量多一点,但读起来清楚,也不容易改串。
void update_mul(int node, int l, int r, int ql, int qr, long long k) { if (ql <= l && r <= qr) { apply_mul(node, k); return; } pushdown(node, l, r); int mid = (l + r) >> 1; if (ql <= mid) update_mul(node << 1, l, mid, ql, qr, k); if (qr > mid) update_mul(node << 1 | 1, mid + 1, r, ql, qr, k); tree[node] = (tree[node << 1] + tree[node << 1 | 1]) % p; }区间加的 update_add 结构一模一样,只是完全覆盖时调用 apply_add(node, l, r, k),并且要给 k 先取模。查询函数也类似,遇到完全覆盖直接返回 tree[node],否则先 pushdown 再递归拼接左右结果:
long long query(int node, int l, int r, int ql, int qr) { if (ql <= l && r <= qr) return tree[node]; pushdown(node, l, r); int mid = (l + r) >> 1; long long res = 0; if (ql <= mid) res = (res + query(node << 1, l, mid, ql, qr)) % p; if (qr > mid) res = (res + query(node << 1 | 1, mid + 1, r, ql, qr)) % p; return res; }边界条件最容易写错的是进入左右儿子的判断。正确写法是if (ql <= mid)和if (qr > mid)。想一想:查询/更新区间 [ql, qr] 是否和左半边 [l, mid] 有交集?有交集的条件是 ql <= mid。是否和右半边 [mid+1, r] 有交集?条件是 qr >= mid+1,等价于 qr > mid。不要写成if (ql <= r)或者if (qr >= l),那样会多递归不需要的子树,甚至无限递归。
3.6 快读与取模:long long、mod p 的溢出问题
输入操作数有 1e5 级别,用 scanf 就已经足够快,没必要强行写快读。但如果刷题时习惯用 getchar 版快读,也完全没问题。关键是读入乘数、加数时要用 %lld,并且操作数 k 可能比 p 大很多,进入更新函数前先k %= p,避免后面乘法溢出或者取模结果不稳定。注意如果 p 是 1,k%p 会变成 0,不过一般题目不会出这种极端数据;真要遇到,所有值对 1 取模都是 0,结果也没有歧义。
数据范围下,long long 足够容纳中间结果:n 最大 1e5,每个数模 p 后不超过 2.1e9(若 p 接近 int 上限),区间和不超过 2.1e14,乘上 k 后仍然在 long long 范围内。但如果把 tree 开成 int,乘法直接溢出,WA 很难查。我的习惯是:只要题目涉及取模和乘法,所有参与计算的变量一律 long long,不要省这点内存。
4. 最容易错的三个地方:从报错纠错到小数据验证
4.1 查询时忘记 pushdown 的典型表现
查询子区间时,必须调用 pushdown 把祖先节点的懒标记先结算给子节点,再递归查询。否则子节点里的 tree 值还是旧的,不包含祖先节点欠的账。这个 bug 的典型表现是:连续做几次区间更新后,查询整个区间的结果可能还对(因为根节点的 tree 每次更新后立即修正了),但一查询子区间,结果就偏小,而且偏小的规律和更新次数有关,越查越明显。
有一种看起来省事的做法:不写 pushdown,而是在查询函数里累加经过路径上的所有标记。这种方法在小规模区间加题里可行,但在区间乘+加的混合题里非常容易写错,还要额外处理乘法对加法的缩放,完全不划算。老老实实在查询的每个递归分支前先 pushdown,才是模板题的正确姿势。
4.2 apply_mul 没处理 add 标记:一个手算反例
我前面说过 apply_mul 必须同步放大 add,这里给一个可以自己手算验证的反例。
假设数组只有两个数 a = [1, 2]。第一步给整个区间加 5,根节点被打上 add=5,tree 变为 13,子节点还没更新。第二步给整个区间乘 2,根节点完整覆盖,如果模板的 apply_mul 只更新 tree 和 mul,不更新 add,那么根节点 tree=26,mul=2,add 还是 5。这时查询整个区间的和,返回 26,看似正确,因为 13×2=26。但再查询第一个数,pushdown 时先把 mul=2 传给左儿子,左儿子 tree=6×2=12,mul=2;此时左儿子的 add 是 5 还是 10,就决定了后续是否正确。如果 add 还是 5,那么左儿子代表“原值 1 乘以 2 再加 5”,等于 7,而不是正确的 12;如果 add 变成了 10,左儿子才表示“原值 1 乘以 2 再加 10”,等于 12。
所以这个 bug 在只查询整段和的时候根本看不出来,非得查子区间或者再做一次区间加才能暴露。P3373 的测试数据恰恰会覆盖这种情况,因此一遍过的概率很低。
4.3 更新边界的 if 写错导致“多更新少更新”
区间更新的边界错误不像标记顺序那样隐蔽,但它更常见。如果你写的是if (ql <= r) update(left, ...),那么当查询区间在右半边、和左半边没有交集时,也会递归进左子树,最终在叶子节点被ql <= l && r <= qr误判为完整覆盖,造成错误更新。反过来,如果你写if (qr >= l),同样可能多走边界。
另一种常见错误是把完全覆盖条件写成ql <= l && qr >= r,这个倒是对的;但有人会写成l >= ql && r <= qr,也等价。最稳定的写法是先判断完全覆盖再算 mid,递归时只走if (ql <= mid)和if (qr > mid)。这样左右子树各自最多递归一次,复杂度正确。
4.4 小数据手算验证法:n=2 的两步操作
调这种模板题最有效的不是瞪眼找错,而是设计一组能覆盖“加乘混合”的小数据,手动算好预期结果,再跑程序比对。我最常用的一组是:
数组 [1, 2],模数取一个大数如 1000000007。操作:
- 对 [1,2] 加 5,得到 [6,7]。
- 对 [1,2] 乘 2,得到 [12,14]。
- 查询 [1,1],预期 12;查询 [1,2],预期 26。
如果程序输出 7 或者 13 附近的值,说明 apply_mul 没有同步放大 add,或者 pushdown 顺序不对。然后再补一组操作: 4. 对 [1,1] 加 3,得到 [15,14]。 5. 查询 [1,2],预期 29。
这一步用来验证懒标记在多层之间的合并是否仍然正确。两组数据跑完,模板基本就稳了。如果还不够,可以继续构造先乘后加再乘再查的序列,把每种组合都过一遍。
4.5 调试时打印整棵树的技巧
我写线段树调试时会写一个简单的 print 函数,递归输出每个节点的区间、tree、mul、add。格式类似:
void print_tree(int node, int l, int r) { printf("[%d,%d] tree=%lld mul=%lld add=%lld\n", l, r, tree[node], mul[node], add[node]); if (l == r) return; int mid = (l + r) >> 1; print_tree(node << 1, l, mid); print_tree(node << 1 | 1, mid + 1, r); }在每次操作后调用一次,把输出和手算过程对照。重点看两个地方:完整覆盖后,父节点的 add 和 mul 是否按照公式更新;pushdown 后,父节点的标记是否清零,子节点的标记是否被正确叠加。很多肉眼发现不了的错误,打印几行状态就一目了然。调试完记得把 print 注释掉再提交,否则输出格式会乱。
5. 把模板变成自己的:记忆公式、扩展思路与最终模板
5.1 一个公式记住合并规则
如果觉得 apply_mul、apply_add、pushdown 容易记混,我建议只记一个公式:
实际值 = 原始值 × 乘法标记 + 加法标记 × 区间长度
所有标记合并规则都能从它推出:
- 整体乘 k:乘法标记 ×k,加法标记 ×k;
- 整体加 k:加法标记 +k,乘法标记不动;
- 父节点下传:子节点的加法先按父乘法放大,再累加父加法。
再配合“先乘后加”的约定,你就不用死记 apply_mul 里的三行代码了。这个公式也是后续写区间赋值、区间取反等扩展题的基础,会了它,线段树模板就不再是一段需要背的黑盒,而是可推导的工具。
5.2 从“加乘”扩展到更多操作组合
学会 P3373 之后,“区间赋值”“区间取反”“区间 min/max 赋值”等经典扩展都可以用同样的懒标记合并思路实现。比如区间赋值,相当于“先乘 0 再加 x”,所以可以复用乘法加法的框架,赋值标记用mul=0, add=x表示。区间取反则要把加和乘的合并规则反过来思考,本质上还是把当前节点的懒标记整合成一个线性变换。理解了“标记是作用在子节点状态上的变换”这一层,任何需要延迟更新的操作都能拆成类似的两个小函数。
线段树能直接做的事远不止求和。覆盖数组最大值、最小值、区间 gcd、区间连续子段和等,只要状态可以合并、懒标记可以复合,都能套用同一个递归结构。P3373 是最典型的“标记复合”训练题,把它吃透,后面看这些扩展都会顺畅很多。
5.3 线段树与树状数组模板怎么选
经常有人同时刷“线段树模板”和“树状数组模板”,问哪个更好。我的看法是:能用树状数组解决的题目,代码短、常数小、好调试,比如单点修改区间求和、区间修改单点查询。但要支持区间乘+区间加+区间求和这种操作,树状数组要么做不了,要么得套差分加前缀公式,推导复杂度反而不如线段树直白。所以做题时先看操作类型:如果只有加法类前缀可拆分的操作,优先树状数组;如果涉及乘除、赋值、取反等不可简单差分的操作,直接上带懒标记的线段树。P3373 属于后者,模板选择没有悬念。
树状数组和线段树不是二选一的死关系。它们共享“二进制划分”思想,树状数组可以看成线段树的特化版本。把线段树 2 写熟之后,你再看树状数组的区间修改区间求和,会更容易理解差分数组为什么要那样设计。
5.4 顺手收藏的常数优化与代码习惯
最后分享几个我实际写题时养成的小习惯,都是血的教训换来的。
第一,数组大小固定写N << 2,不要在函数里临时计算,避免重复写错。第二,所有和取模有关的变量统一 long long,宁可多占一点内存,不要因为 int 溢出换来一次 WA。第三,每次读入操作数后立刻k %= p,这样后面所有乘法都控制在模数范围内,中间结果更小、出错概率更低。第四,pushdown 函数建议写成独立的函数,而不是在更新函数里重复内联;虽然多一次函数调用,但代码清晰,调试方便,模板题这点开销无所谓。
如果追求极致常数,可以把更新和查询改成迭代版 zkw 线段树,但 P3373 这种递归版足够过题,没必要为了常数牺牲可读性。还有一个很多人忽略的点:build 时如果输入数据全部在递归前读入,那么叶子节点直接取模即可;如果一边读一边 build,要注意读入顺序和递归顺序一致,否则会错位。保持“先读入 a 数组,再 build”的写法最不容易出问题。
下面是整理好的完整模板,build、apply_mul、apply_add、pushdown、update 和 query 都放在一起,main 里按操作类型调用,直接提交即可:
#include <bits/stdc++.h> using namespace std; typedef long long ll; const int N = 100005; ll a[N]; ll tree[N << 2], mul[N << 2], add[N << 2]; int n, m, p; void build(int node, int l, int r) { mul[node] = 1; add[node] = 0; if (l == r) { tree[node] = a[l] % p; return; } int mid = (l + r) >> 1; build(node << 1, l, mid); build(node << 1 | 1, mid + 1, r); tree[node] = (tree[node << 1] + tree[node << 1 | 1]) % p; } void apply_mul(int node, ll k) { tree[node] = (tree[node] * k) % p; mul[node] = (mul[node] * k) % p; add[node] = (add[node] * k) % p; } void apply_add(int node, int l, int r, ll k) { tree[node] = (tree[node] + (ll)(r - l + 1) * k) % p; add[node] = (add[node] + k) % p; } void pushdown(int node, int l, int r) { int mid = (l + r) >> 1; int left = node << 1; int right = node << 1 | 1; if (mul[node] != 1) { apply_mul(left, mul[node]); apply_mul(right, mul[node]); mul[node] = 1; } if (add[node] != 0) { apply_add(left, l, mid, add[node]); apply_add(right, mid + 1, r, add[node]); add[node] = 0; } } void update_mul(int node, int l, int r, int ql, int qr, ll k) { if (ql <= l && r <= qr) { apply_mul(node, k); return; } pushdown(node, l, r); int mid = (l + r) >> 1; if (ql <= mid) update_mul(node << 1, l, mid, ql, qr, k); if (qr > mid) update_mul(node << 1 | 1, mid + 1, r, ql, qr, k); tree[node] = (tree[node << 1] + tree[node << 1 | 1]) % p; } void update_add(int node, int l, int r, int ql, int qr, ll k) { if (ql <= l && r <= qr) { apply_add(node, l, r, k); return; } pushdown(node, l, r); int mid = (l + r) >> 1; if (ql <= mid) update_add(node << 1, l, mid, ql, qr, k); if (qr > mid) update_add(node << 1 | 1, mid + 1, r, ql, qr, k); tree[node] = (tree[node << 1] + tree[node << 1 | 1]) % p; } ll query(int node, int l, int r, int ql, int qr) { if (ql <= l && r <= qr) return tree[node]; pushdown(node, l, r); int mid = (l + r) >> 1; ll res = 0; if (ql <= mid) res = (res + query(node << 1, l, mid, ql, qr)) % p; if (qr > mid) res = (res + query(node << 1 | 1, mid + 1, r, ql, qr)) % p; return res; } int main() { scanf("%d%d%d", &n, &m, &p); for (int i = 1; i <= n; i++) scanf("%lld", &a[i]); build(1, 1, n); while (m--) { int op; scanf("%d", &op); if (op == 1) { int l, r; ll k; scanf("%d%d%lld", &l, &r, &k); update_mul(1, 1, n, l, r, k % p); } else if (op == 2) { int l, r; ll k; scanf("%d%d%lld", &l, &r, &k); update_add(1, 1, n, l, r, k % p); } else { int l, r; scanf("%d%d", &l, &r); printf("%lld\n", query(1, 1, n, l, r)); } } return 0; }如果你也想省事,建议把这份代码整理成自己的风格,而不是直接背网上的版本——自己动手抄一遍、调一遍,记忆深度完全不一样。之后遇到区间加乘混合题,先复制出来,再根据题目微调即可。