1. 前缀和,一道题吃透“O(1)区间求和”的底层逻辑
1.1 从烂大街的暴力求和到pre数组:一个反直觉的预处理
带算法集训带了这么多年,我越来越确信一件事:前缀和是很多人“以为自己会了,其实根本没会”的知识点。问起来都知道pre[i] = pre[i-1] + a[i],但真扔一道“连续子数组求和”的题过来,能五秒钟内写出最优解的人不超过三成。问题出在哪?出在大家记公式,不记公式背后的那个“为什么”。
先回到基础。给定一个长度为n的数组,要求任意区间[l, r]的元素和,你会怎么做?最朴素的做法是嵌套循环:外层枚举区间起点,内层累加区间元素。复杂度O(n²),n一大就完蛋。稍微有点优化意识的人会想到“打表”:开一个二维数组sum[i][j]存所有区间和,预处理O(n²),查询O(1)。这个方案看似聪明,但n稍微上升到10⁴就存不下了——n²的空间开销不是闹着玩的。
前缀和的做法完全绕开了这两个坑。它的预处理是一个O(n)的一维数组:
pre[i] = pre[i - 1] + a[i]pre[i]的含义是“原数组前i个元素的和”。有了这张表,任意区间[l, r]的和就是:
sum(l, r) = pre[r] - pre[l - 1]只做一次减法,O(1)拿到结果。这个式子为什么不是pre[r] - pre[l]?因为pre[l]已经把第l个元素也算进去了,减掉它等于把左端点一起丢了。区间左端点是闭区间,所以减去的一定是pre[l - 1]。
今天我是用一道LeetCode 303(区域和检索 - 数组不可变)作为开场题的。这道题简直是为前缀和量身定做的:数组固定不变,查询几百上千次,标准解法就是预处理pre数组,然后每次查询直接套减法。不夸张地说,这道题跑完,学员对前缀和的“区间查询只需要一次减法”这个直觉就建立起来了。
1.2 前缀和到底解决了什么问题:静态场景下的“查询碾压”逻辑
很多人会问:既然有差分、有树状数组,还有线段树,为什么还要学前缀和?答案很简单:在数据不修改的静态场景下,前缀和是区间查询的终极答案,没有任何结构在查询速度上能反超它——O(1)就是这个问题的理论下限。
前缀和的适用场景特征非常明确:
- 数组在预处理后不再变化(无修改操作,或修改次数少到可以忽略)
- 查询次数多(一次数据集通常要频繁、反复地查询)
- 查询内容是连续区间的某种可累加属性(和、乘积、异或等)
我把这个场景总结成一句大白话:一次预处理,终生查询。拿数值分析里的积分图来说,图像处理中的“滑动窗口区域像素和”就是二维前缀和直接铺出来的。为什么图像处理里框选区域求和那么快?因为先算好积分图,之后每个矩形区域查询都只是三次加减法的问题。框架可以千变万化,底层数学就是前缀和。
反过来说,如果数据会频繁修改,静态前缀和就显出原形了:改一个点,后面所有pre值都要重算,修改复杂度退化为O(n),一次改一次查就是O(n²)级别的死局。这是前缀和最大的边界,也是为什么我们后面要引出树状数组。学任何一个数据结构,先搞清楚它的能区和禁区,比会背模板重要得多。
1.3 3分钟手推一维前缀和的数值表:下标从1开始是算法世界的潜规则
集训时我习惯让学员先干一件“笨事”:手推一维前缀和的数值表,推完再上代码。这一步能过滤掉绝大多数公式失忆症。
比如有数组a = [3, 1, 4, 1, 5, 9, 2, 6],我们规定下标从1开始:
| 下标i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| a[i] | 3 | 1 | 4 | 1 | 5 | 9 | 2 | 6 |
| pre[i] | 3 | 4 | 8 | 9 | 14 | 23 | 25 | 31 |
pre[4] = 9,就表示原数组前4个元素3+1+4+1=9。验证一下区间求和:sum(3, 6) = pre[6] - pre[2] = 23 - 4 = 19。手算a[3]到a[6]也就是4+1+5+9=19,完全吻合。
这里有个细节值得展开:为什么算法题里普遍用下标从1开始?因为前缀和需要pre[0]这个哨兵。如果下标从0开始,pre[0]就是a[0]本身,计算区间[0, r]时得特判,烦得很。下标从1开始,pre[0]设为0,天然就解决了“左边界是数组头”的情况,所有区间查询统一变成pre[r] - pre[l - 1]一个公式,不再有边界特判。这是我从新手期就一直保持的习惯,也推荐大家写算法题时沿用。
代码也贴出来,虽然是C++阵营,但Python版逻辑更直白,适合先看思路再看实现:
# 下标从1开始,a[0]占位 a = [0, 3, 1, 4, 1, 5, 9, 2, 6] n = len(a) - 1 pre = [0] * (n + 1) for i in range(1, n + 1): pre[i] = pre[i - 1] + a[i] # 查询区间[l, r] def query(l, r): return pre[r] - pre[l - 1]这个预处理的复杂度是O(n),查询复杂度是O(1)。
1.4 一学就会的变形:前缀积、前缀异或和“同套路不同运算”
前缀和这个名字有迷惑性,容易让人觉得只能用来求“和”。其实前缀思想适用于一切满足结合律的运算——只要这个运算能通过“逆运算”撤销掉前段的影响,就能做区间查询。
前缀积是最自然的变形。定义preMul[i] = a[1] * a[2] * ... * a[i],区间[l, r]的乘积用preMul[r] / preMul[l - 1]就能得到。注意除法的前提是元素不为0,或者用模逆元(解决模意义下的除法)来处理。LeetCode 238(除自身以外数组的乘积)表面上不直接放前缀积,但解法核心就是“前缀积×后缀积”的双重预处理,思路同源。
前缀异或是我私下特别偏爱的变形,因为它有一个极其优雅的性质:异或的逆运算就是异或本身。这意味着区间[l, r]的异或和,直接就是preXor[r] ^ preXor[l - 1],不需要反向操作。LeetCode 1310(子数组异或查询)就是一道裸题。如果哪天有人问我“一个数组的区间异或和怎么快速算”,我脑子里蹦出来的结构就是前缀异或。
这些变形的共同点是:先预处理,再O(1)查询。区别只在于运算本身是否支持“减法”这种逆操作。训练时我往往会加一道综合题——比如“求区间最大值/最小值”就不适合直接前缀,因为max没有逆运算,这正是线段树和RMQ的舞台。学完前缀和,应该能分清楚哪些查询它能管、哪些管不了,这是进阶的第一道坎。
2. 树状数组:当“动态”两个字砸碎了前缀和的舒适区
2.1 从静态到动态:为什么修改一个数会让整个前缀表失效
前缀和最大的软肋就是“修改”。假设我们费了O(n)时间把pre数组算好了,此时来了个操作:把a[3]从4改成9。请问,pre数组哪些值需要动?
答案是pre[3]之后一共n-2个值全部要更新,因为每个pre[i]都包含a[3]的贡献。这就是把查询从O(n)优化到O(1)付出的代价——你攥住了查询的复杂度,却把修改的复杂度全部拖入了O(n)深渊。如果题目是“查询多、修改少”,前缀和是王道;如果题目是“修改和查询交替各来几万个”,那就必须上更高级的数据结构了。
我集训时举过一个比喻:前缀和像一本提前排好版的书,查询就像翻到指定页直接读,但每次修改内容,整本书的页码和目录都得重排。树状数组则像给每章做了一份“摘要卡”,修改时只更新相关章节的摘要,查询时拼装几张摘要卡就能得出答案,两边都轻巧。
“区间查询+单点修改”的经典场景有哪些?数组单元素更新后的前缀和查询、数据流中实时统计、频次统计表的动态维护、偏序问题中的扫描线……就连打比赛时经常遇到的“在线查询排行榜第k大”,在嵌套树状数组的结构里也是靠这层动态维护打底的。
2.2 树状数组的存储视角:tree[i]到底存的是什么
树状数组(Binary Indexed Tree,BIT)的发明者Fenwick在1964年发表论文的时候,恐怕都没想到这个结构四十年后依然是算法竞赛的必考点。它的设计极其精妙:用数组下标本身的二进制特性,隐式地描述一棵树。
关键概念是lowbit。lowbit(x) = x & (-x),含义是“x的二进制表示中最低位的1所对应的权值”。别用死记硬背的方式理解它,看几个例子:
- lowbit(6):6的二进制是110,最低位1在第1位(从0开始计),权值为2,所以lowbit(6) = 2。
- lowbit(8):8的二进制是1000,最低位1在第3位,权值为8,所以lowbit(8) = 8。
- lowbit(7):7的二进制是111,最低位1在第0位,权值为1,所以lowbit(7) = 1。
树状数组里tree[i]的定义是:tree[i] = a[i - lowbit(i) + 1] + a[i - lowbit(i) + 2] + ... + a[i],也就是以i结尾、长度为lowbit(i)的区间和。
tree[6] = a[5] + a[6],因为lowbit(6)=2,区间是[5,6];
tree[8] = a[1] + a[2] + ... + a[8],因为lowbit(8)=8,区间是[1,8];
tree[4] = a[1] + a[2] + a[3] + a[4],因为lowbit(4)=4,区间是[1,4]。 看着是不是有点“区块划分”的意思?整个数组被这些tree节点像俄罗斯方块一样交叠覆盖,但每个a[i]恰好被O(logn)个tree节点包含。这就是为什么单点修改只需要动O(logn)个节点,查询前缀和也只需要拼O(logn)个节点——一切源于二进制对全集的完美划分。
2.3 手推add(3, x):为什么下标是“每次加lowbit”而不是别的
理论再好,不手推一次就等于没学。我们用n=16的树状数组,手动模拟把a[3]增加x(单点修改add(3, x))的过程。
规则非常机械:从当前下标i开始,每次更新tree[i]之后,令i = i + lowbit(i),直到i超过n为止。初始i=3:
| 当前下标i | lowbit(i) | 需要更新的tree下标 |
|---|---|---|
| 3 | 1 | tree[3] |
| 4 | 4 | tree[4] |
| 8 | 8 | tree[8] |
| 16 | 16 | tree[16] |
所以add(3, x)一共需要更新tree[3]、tree[4]、tree[8]、tree[16]四个节点。问题来了:为什么跳过6、10、12这些看似离3更近的下标?
答案藏在tree的“管辖范围”里。我们回顾定义:tree[i]管理的是区间[i - lowbit(i) + 1, i]。tree[3]管辖[3,3],包含a[3];tree[4]管辖[1,4],包含a[3];tree[6]管辖[5,6],不包含a[3],所以它与a[3]无关。**包含a[3]的tree节点,才需要在修改a[3]时联动更新。**而“从3开始不断加lowbit”这个规则,恰好机械地枚举出了所有包含a[3]的tree节点,不多不少。
换个方向理解:从任意i出发,不断i += lowbit(i),相当于“跳到父节点”,而这个父节点的管辖区间必然包含子节点管辖的所有元素。这样树状数组的更新就变成了一条从叶子到根的通路,只是这条通路的跳跃步长由二进制决定。
代码实现:
void add(int i, int x, int n) { // 单点修改:a[i] += x while (i <= n) { tree[i] += x; i += i & (-i); } }2.4 手推query(11):前缀和是“拼积木”的过程
前缀和的查询和单点修改是对称操作,规则是:从下标i开始,每次把tree[i]累加到答案,然后令i -= lowbit(i),直到i变为0。还是用手推说话,求sum(11),也就是前11个元素的和:
| 当前下标i | lowbit(i) | 累加的tree节点 | 对应区间 |
|---|---|---|---|
| 11 | 1 | tree[11] | [11, 11] |
| 10 | 2 | tree[10] | [9, 10] |
| 8 | 8 | tree[8] | [1, 8] |
sum(11) = tree[11] + tree[10] + tree[8]。三个tree节点拼起来,正好覆盖[1, 11]这个连续区间,没有重叠也没有遗漏。这就是二进制分解的威力:11 = 8 + 2 + 1,树状数组把前缀和拆成若干个长度分别对应lowbit的块,每次查询恰好把这些块拼满。
你看,查询和修改是对称的两条规则,一个减lowbit一个加lowbit。很多初学者觉得这两个方向容易搞混,我的记忆法是:查询是从下往上找块,修改是从下往上通知——都是“向上”,只不过查询时每步递减当前位置,修改时每步递增当前位置。
代码:
int query(int i) { // 求pre[1..i]的和 int res = 0; while (i > 0) { res += tree[i]; i -= i & (-i); } return res; }有了单点修改和前缀和查询,区间和自然就是query(r) - query(l - 1),跟前缀和的套路完全一致,只是把O(1)的静态查询换成O(logn)的动态查询。这就是从静态前缀和到树状数组的“无缝升级”:思维方式不变,复杂度从O(1)放宽到O(logn),但换来的是修改也能O(logn)。
2.5 为什么这里必须先会前缀和,学树状数组才不懵
我访谈过不少学员,他们说学树状数组最容易卡住的地方是“这两个函数我背下来了,但根本不知道它在干嘛。”原因几乎都是:没先真正搞懂前缀和数组的物理意义。
你看,树状数组的本质就是“前缀和数组的分块压缩版”。你理解了pre[i]存的是a[1]到a[i]的累加,你就理解了tree[i]存的是“一段长度为lowbit(i)的区间和”;你理解了静态前缀和用pre[r]-pre[l-1]求区间和,你就理解了树状数组用query(r)-query(l-1)求区间和。唯一的差别只是:静态前缀和把全部信息压在一个表里,修改一次全表重算;树状数组把信息拆成稀疏的几个块,修改时只碰受影响的块。
所以讲授顺序必须是“前缀和 → 差分 → 树状数组”,不能跳。即便学员一开始只是为了应付考试,我也会反复强调:**先从一维静态前缀和建立“区间和就是两前缀之差”的直觉,再学树状数组的lowbit和块划分,你才能看见那棵隐形的树。**不然的话,你背了add和query两个函数,换一道题还是不会写。
2.6 树状数组如何用O(logn)完成区间求和:复杂度去哪了
有人会质疑:既然静态前缀和查询是O(1),树状数组查询是O(logn),那树状数组不是更慢吗?这个账必须算清楚:它不是更慢,是“用查询慢一点换修改变快”。
看一组对比:
| 操作 | 静态前缀和 | 树状数组 |
|---|---|---|
| 预处理 | O(n) | O(nlog n)(n次add) |
| 区间查询 | O(1) | O(log n) |
| 单点修改 | O(n) | O(log n) |
| 空间 | O(n) | O(n) |
| 适用场景 | 数据不变,查询密集 | 修改与查询交替频繁 |
静态数据下我绝不会用树状数组替代前缀和,杀鸡用牛刀;但一旦出现“每次修改一个点,马上查区间和”的动态题目,树状数组的O(logn)就远优于前缀和每次修改的O(n)。更好的消息是,树状数组的实现极其简洁,两个函数加起来不到十行,在竞赛、面试中是性价比最高的动态维护结构之一。
3. 实战视角:一道综合题如何从零到一选择前缀和还是树状数组
3.1 面试官想看到的“选型判断链”
集训第14天,我给学员出了一道综合题,先不公布题,先公布思考过程。题目是:设计一个数据结构,支持单点更新一个整数数组,以及查询子数组的和。
拿到这种题,如果只背过模板,很容易直接冲动上树状数组。但我会引导大家走一遍“判断链”:
- 先分析操作类型:有没有修改?如果没有任何修改,前缀和就是最优解,O(1)查询,没有任何结构比它更快。
- 再看修改和查询的比例:如果修改极少(比如不超过总操作数的10%),朴素前缀和维护也还能接受;如果修改和查询都很频繁,树状数组/线段树才上场。
- 最后考虑实现成本和空间:树状数组代码短、常数小,远优于线段树(代码量大、常数大);除非题目还需要区间最值、区间翻转等额外能力,否则树状数组是首选。
这就是为什么我把前缀和和树状数组放在同一天讲——它们不是替代关系,而是同一谱系下的两个阶段。一个合格的工程师或竞赛选手,看到题目的第一反应不该是“我会哪个算法”,而是“这个题的数据结构需求是什么”。
3.2 手写代码时的边界条件清单
光有思路不够,写代码时边界条件是坑的高发区。我整理了一份自查清单,集训时贴在自习室白板上,今天也分享出来:
- 下标从1开始:树状数组的0下标无意义,查询和修改到0必须停止,否则死循环。
- pre[0] = 0:前缀和数组必须初始化pre[0]=0(Python列表第0位占位),否则第一个元素的前缀和会算错。
- lowbit计算不要写成i - (i & -i)(修改时容易把符号写反):add是i += lowbit,query是i -= lowbit。
- 树状数组大小n+1:因为下标从1开始,tree数组必须能容纳到tree[n],所以开n+1个槽位。
- 区间查询的减法:sum(l, r) = query(r) - query(l - 1),千万别写成query(l)。
这些边界问题看着琐碎,但它们才是真实笔试/机试里扣分的重灾区。八成以上学员第一次提交树状数组代码都会在这里翻车,不是思路不对,是边界写错。
3.3 从手算到代码:一个完整的小型Case Study
我把集训时反复演示的一组完整数据搬出来。n = 16的数组,我们做三个操作:先add(3, 5)再query(11),再add(7, -2)再query(11)。
静态数组初始假设全为0。add(3, 5)之后,tree的哪些节点被更新?我们上面推过:tree[3]、tree[4]、tree[8]、tree[16]分别加5。现在query(11)按lowbit分解:tree[11] + tree[10] + tree[8]。tree[8]此时是5,tree[10]和tree[11]还是0,所以query(11) = 5。手算a[3]到a[11]这些值只有a[3]是5,别的都是0,确实和为5,正确。
add(7, -2)会更新tree[7]、tree[8]、tree[16](7 + lowbit(7)=8,8 + lowbit(8)=16)。此时tree[8] = 5 + (-2) = 3。再次query(11) = tree[11] + tree[10] + tree[8] = 0 + 0 + 3 = 3。手算a[3]=5,a[7]=-2,前11个元素和是3,正确。
**这里能看到树状数组的核心魅力:我们并没有把所有a[i]都存下来,而是实时在tree节点上做增减。每个修改只碰O(logn)个节点,每次查询只拼O(logn)个节点,数据和逻辑都对得上。**通过手推这两轮,学员基本就对树状数组的“分块拼装”有了一种肌肉记忆。
3.4 树状数组与线段树的简短对比:什么时候别用树状数组
顺便把集训时常被问到“为什么不用线段树”的问题一并回答了。树状数组的优点是:代码极短、常数极小、容易扩展。线段树的优点是:支持区间最值查询、区间修改(懒标记)等更泛化的操作。在只涉及“单点修改+区间求和”的动态场景下,我强烈推荐树状数组,原因只有一个:锻炼数理直觉,用最少的代码实现最高频的需求。
但如果你发现题目需要“区间取最大值+区间赋值”这种树状数组搞不定的操作,就别死磕了,果断转线段树。算法选型从来不是“谁更高级选谁”,而是“谁更匹配选谁”。前缀和、树状数组、线段树、分块,就像工具箱里不同尺寸的扳手,你不可能拿最小号扳手拧最大的螺丝,也不可能拿大号扳手去拧精密仪器。
4. 从集训第14天延伸出去:差分数组、二维树状数组和其他
4.1 预告差分数组:前缀和的“逆运算”如何实现区间加
静态前缀和解决“查询”,但我们还有一类同样常见的操作——区间修改:把a[l]到a[r]每个元素都加上v。朴素的实现是遍历这个区间,O(区间长度)的复杂度,如果修改很多次,照样爆炸。
差分数组就是为了区间修改而生的。定义diff[i] = a[i] - a[i - 1](令a[0]=0)。然后你会发现一件奇妙的事:对原数组做前缀和,等于对差分数组求前缀和;对原数组做区间修改[l, r]加v,等价于对差分数组做两次单点修改——diff[l] += v,diff[r + 1] -= v。原因也好理解,差分数组记录了相邻元素的“变化量”,一段连续的加v操作,只在端点处产生跳变。
这个思路我放到第15天专门展开,今天先埋个种子:**前缀和与差分是一对互逆操作,就像积分与微分。**掌握了这层关系,你就打通了“区间查询”和“区间修改”的任督二脉。之后再学的“树状数组+差分”组合(区间修改+区间查询),本质上就是把这套互逆关系搬进动态数据结构里。学员但凡今天把前缀和的“区间和=前缀和之差”吃透,明天学差分时基本就是两小时速通的节奏。
4.2 二维树状数组:把lowbit扩展到平面
解决了链状结构的一维动态问题,下一步自然会问:二维数组单点修改+子矩阵求和怎么动态做?答案是把一维树状数组扩展到二维。
二维树状数组的tree[i][j]不再是单个下标管辖的一段区间,而是“以(i, j)为右下角、宽度lowbit(j)、高度lowbit(i)”的子矩阵和。修改时,两个维度同时按lowbit跳:
void add2(int x, int y, int v, int n, int m) { for (int i = x; i <= n; i += i & (-i)) for (int j = y; j <= m; j += j & (-j)) tree2[i][j] += v; } int query2(int x, int y) { // 求左上角(1,1)到(x,y)的子矩阵和 int res = 0; for (int i = x; i > 0; i -= i & (-i)) for (int j = y; j > 0; j -= j & (-j)) res += tree2[i][j]; return res; }子矩阵查询同样用容斥:sum(x1, y1, x2, y2) = query2(x2, y2) - query2(x1 - 1, y2) - query2(x2, y1 - 1) + query2(x1 - 1, y1 - 1)。很多人直接背公式,但如果今天一维树状数组的“拼接块”直觉已经建立,二维的容斥就好理解了:你加的每一块都和坐标轴对应,减重了就加回来,和二维前缀和的容斥逻辑完全同源。这就是为什么我把二维前缀和和树状数组放在同一时期教——它们共享同一套空间划分直觉。
4.3 结合哈希表的经典延伸:处理“子数组和统计”类题目的三板斧
树状数组学的不仅是数据结构,更是“离线处理+前缀思想”的方法论。我额外给学员补充了一个高频套路:给定数组和目标,统计满足某种前缀条件的子数组数量,核心三步是:
- 计算前缀和(或前缀异或,视题目而定)
- 边遍历边用哈希表记录前缀值出现的次数(典型如LeetCode 560和为K的子数组)
- 每次查哈希表找满足条件的“另一半前缀值”(例如cur - k)
这个套路树状数组也能配合使用——当问题变成“统计前缀和小于等于某个值的个数”时,哈希表就不够用了,需要把“值域”离散化后用树状数组维护“每个前缀和值出现过几次”。这本质上就是动态逆序对、区间计数问题的通用解法。所以今天学的树状数组,本质上是一种可以跟你已有的任何“值域统计”想法组合的积木。
莱所谓的学习路径,就是把积木一块块垒起来:前缀和是积木底座,差分是反向积木,树状数组是动态积木,哈希表是统计积木。等到需要处理二维或不带修改的复杂查询时,你已经有足够的工具去搭建专属解法了。
4.4 那些被反复问到的“经典模板题”清单
集训第14天收尾时,我给了学员一份“必刷清单”——全是这个知识点的应用场景:
- LeetCode 303 / 304:一维/二维静态前缀和模板题,入门必刷
- LeetCode 560:前缀和+哈希表,统计类题型的代表
- LeetCode 1310:前缀异或,换个运算巩固概念
- LeetCode 307:单点修改+区间查询,树状数组的经典入门
- LeetCode 2836 / 315:偏序问题,树状数组配合离散化的进阶题
- POJ 2352 / 洛谷P3374:树状数组的竞赛级经典场
为什么要刷题?不是为了堆数量,而是为了在“不同的包装下”反复识别同一个想法。前缀和的本质是“预处理空间换时间”;树状数组的本质是“利用二进制划分维护动态前缀”。一个学员如果能把这几道题做完、做透、能给别人讲明白,他基本就完成了从“背模板”到“懂原理”的跨越。基础算法集训第14天,到这里才刚刚画上句号的七十个百分点,剩下的三成,留给明天的差分数组去点亮。