线段树与树状数组:区间查询与区间修改怎么选
很多同学学完数据结构的第一反应是:”线段树是万能的,区间问题全上线段树就行。”
这个判断在 CSP-J T3 也许能拿满分,但在 CSP-S T3/T4 上,一份常数过大的线段树会直接 TLE。而换成树状数组,同样的题代码量只有三分之一。
这篇文章不打算罗列两者的功能清单,而是拿同一道题——“区间加 + 区间求和”——分别用树状数组和线段树写一遍,让你亲眼看到:同一个任务,两种数据结构的代码量和常数差多少,以及为什么。
问题背景
我们维护一个长度为 n 的数组 a,初始全为 0,需要支持两种操作:
add(l, r, v):把区间 [l, r] 内每个数加上 vquery(l, r):查询区间 [l, r] 的和
n = 10^6,q = 10^5。这是一道标准的”区间修改 + 区间查询”题,正好用来对比两种数据结构。
先说清楚复杂度目标:这两种操作都要做到 O(log n)。如果一次操作是 O(n),q = 10^5 就是 10^11 次运算,必挂。
思路
第一步:树状数组到底能做什么
树状数组(Binary Indexed Tree)的核心只有一句话:用 lowbit 维护前缀和,让”单点修改”和”前缀查询”互为对方的逆操作。
lowbit(i) = i & (-i),即 i 的二进制最低位 1 对应的值。比如 lowbit(12) = 4,lowbit(7) = 1。
树状数组的定义是:tree[i] 存储的是 a[i - lowbit(i) + 1 ... i] 这一段的和。
有了这个定义,两个操作就非常自然了:
前缀和 sum(x):不断累加 tree[x],然后 x -= lowbit(x)。
为什么正确?以 x = 13 为例:13 的 lowbit 是 1,所以取 tree[13](管 a[13]),然后 x = 12,lowbit(12) = 4,取 tree[12](管 a[9..12]),然后 x = 8,lowbit(8) = 8,取 tree[8](管 a[1..8]),x = 0 结束。这三段不重不漏地拼成了 a[1..13]。
1 | for (int i = x; i > 0; i -= i & (-i)) res += tree[i]; |
单点修改 add(x, v):不断让 x += lowbit(x)。
为什么正确?把所有”管辖范围包含位置 x”的结点都更新一遍。tree[13]、tree[12]、tree[8] 恰好就是那些。沿 lowbit 往上跳不会漏。
1 | for (int i = x; i <= n; i += i & (-i)) tree[i] += v; |
区间和:前缀和相减,sum(r) - sum(l - 1)。O(log n)。
到这里为止,一切都很干净。树状数组的强项是:单点改 + 前缀和 = O(log n),区间和是白送的减法。
第二步:为什么”区间修改”用树状数组很别扭
现在问题来了:如何支持 add(l, r, v)?
直觉想法是:把 [l, r] 里的每个元素都单点修改一遍。那是 O(n log n),白做了。
经典做法:开两棵树状数组(也叫”差分 + 树状数组”)。
先在脑子里把 a 数组做一次差分:d[i] = a[i] - a[i-1](a[0] = 0)。
差分有个漂亮的性质:区间加变成了两次单点修改。
因为 a[l..r] 每个数都加 v,等价于 d[l] += v,而 r+1 处要抵消:d[r+1] -= v。
就像一排身高,1 号到 5 号都长高了 5cm,那么第 1 人的”身高差”多了 5,第 6 人的”身高差”就少了 5。
于是我们只要用两棵树状数组分别维护 d[i] 和 i * d[i]。
关键推导:原数组的前缀和
1 | sum(x) = a[1] + a[2] + ... + a[x] |
注意最后一步是 x+1 而不是 x。 这是这个公式最容易错的一个字符:x - j + 1 拆成 (x+1) - j,多出来的那个 1 藏在系数里。写成 x * Σd[j] - Σj·d[j] 会恒定少算 Σd[j],用上面的例子一测就露馅。
所以维护两棵 BIT:一棵存 d[i],一棵存 i*d[i]。区间和就是两次前缀查询相减:
1 | query(l, r) = calc(r) - calc(l-1),其中 calc(x) = (x+1) * sum(bit1, x) - sum(bit2, x) |
这里的坑(学生最容易错的地方):
- 坑 1:必须开 long long。
i * d[i]里 i 最大到 10^6,d[i] 的绝对值可能累加到很大,乘积轻易超过 int。而且(x+1) * Σd[j]这一项会达到n^2 * v的量级(10^12 × v),int 溢出是必然的。 - 坑 2:r = n 时不需要
d[n+1] -= v。 但你的 BIT 循环是i <= n,如果你不加判断而写了for (i = r+1; i <= n; ...),在 r=n 时循环体自然不执行,是安全的;但如果你为了别的理由写了add(r+1, ...)且没有上界判断,就会越界。 - 坑 3:区间和公式的符号容易搞错。 建议把它封装成一个
prefixSum(x)函数,然后rangeSum(l,r) = prefixSum(r) - prefixSum(l-1),把易错的部分只写一遍。 - 坑 3.5:别把
x+1写成x。 见上面的推导。用n=3、add(2,3,5)手算一遍:a = [0,5,5],sum(3) = 10。此时只有d[2] = 5,所以Σd[j] = 5、Σj·d[j] = 10,(3+1)*5 - 10 = 10✓;写成3*5 - 10 = 5✗,恰好少算了Σd[j] = 5。 - 坑 4:中途插入一个数导致后面所有下标平移 → 所有 d[i] 都变了,BIT 直接失效。这就是下面要讲的坐标压缩要解决的问题。
第三步:线段树的三类操作与懒标记
线段树的结点不再对应 lowbit 区间,而是对应一个明确的左右子树区间 [l, r],mid = l + (r-l)/2。每个结点维护一个”信息”(区间和、区间最值等)。
线段树的三类基本操作:
| 操作 | 描述 | 时间 |
|---|---|---|
| 单点修改 | 走到叶子,改叶子,回溯更新 | O(log n) |
| 区间查询 | 把 [l, r] 拆成若干完整结点,取信息合并 | O(log n) |
| 区间修改 | 命中完整结点就”打标记”返回,部分相交就递归 | O(log n) |
为什么区间查询是 O(log n)? 因为 [l, r] 在任何一层最多只有 2 个结点是”部分相交”的(一个在中点左边,一个在中点右边),其余都是完整命中并立即返回。所以访问的结点总数是 O(log n) 级别,不是 O(n)。
懒标记(lazy tag)的原理:
朴素做法是”区间改 = 把区间内每个叶子都改一遍”,O(n)。但注意:如果一个结点管辖的整个区间都要加 v,那么它的所有后代在逻辑上也都加了 v。既然我们暂时不需要立刻知道每个叶子的值,就可以只在这个结点记一笔账 lazy += v,然后直接返回,不往下走。
查询的时候,如果需要进入一个带 lazy 的结点的孩子,就要把这笔账”下推”:
1 | void pushdown(int p) { |
这里有一个最容易错的细节:下传给左孩子的是 lazy * 左区间长度,不是 lazy 本身。因为结点存的是区间和!如果结点存的是最值,那才直接传 lazy。
一句话总结 pushdown 的意义:保证”什么时候访问孩子,什么时候把父亲的账结清”,这样结点值永远是最新的。
第四步:同一道题,两份代码对比
任务:add(l,r,v) + query(l,r)。
树状数组版(两个 BIT)核心只有四个函数:
1 | // 在差分数组的 x 处加 v(等价于原数组 a[x..n] 全部加 v) |
线段树版需要 build(建树)、pushdown(下传懒标记)、update(区间加)、query(区间查)四个函数,而且 update 和 query 都必须正确处理「完全命中/不相交/只递归左孩子/只递归右孩子/递归两个孩子」五种情况。
代码量对比(含注释与空行):树状数组约 40 行,线段树约 75 行。差距主要来自:递归的双参数边界传递、pushdown、build,以及那几种递归分支的组合。完整可提交代码见「参考代码」一节。
第五步:内存与常数的真实差距
这是学生最关心的”到底差多少”,我给你量化的结论(n = 10^6,long long):
| 树状数组(2 棵) | 线段树 | |
|---|---|---|
| 数组大小 | 2 × 8n = 16 MB | 2 × 8 × 4n = 64 MB |
| 递归深度 | 0(无递归) | ~20 层 |
| 单次区间改 | 约 2 × log n 次循环,无函数调用 | 约 4log n 个结点访问,含递归调用 |
| 单次区间查 | 约 2 × 2log n 次循环 | 约 4log n 个结点访问 |
内存:线段树是树状数组的 4 倍。这在 128MB 限制下可能直接超了(64MB + 其他数组),而树状数组只占 16MB 非常安全。
常数:线段树每次操作有递归函数调用的开销(栈帧压栈、传参、返回)。经验值是树状数组比线段树快 2~4 倍。在 CSP-S T4 那种需要卡常的场景,这个差距就是 AC 和 TLE 的差别。
所以结论是:只要树状数组能做,就用树状数组。 只有当树状数组做不到时,才上树状数组的扩展或线段树。
第六步:坐标压缩在区间修改问题里的作用
回到坑 4:如果操作里有”在位置 p 插入一个数”或者”值域可能达到 10^9”,怎么办?
值域很大 → 离散化。 离线读入所有操作,把所有出现过的位置/坐标收集起来排序去重,映射到 1..k。区间加区间和只关心”哪些位置被覆盖”,不关心坐标的实际大小,所以离散化完全无损。前提:所有操作必须离线读入(这就是为什么这类题常常不能在线)。
有插入操作 → 平衡树 / 树套树 / 离散化 + “插入转赋值”。 典型做法:把插入操作离线收集,最终确定每个元素的真实位置(因为每个位置被插入的时刻是已知的),然后按最终顺序离散化;插入本身用”赋值”处理(该位置此前是空的,相当于 0),插入前的所有操作实际上不影响这个位置。这种”离线确定位置 + 在线按时刻赋值”的手法在 NOI 级题目里非常常见。
注意:离散化会改变 lowbit 的分布,所以离散化之后必须重新构建树状数组或线段树,不能在原结构上改。
第七步:什么时候必须用线段树
以下场景,树状数组做不到或代价过高,应该用线段树:
- 维护区间最值:区间加、区间最大值。差分对 max 没有可加性——Σd[i] 变成 max 完全没有意义。带懒标记的线段树是标准解法。
- 维护”有结合律但差分后丢失语义”的信息:区间 gcd、矩阵乘法、字符串哈希(区间合并 hash = h_left * base^len_right + h_right)、区间异或(异或其实可以 BIT 维护)等。差分只对”可加/可减”的代数信息有效。
- 需要区间修改 + 复杂合并:区间赋值 + 区间求和(注意这也不能用差分 BIT 简单做,因为赋值的差分形式与位置有关,必须记录修改点数量)。
- 在线算法:题强制在线(
^ last_ans强制解密)时无法离线离散化,线段树更通用。 - 需要维护修改历史 / 可回滚:如”区间加 + 区间历史最值”,只能线段树挂时间维度。
选型口诀:先问自己”这个问题能不能写成差分/前缀形式”。能 → BIT(还能省一半常数和时间);不能 → 线段树。
正确性
以线段树模板为例说明为什么它是对的:
- build:叶子存 a[i],非叶子存左右孩子之和。由归纳法,任意结点
sum等于其区间内元素之和。初始全 0 的场景可以省略 build。 - update(p, l, r, L, R, v):
- 若
[l,r] ⊆ [L,R],执行sum[p] += (r-l+1)*v; lazy[p] += v; return;。此时逻辑上区间内每个元素确实都加了 v,且该信息已记录(sum 立即正确,lazy 记录待下传的部分),正确。 - 若不相交,return,不影响。
- 否则先 pushdown(把祖先欠的账结清,使孩子值可信),再递归左右孩子,最后
sum[p] = sum[2p] + sum[2p+1]。由归纳,两个孩子正确则父亲正确。
- 若
- query(p,l,r,L,R):不相交返回 0;完全覆盖返回
sum[p](此时该结点的账已由 update 保证正确,部分覆盖时也经过 pushdown);否则 pushdown 后递归求和。由归纳,返回值等于 [L,R] 内的真实元素和。 - pushdown 的必要性:若不 pushdown 就去读孩子的
sum,读到的是过期值。pushdown 后孩子sum与lazy恰好反映父亲懒标记的全部内容,且父亲lazy归零(防止重复下传),所以不变量”每个结点的 sum 永远包含它自己及所有祖先未下传的贡献”始终成立。
树状数组版正确性由前面第二节的推导给出:sum(x) = (x+1)·Σd[j] − Σj·d[j],两次差分修改(d[l] += v、d[r+1] -= v)等价于一次区间加。
复杂度
| 操作 | 树状数组(2棵) | 线段树 |
|---|---|---|
| 单点加 | O(log n) | O(log n) |
| 区间加 | O(log n) | O(log n) |
| 前缀和 | O(log n) | O(log n) |
| 区间和 | O(log n) | O(log n) |
| 空间 | 16n 字节 | 64n 字节 |
| 实测常数 | 基准 1× | 约 2~4× |
两者渐进复杂度相同,差别在常数与内存。这是典型的”选对了就过、选错了就 TLE”的场景。
参考代码(C++17)
线段树模板:区间加 + 区间和 + 懒标记,可直接提交。
1 |
|
对照:同一道题用两个树状数组怎么写
1 |
|
对比一下:线段树版 75 行(含注释),树状数组版 40 行。这就是”能翻译就用 BIT”的直接收益。
易错点清单
int mid = (l + r) >> 1在 l、r 接近 int 上界时会溢出。 统一写int mid = l + (r - l) / 2;。本文所有代码都已按此写法。- 懒标记下传时忘记乘区间长度。 结点存区间和,就必须乘;结点存最值(max/min),才直接加。
- 下传后忘记把
lazy清零。 导致同一笔账被重复下传,答案成倍放大。 sum和lazy数组开成4n太小。 至少4*n + 8。- 全部用
int。 区间和、i*d[i]、(x+1)*Σd[j]全部可能爆 int,一律long long。 - 两个 BIT 写成
int数组。 同上,t1、t2都必须是long long。 - 树状数组的
addDiff(r+1, -v)在 r = n 时越界。 用for (i = x; i <= n; ...)的写法自然规避,但如果你手动访问tree[r+1]就会出事。 - 递归里忘记不相交判断。 建议在递归函数开头写上
if (R < l || r < L) return;,比if (L <= mid)这种分发写法更不容易漏。 - 离线离散化后忘了重建数据结构。 离散化改变的是整个坐标体系,必须重建。
- 在线题强行用离线思路。 带强制在线(
^ last_ans)的题目不能排序询问、不能离散化,只能上线段树。
常见误区
误区一:”线段树万能,树状数组太简单没前途。”
恰恰相反,树状数组在大量题目里都是更优解,因为省内存、省常数。NOI 级别题目里”用线段树 TLE,换树状数组 AC”的案例非常多见。
误区二:”树状数组只能做前缀和。”
加上差分,它可以做区间和、区间加、甚至二维树状数组(单点改 + 矩形和)。但它做不了”区间 + 区间”以外的组合——这是它的边界。
误区三:”懒标记很难理解,绕开它只用树状数组。”
理解懒标记的最好方式是记住那个不变量:访问任何结点前,它的所有祖先标记必须已经下传。所以 pushdown 只在”部分相交”时调用,完全命中直接返回——这条规则本身就把懒标记的理解难度降下来了。
一句话总结:先用差分思维把问题翻译成”单点改 + 前缀查”,能翻译就用树状数组(快 2~4 倍、省 4 倍内存);翻译不了(最值、复杂合并、在线)才用线段树。