做竞赛题的人可能都有过这种体验:看到 “区间修改 + 区间查询” 的第一反应就是上线段树,三分钟敲完模板,然后发现要么超时,要么答案压根不对。尤其是当题目里混进 “构造”“数学”“规律” 这些字眼时,很多人就直接放弃了。我这几年刷算法提高类的题,最大的感受是:真正把“线段树 + 数学”这类硬核区间题做明白的人,不是线段树打得有多熟,而是愿意在草稿纸上多推几步公式。
这篇文章想聊的,就是那些“看似是数据结构题,实际靠数学救场”的区间问题。我会用几个典型例子拆解推导过程,把懒标记怎么设计、势能均摊怎么证明、公式校验为什么能判区间性质,一点一点讲清楚。适合已经会线段树基本操作、但觉得进阶题无从下手的同学,也适合正在备战算法竞赛或大厂算法笔试的人。你不需要一口气读完,挑自己卡壳的章节看就行,但如果你能把每道例子的推导亲手写一遍,收获会比看十篇教程都大。
1. 别急着写代码,先想清楚这题考的是数据结构还是数学
1.1 三类容易混淆的“区间题”
区间问题在算法题里出现频率很高,但难度层级差别非常大。我一般把它们分成三类:
- 纯数据结构题:操作和查询都能直接翻译成线段树的节点维护、懒标记合并。比如区间加、区间求和、区间最大值,这类题考验的是模板熟练度。
- 数据结构 + 数学建模题:操作本身有“不规则性”,比如区间开根号、区间取模、区间加等差数列,如果不做数学化处理,线段树的懒标记根本没法定义,或者更新一次要动一片叶子。
- 数学为主、数据结构为辅的题:比如“判断一个区间能否重排成等差数列”“区间内是否满足某种模运算规律”,这类题核心是找到一组“特征值”,用公式把特征值快速算出来,线段树只是帮你在 log 时间内拿到这些特征值。
很多人一上来就把第三类当第二类做,写了一个超级复杂的线段树去维护“能不能重排成等差数列”这种 bool 标记,结果根本没法合并。其实答案早在数学里:能不能构成等差数列,不是靠搜索验证的,是靠“必要条件足够强”来判定的。这个思路的转变,才是解题的分水岭。
1.2 为什么数学性质直接决定算法复杂度
拿“区间开根号求和”来说。如果线段树维护的是区间最大值,我们可以发现一个关键事实:任何一个大于 1 的数,连续开整数次根号后很快就会变成 1,而 1 再开根号还是 1。也就是说,每个叶子节点真正需要“被更新”的次数是极少的。这样我们就能设计一种“暴力但均摊后复杂度极低”的更新策略:区间被完整覆盖时,如果最大值已经等于 1,直接跳过;否则一路下钻到叶子。单点更新的次数总和是 O(n log log MAX),再乘上树高 log n,总复杂度依然非常可观。
这个例子里,线段树的结构没有变,变的只是更新策略。而更新策略的依据,就是从数学上证明了“势能下降有界”。所以我一直认为,刷这类题的目的不是背更多模板,而是锻炼一种能力:把每个修改操作翻译成“某种量在有界次操作后必然收敛”的形式。掌握这个思路,你看到很多看似无解的题,都会打开新局面。
2. 典例一:区间开根求和的势能分析
2.1 朴素想法为什么不行
题目模型是:给定长度为 n 的数组,支持两种操作,第一种把区间 [l, r] 内每个数变成它的向下取整平方根,第二种查询区间和。数据范围 n 和操作次数可能是 1e5,数组元素在 1e18 以内。
最直观的想法是:线段树每个节点维护区间和,区间开根号时,因为开根号不是区间加、区间乘这类“可打懒标记”的操作,只好一直递归到叶子,对每个叶子单独开根。这最坏情况下一次操作就是 O(n log n),如果来 1e5 次操作,直接爆炸。
那能不能用懒标记存一个“开根若干次”的状态?也不行,因为不同位置的数开根次数不一样,无法统一合并。所以必须换个角度找性质。
2.2 核心推导:开根下降次数最多有多少次
关键性质其实很简单:对于任意整数 x ≥ 2,令 y = floor(√x),则 y < x,且当 x 很大时,y 大约只有 x 的一半位数。比如:
- 1e18 开根约等于 1e9
- 1e9 开根约等于 31622
- 31622 开根约等于 177
- 177 开根约等于 13
- 13 开根约等于 3
- 3 开根约等于 1
也就是说,1e18 级别的数,开根 6 次就掉到 1 了。全局来看,每个叶子在它被真正更新的次数上,都有一个非常小的上限 O(log log MAX)。那么即使我们每次区间更新时野蛮地下钻到叶子,所有叶子累积被访问的次数也不会超过 n × log log MAX。
这样一来,线段树上每个内部节点还能再剪一刀:如果当前节点的区间最大值已经是 1,说明这个区间内所有数都已经变 1,不用再下钻。于是总时间复杂度可以证明为 O((n + q) log n log log MAX),实际操作中远远跑不满。
2.3 可参考的实现代码
#include <bits/stdc++.h> using namespace std; typedef long long ll; const int N = 100005; ll a[N], sumv[N << 2], maxv[N << 2]; void pull(int p) { sumv[p] = sumv[p << 1] + sumv[p << 1 | 1]; maxv[p] = max(maxv[p << 1], maxv[p << 1 | 1]); } void build(int p, int l, int r) { if (l == r) { sumv[p] = maxv[p] = a[l]; return; } int mid = (l + r) >> 1; build(p << 1, l, mid); build(p << 1 | 1, mid + 1, r); pull(p); } void update(int p, int l, int r, int ql, int qr) { if (ql <= l && r <= qr && maxv[p] <= 1) { // 整个区间内全是 1,开根号没有任何变化 return; } if (l == r) { maxv[p] = (ll)sqrtl(maxv[p]); // 注意用 sqrtl 保证精度 sumv[p] = maxv[p]; return; } int mid = (l + r) >> 1; if (ql <= mid) update(p << 1, l, mid, ql, qr); if (qr > mid) update(p << 1 | 1, mid + 1, r, ql, qr); pull(p); } ll query(int p, int l, int r, int ql, int qr) { if (ql <= l && r <= qr) return sumv[p]; int mid = (l + r) >> 1; ll res = 0; if (ql <= mid) res += query(p << 1, l, mid, ql, qr); if (qr > mid) res += query(p << 1 | 1, mid + 1, r, ql, qr); return res; }注意:sqrt 的浮点精度在很多编译器里对 1e18 数量级会产生偏差,竞赛中我强烈建议用
sqrtl,或者用二分法手动开整数根。我因为这个精度问题踩过不止一次坑,最后统一改成了sqrtl,过题速度也没慢多少。
2.4 还能怎么迁移这个套路
一旦理解了“势能均摊”,类似题目直接套:
- 区间取模:维护区间最大值,如果最大值小于当前模数,整个区间直接跳过;否则下钻到叶子。数学上可以证明每个数被有效取模的次数是 O(log x),因为 x % m ≤ x / 2(当 m ≤ x / 2 时显然,当 m > x / 2 时余数为 x - m < x / 2)。
- 区间变约数个数:比如把每个数变成它的约数个数,也是每个点下降若干次后稳定。
这类题表面上是“区间暴力更新”,但因为每个点的下降次数有对数级别的天花板,整体复杂度就能被数学性质兜住。
3. 典例二:区间能否重排成等差数列——公式校验法
3.1 题目模型:信息合并的难点
再来看一道更符合标题气质的题:给定数组,支持单点修改,多次查询区间 [l, r] 内的数能否通过重排构成一个等差数列(通常还会加一个约束:公差 d 是正整数,或者允许 d = 0)。
如果只靠线段树存一个“这个区间已经是等差数列”的布尔值,合并两个子区间时是没法判断的,因为你不知道左边区间的最后一个数和右边区间的第一个数是否衔接上了。直接维护区间排好序的完整列表更不可能,合并代价太大。
所以我们需要换一个思路:不直接判断序列本身,而是用一组“必要条件”来把所有可能的情况卡死。
3.2 推导过程:四个特征值缺一不可
假设区间长度为 len = r - l + 1,如果这 len 个数可以重排成公差为 d 的等差数列,那么设最小值为 mn,最大值为 mx,则:
- 若 len = 1,一定可以,公差任意。
- 若 len = 2,一定可以,公差是 mx - mn(大于等于 0 即可)。
- 若 len ≥ 3 且 d = 0,所有数必须相等,也就是 mx = mn。
- 若 d > 0,必须满足 (mx - mn) % (len - 1) == 0,并且公差 d = (mx - mn) / (len - 1)。
但仅仅满足最大值和最小值的关系还不够。比如区间是 {1, 2, 4, 5},mn=1,mx=5,len=4,(5-1) % 3 == 0,d = 4/3 并不是整数,所以会被筛掉。再看 {1, 2, 3, 5},mn=1,mx=5,(5-1)%3 == 0 不成立,也会被筛掉。但 {1, 2, 4, 7} 呢?(7-1)%3 == 2,也不行。真正严格的情形是 {1, 2, 4, 8},d 算出来不是整数,所以仍不满足。
那有没有可能 mn、mx 都满足整除关系,但区间里乱序?例如 len=4,mn=1,mx=7,d=2,理论上数列是 {1, 3, 5, 7},但实际区间可能是 {1, 2, 5, 7}。这种情况只靠 min 和 max 检测不出来,所以还要加上和校验。
等差数列的和公式是:
sum_true = (mn + mx) * len / 2如果区间实际和等于这个值,范围进一步缩小。但还可能有构造失效的情况:{1, 3, 5, 7} 和 {1, 5, 5, 7}?后者的和是 18,前者和是 16,不相等,被排除。那有没有区间和恰好等于理论值但又不是等差数列的?有,比如 {1, 2, 6, 7},mn=1,mx=7,len=4,理论和为 16,实际和也是 16。肉眼可见它不是等差数列。所以和还不够,需要继续加特征。
此时用平方和校验:
sum_sq_true = mn^2 + (mn+d)^2 + ... + (mx)^2推导公式可以写成:
sum_sq_true = (mn^2 + mx^2) * len / 2 + d^2 * (len - 1) * len / 6等等,这个公式要仔细推。设数列元素为 a_i = mn + i * d,i 从 0 到 len-1。那么:
sum_sq_true = Σ(mn + i*d)^2 = Σ(mn^2 + 2*mn*i*d + i^2*d^2) = len * mn^2 + 2 * mn * d * (len-1)*len/2 + d^2 * (len-1)*len*(2*len-1)/6 = len * mn^2 + mn * d * len * (len-1) + d^2 * len * (len-1) * (2*len-1) / 6如果你维护了区间和、平方和,再配合 mn 和 mx,就能把大部分非法情况排除。但这套必要条件在数学上并不是完全充分的,因为可能存在哈希碰撞,实际竞赛里为了简化,通常把平方和校验换成一组随机权值的哈希校验,比如对值域映射随机大数后求和,或者直接用两个大质数下的模运算来降低碰撞概率。对于以“能否重排成等差数列”为判定目标的题,严格来说还需要判断区间内有没有重复元素,所以往往还会维护一个“值域上的出现次数哈希”。
现实中更常见的考法是:题目改成“区间排序后是否等于某个等差数列的前若干项”,这时候等价于验证集合相等,用两个哈希或者随机权值异或等方式做。线段树节点里维护的就不再是单个和,而是一组特征值。
3.3 合并操作和代码骨架
为了简洁,这里用随机权值哈希演示思路。给每个数值 x 分配一个 64 位随机数 h[x],线段树节点维护:
- 区间最小值 mn
- 区间最大值 mx
- 区间随机权值异或和 xr(或者和)
- 区间实际和 sum(便于校验等差数列求和公式)
每次合并两个子区间,mn 取小、mx 取大、xr 取异或、sum 直接相加。
判断一个区间能否构成等差数列时,先用 mn 和 mx 算出理论首项和公差,再用等差序列的哈希公式,计算出“理论区间哈希”,最后和实际维护的 xr 比对。随机权值下碰撞概率极低,工程上可以接受。
这个思路说明了一个很重要的点:有些时候我们不需要维护“直接答案”,而是维护一组可以被公式快速验证的特征值。这也解释了为什么很多题解里线段树节点会同时维护最大值、最小值、和、平方和,因为每个特征都是来“逼近”最终判定条件的。
注意:如果题目明确要求判断是否包含重复元素,单纯靠和、平方和、随机哈希都不能完全解决重复元素问题。更可靠的办法是额外维护每个数上次出现的位置,然后用区间最大值判断是否有重复,这是另一套基于“前驱位置”的技巧,这里就不展开了。
4. 典例三:区间加等差数列——一次函数懒标记的推导与下传
4.1 操作模型与问题难点
题目模型:对区间 [l, r] 的每个位置 i,加上一个首项为 A、公差为 D 的等差数列,也就是:
a[i] += A + (i - l) * D同时支持查询区间和。数据范围照例是 1e5,操作数量也是 1e5。
如果我们给每个位置都单独算首项,显然不能打统一懒标记。但仔细观察,*这个更新本质上是在区间上叠加一个一次函数 f(i) = A + (i-l)D。也就是说,更新到的每一个点,其真实增量可以写成关于位置 i 的线性函数。既然线段树每个节点都对应一个连续区间,那我们就可以把懒标记设计成“这个区间整体增加了一个一次函数”。
4.2 标记合并与下传的公式推导
设节点 p 对应区间 [l, r],当前有一个待下传的懒标记,表示区间内每个位置 i 都要增加:
tag_val(i) = k * i + b这里的 k 对应公差,b 是常数项。注意这种写法里位置 i 用的是全局下标,这样好处是合并子区间时不需要换元。但实际操作中,因为b的值会随区间左端点变化,很多人容易把符号搞混。
如果两次懒标记分别是 k1i + b1 和 k2i + b2,叠加后显然是:
(k1 + k2) * i + (b1 + b2)所以懒标记合并只需要两个加法,不用做任何乘除。这个结论对“ pushdown 到子节点”很重要:当一个节点把懒标记传给左孩子时,左孩子区间 [l, mid] 的所有位置 i 同样增加 k*i + b,所以直接加在孩子的 k 和 b 上即可;传给右孩子也不例外,因为公式里已经用了全局下标,右孩子区间 [mid+1, r] 照样套在图里。
但是要小心:节点维护的区间和怎么更新?假设当前节点区间是 [l, r],长度 len = r - l + 1,每个位置 i 增加 k*i + b,那么区间和增加:
Σ_{i=l}^{r} (k*i + b) = k * (l + r) * len / 2 + b * len这个公式在 update 和 pushdown 里都要用。稍有不注意,左孩子更新后可能忘记把同样是 k 的项带进去,导致区间和算错。
4.3 可参考的实现代码
struct Node { ll sum; ll k; // 公差 ll b; // 一次函数常数项 } tree[N << 2]; ll calc_sum(int l, int r, ll k, ll b) { ll len = r - l + 1; return k * (l + r) * len / 2 + b * len; } void apply(int p, int l, int r, ll k, ll b) { tree[p].sum += calc_sum(l, r, k, b); tree[p].k += k; tree[p].b += b; } void pushdown(int p, int l, int r) { if (tree[p].k == 0 && tree[p].b == 0) return; int mid = (l + r) >> 1; apply(p << 1, l, mid, tree[p].k, tree[p].b); apply(p << 1 | 1, mid + 1, r, tree[p].k, tree[p].b); tree[p].k = tree[p].b = 0; } void update(int p, int l, int r, int ql, int qr, ll A, ll D) { if (ql <= l && r <= qr) { // 当前区间整体加:首项 A,公差 D // 由于公式基于全局下标,直接 apply(k = D, b = A - D * l) ll k = D; ll b = A - D * ql; // 注意这里是用 ql 推导,不是用当前节点的 l apply(p, l, r, k, b); return; } pushdown(p, l, r); int mid = (l + r) >> 1; if (ql <= mid) update(p << 1, l, mid, ql, qr, A, D); if (qr > mid) update(p << 1 | 1, mid + 1, r, ql, qr, A, D); tree[p].sum = tree[p << 1].sum + tree[p << 1 | 1].sum; }注意一个细节:区间完全覆盖时,我直接用了b = A - D * ql。为什么不是A - D * l?
因为题目定义A是区间左端点 ql 位置的增量。对任意位置 i 而言,实际增量为:
A + (i - ql) * D = D * i + (A - D * ql)所以一次函数的常数项b必须基于真实的区间左端点 ql 来算,而不是基于当前线段树节点的 l。如果这里搞混,更新区间不是恰好和节点区间重叠时,就会产生系统性偏差。我当时第一次写就踩了这个坑,查了半天才发现是 b 算错了。
4.4 为什么一次函数标记很好用
这个例子的意义在于:很多看起来“不规则”的区间加法,本质都是某个低次多项式在区间上的叠加。一次函数是最简单的,如果题目变成区间加二次函数,做法完全同理,只是区间和的更新公式要从等差扩展到平方和公式。这也正是“线段树 + 数学”最核心的复利效应:你每多掌握一个公式,就能多解锁一类懒标记设计。
如果再配合后续的“二次函数前缀和”“调和级数预处理”,你会发现许多题目都是同一个套路:把修改操作映射为一个在位置上有闭式表达式的函数,推一下节点信息更新的公式,然后线段树照常跑。
5. 进阶方向:动态开点线段树与线段树套线段树
5.1 什么时候需要动态开点
做区间数学题时,有时值域特别大,比如 1e9,而且不是所有位置都会用到。这时如果开一棵满二叉树,内存直接爆掉。动态开点线段树的核心思想是:用多少节点才建多少节点,每个节点只有在被更新或查询访问到时才创建。
记录左右儿子的下标编号,而不是用p<<1、p<<1|1。这样一次单点修改会新建 O(log V) 个节点,V 是值域。区间加、区间求和的操作照常,只是每个节点多了两个 int 指针。
struct Node { int lc, rc; ll sum, lazy; } tr[N * 40]; int tot = 0, root = 0; void pushup(int p) { tr[p].sum = tr[tr[p].lc].sum + tr[tr[p].rc].sum; } void modify(int &p, int l, int r, int ql, int qr, ll val) { if (!p) p = ++tot; if (ql <= l && r <= qr) { tr[p].sum += val * (r - l + 1); tr[p].lazy += val; return; } int mid = (l + r) >> 1; if (ql <= mid) modify(tr[p].lc, l, mid, ql, qr, val); if (qr > mid) modify(tr[p].rc, mid + 1, r, ql, qr, val); pushup(p); }注意modify的第一个参数是引用,这是动态开点的关键,因为在递归过程中可能会创建新节点,必须把地址传回去。
5.2 线段树套线段树的逻辑框架
树套树一般出现在二维统计题里,比如平面 n 个点,支持单点修改权值,查询矩形区间内满足某个数学条件的点的个数。之所以套树,是因为单棵线段树只能管一个维度,要同时约束两个维度就得内外两层索引。
外层线段树按 x 坐标分治,每个节点内部再维护一棵动态开点的权值线段树,用于统计该 x 区间内不同 y 的出现情况。修改一个点 (x0, y0) 时,外层从根走到叶子,沿途每个节点都在它的内层线段树上对 y0 做一次单点更新,复杂度 O(log n) × O(log C),C 是 y 值域。
查询矩形 [x1, x2] × [y1, y2] 时,外层先找到所有覆盖 x 区间的 O(log n) 个节点,然后在每个节点的内层线段树上查询 y 区间内的和,累加结果。
代码模板大概长这样,但完整较短版本如下:
struct InnerTree { int ls, rs, sum; }; void inner_update(int &p, int l, int r, int pos, int val) { if (!p) p = ++tot_inner; tr_inner[p].sum += val; if (l == r) return; int mid = (l + r) >> 1; if (pos <= mid) inner_update(tr_inner[p].ls, l, mid, pos, val); else inner_update(tr_inner[p].rs, mid + 1, r, pos, val); } // 外层线段树,节点编号用 out[p] 指向 inner 的根 void outer_update(int p, int l, int r, int x, int y, int val) { inner_update(out[p], 1, MAX_Y, y, val); if (l == r) return; int mid = (l + r) >> 1; if (x <= mid) outer_update(p << 1, l, mid, x, y, val); else outer_update(p << 1 | 1, mid + 1, r, x, y, val); }用引用传递内层根下标时,要注意out[p]本身是 int,传入inner_update(out[p], ...)时要确保它是一个可修改的左值,否则 new 出来的节点会丢。
5.3 什么时候该放弃树套树
树套树的代码量不小,常数也大,调试难度高。如果题目允许离线,很多二维区间数学统计其实可以换成 CDQ 分治、树状数组套权值线段树、莫队二次离线等方案。我的个人经验是:
- 如果只涉及单点修改、矩形查询,并且强制在线,才考虑树套树。
- 如果能离线,优先想 CDQ 分治 + 树状数组,代码更稳。
- 如果值域不大,甚至可以二维前缀和的差分思路。
不要因为标题里有“树套树模板”就去硬背。真正比赛时,能用简单方法解决,就别给线段树套线段树加戏。
6. 现场翻车实录:线段树 + 数学题的常见坑
6.1 懒标记合并顺序和覆盖问题
很多人写区间加等差数列时,把k和b分开传,但 pushdown 时没有先把子节点的旧懒标记算进 sum,导致覆盖了旧标记。正确的做法是:apply 时先更新 sum,再叠加懒标记,不能先存标记后更新 sum,否则查询时子节点没有及时拿到上一层的增量。
另外,树套树的懒标记在多层结构里容易重复下传,建议每个节点都写一个pushdown,如果没有懒标记就立即返回。
6.2 公式里的除法与取整
等差数列求和公式和平方和公式里都有除以 2、除以 6,如果直接len * (len - 1) / 2,在 len 很大时先乘后除可能溢出 long long。稳妥的办法是先除以 2,或者用__int128中间运算。我见过很多次有人在这里爆负,排查半天才发现是溢出。
》 提示:如果题目里所有数都是正数,一旦线段树的 sum 变成负数,优先怀疑溢出,其次才是懒标记写错。
6.3 随机哈希的稳定性
用随机权值哈希做区间集合判定时,碰撞概率和随机数的质量直接相关。我在本地用mt19937_64生成权值,配合std::uniform_int_distribution<unsigned long long>,实际跑下来非常稳。但不要用rand(),它的 16 位随机数在哈希题里很容易被卡。还可以直接用两个不同的模数做双哈希,虽然代码更啰嗦,但安全性更高。
6.4 输入输出与卡常
涉及 1e5 级别的操作,cin/cout不关同步会拖累整体时间。我一般直接加:
ios::sync_with_stdio(false); cin.tie(nullptr);线段树节点如果开了 struct,尽量把sum, max, lazy, k, b这些字段按访问频率排序,缓存友好一点。对于动态开点,数组尽量开 4 倍之上,不要用 vector 动态扩容,比赛环境里 vector 的扩容开销很致命。
下面是我总结的快速排查表:
| 异常现象 | 可能原因 | 处理方式 |
|---|---|---|
| 区间查询结果偏小 | pushdown 没有更新子节点 sum | 在 pushdown 里先 apply 再清除懒标记 |
| 更新后区间和出现负数 | 公式溢出 | 中间过程用 __int128 或保证除法的先后顺序 |
| 树套树修改后数据丢失 | 内层根节点传参失败 | 确保inner_update第一个参数是引用 |
| 开根题在 1e18 数据下 WA | sqrt 精度不足 | 用sqrtl或手动二分整数根 |
| 等差数列判定误判 | 最小值和最大值不满足整除关系 | 先检查 (mx - mn) % (len - 1) == 0 |
| 哈希判断偶尔 WA | 随机权值碰撞或用了弱哈希 | 换mt19937_64,或改双哈希 |
6.5 数据对拍是最有效的调试方式
线段树 + 数学这类题,推导一旦有误,样例可能都能过,但大数据一上就原形毕露。我每次都会写一个小的暴力程序,生成随机数组和随机操作,然后和线段树程序对拍。几万组数据跑下来,只要有一组不一致,就能定位到哪个操作出了问题,再配合断点看节点的 sum 和懒标记,基本十几分钟内能找到 bug。
对拍的代码框架很简单:生成随机操作序列,分别跑暴力和线段树,逐一比较结果。很多新人觉得写对拍麻烦,但它在进阶题上的性价比真的高得离谱。
一点个人经验总结
做了这么多线段树 + 数学的题,我最大的体会是:题目越“硬核”,越要做足纸面功夫。拿到一道题,先不要想线段树怎么写,而是先在草稿纸上把修改操作用数学语言表达出来。如果它是一次函数,就推一次函数的合并公式;如果它是开根号取模,就证明一下势能下降有界;如果它是判断区间性质,就找一组必要条件并验证充分性。公式推导一旦成立,线段树的结构基本就是明牌,照着模板填就行。
如果你现在正在刷题,我建议把今天讲的三个典型例子的推导过程亲手抄写一遍:区间开根、等差数列判定、区间加等差数列。抄完之后再合上题解,重新实现一遍。这个过程虽然慢,但比刷十道水题都有用。希望这篇内容能让你在遇到线段树和数学碰撞的题目时,不再头皮发麻,而是有一种“让我来算算”的底气。