☰
前缀数组从原理到实战:区间求和与树状数组的取舍
2026/10/12 5:29:07 网站建设 项目流程

提到"前缀数组",很多人的第一反应是LeetCode入门题里那个prefixSum[i] = prefixSum[i-1] + nums[i]。但真正把它用明白、用出价值的人,其实不多。前缀数组(也叫前缀和)本质上是一种"预处理换查询"的思路:一次性建表,换来任意区间求和从O(n)降到O(1)。这篇文章我想从原理讲到实战,再聊到它的软肋——特别是和树状数组的关系,用具体的n=16的例子把sum(11)、add(3,x)这类操作说透。适合刚学算法的同学建立整体认知,也适合写业务代码的工程师在遇到区间聚合统计时多一个顺手的选择。

1. 前缀数组的核心价值:把O(n)区间查询降成O(1)

1.1 一个最常见的场景:多次区间求和

假设你有一个长度为n的数组,现在要回答m次询问,每次给一个区间[l, r],求这个区间内所有元素的和。

最朴素的做法是每次询问都遍历区间:

def range_sum(nums, l, r): total = 0 for i in range(l, r + 1): total += nums[i] return total

单次询问是O(n),m次询问就是O(n*m)。当n和m都到10^5级别,这个复杂度在比赛中基本会被卡死,在业务里也就是慢查询。

而用前缀数组,构建过程是O(n),之后每次查询是O(1)。也就是说,把大量时间花在一次性预处理上,后续的每次查询都变成两次数组访问加一次减法。用空间换时间,这在数据量大的场景下收益非常明显。

1.2 "前缀"这个名字到底指什么

前缀,就是"从数组开头到某个位置"这一段。对于原数组a[0], a[1], ..., a[n-1],前缀数组pre[i]表示a[0] + a[1] + ... + a[i],也就是从起点到下标i的所有元素之和。

举个例子。原数组:

下标01234
a25183

对应的前缀数组:

下标01234
pre2781619

可以看到pre[3] = 2 + 5 + 1 + 8 = 16,就是从开头到下标3的累加结果。每一个pre[i]都是"一路从头加过来"的部分和,这就是"前缀"二字的由来。

1.3 核心公式:区间和等于前缀数组两项相减

这是整个前缀数组的灵魂:

$$\text{sum}(l, r) = pre[r] - pre[l-1]$$

为什么成立?因为pre[r]是a[0]到a[r]的和,pre[l-1]是a[0]到a[l-1]的和,两者相减,正好把a[0]到a[l-1]的部分抵消掉,剩下的就是a[l]到a[r]。

如果l = 0,那pre[l-1]就是pre[-1],为了避免处理负索引,要么单独判断,要么在建表时把前缀数组做成n+1的长度,让pre[i+1]表示前i个元素的和。后一种做法更干净,后面我会详细讲。

注意:区间求和不是前缀数组唯一能干的事,"前缀"是一种思维方式,可以推广到异或、乘积、计数统计等场景,后面有专门一节展开。

2. 建表与查询:最小可运行的代码与边界细节

2.1 递推建表的一行核心代码

前缀数组的构建非常自然,就是递推:

# 原数组 a = [2, 5, 1, 8, 3] n = len(a) # 前缀数组,pre[i] 表示 a[0] 到 a[i-1] 的和 pre = [0] * (n + 1) for i in range(1, n + 1): pre[i] = pre[i - 1] + a[i - 1]

这里pre的长度是n+1,pre[0] = 0是哨兵。为什么要在前面垫一个0?因为这样pre[i]就可以统一表示"前i个元素的和",查询区间[l, r]时用pre[r+1] - pre[l],不需要单独处理l=0的情况。

这个递推式的意思很直白:前i个元素的和 = 前i-1个元素的和 + 第i个元素(下标i-1)。它不是什么高深的数学结论,就是一个累加过程的缓存。

2.2 查询区间的下标换算

用上面的pre结构查询[l, r],区间和是:

def range_sum(pre, l, r): # l 和 r 是原数组下标,0-based return pre[r + 1] - pre[l]

对应例子里,查[1, 3](元素为5 + 1 + 8 = 14):

range_sum(pre, 1, 3) # pre[4] - pre[1] = 19 - 2 = 17? 等一下

哎,这里我算一下。例子里a = [2, 5, 1, 8, 3],pre长度6:

i012345
pre[i]02781619

查[1, 3]就是pre[4] - pre[1] = 16 - 2 = 14,正确。因为前4个元素是2+5+1+8=16,前1个元素是2,相减得5+1+8=14。

这里最常见的坑就是下标差一。我的习惯是:建表时pre[i]表示前i个元素的和,查询时统一用pre[r+1] - pre[l]。只要代码里到处都遵守这一个约定,就不会混乱。最怕的是有时候用"前i个"有时候用"到下标i为止",很容易出错。

2.3 一种常见的偏移约定:1-based

竞赛圈习惯直接把原数组也改成1-based,即下标从1开始,读入时存到a[1]到a[n]。这样前缀数组pre[i] = pre[i-1] + a[i],查询[l, r]用pre[r] - pre[l-1]。

两种写法本质是一样的,选哪种取决于你后续要混合什么操作。如果只是单纯给数组做前缀和,用 Python 的 0-based 加n+1长度就很清晰;如果后面要接树状数组、差分数组之类的进阶结构,1-based 往往更顺手,因为树状数组的下标从1开始是刻在骨子里的。

我自己在比赛里更常用1-based,在写业务脚本处理数据时更常用0-based。选择标准就一条:和你要搭配的其他代码保持同一个约定。

3. 不只是求和:前缀思想的高频扩展用法

3.1 二维前缀和:子矩阵求和

一维前缀和解决的是线段求和,二维前缀和解决的是矩形求和。核心思路一样,只是变成二维递推:

# grid 是 m 行 n 列的矩阵 prefix = [[0] * (n + 1) for _ in range(m + 1)] for i in range(1, m + 1): for j in range(1, n + 1): prefix[i][j] = (prefix[i-1][j] + prefix[i][j-1] - prefix[i-1][j-1] + grid[i-1][j-1])

查询左上角(r1, c1)到右下角(r2, c2)的子矩阵和:

total = (prefix[r2+1][c2+1] - prefix[r1][c2+1] - prefix[r2+1][c1] + prefix[r1][c1])

这个公式的来源是容斥原理:prefix[r2+1][c2+1]是大矩形的和,减掉上面的长条,减掉左边的长条,但左上角那块被减了两次,所以要加回来一次。面试里考察二维前缀和的题不算少,LeetCode 304 就是典型的例子。

3.2 前缀异或和:区间异或与子数组性质

求和可以前缀,异或同样可以前缀。因为异或运算自带"自反性":x ^ x = 0,所以区间异或结果可以直接用两个前缀异或值相异或得到。

# 前缀异或 px = [0] * (n + 1) for i in range(1, n + 1): px[i] = px[i - 1] ^ a[i - 1] # 区间 [l, r] 的异或和 xor_result = px[r + 1] ^ px[l]

这个技巧在处理"找出数组中哪两个数的异或最大"之类的问题时很关键,配合字典树可以做很多经典题目。它的本质还是前缀思想:把区间查询转化为两个前缀结构的运算。

3.3 差分数组:前缀数组的逆运算

如果说前缀数组是"由原数组生成累积数组",那么差分数组就是"由原数组生成相邻差值数组",是前缀的逆操作。

# 差分数组 diff = [0] * n diff[0] = a[0] for i in range(1, n): diff[i] = a[i] - a[i-1]

差分数组的价值在于区间修改:如果要对[l, r]统一加上x,只需要diff[l] += x; diff[r+1] -= x,然后再求一次前缀和就能还原出修改后的数组。

我在实际工程里用它处理过"一段时间内给一批订单批量改价再统计汇总"的场景:把区间修改转成两个端点的标记,最后一次性前缀求和得到每个元素被加了多少。这种思路在数据量大的时候能省掉大量循环。

4. 前缀数组的短板:静态是主场,动态是软肋

4.1 单点修改之后,麻烦来了

前缀数组最大的硬伤:一旦原数组发生修改,前缀数组就需要大范围更新。

具体来说,如果修改了a[k]的值,那么所有包含a[k]的前缀和,也就是pre[k+1], pre[k+2], ..., pre[n]全部要重新计算。一次修改的代价是O(n)。如果业务场景是"查询很多、修改很少",那前缀数组非常理想;但如果修改和查询一样频繁,每次都重建前缀数组,总体复杂度又回到O(n*m),和不用它没什么区别。

我之前处理过一个监控指标存储的需求:每分钟上来一批数值,查询端要看任意时间窗口的总量,同时数据本身会有延迟修正,也就是会修改已有位置的数值。这种情况下纯前缀数组就撑不住了,每来一个修正都要更新后面的所有位置,性能瓶颈非常明显。

4.2 动态场景的替代:从朴素更新到树状数组

要支持单点修改和区间查询同时频繁发生,有几种常见的进阶结构:

结构单点修改区间查询适用场景
朴素数组O(1)O(n)查询少
前缀数组O(n)O(1)修改极少
树状数组(Fenwick Tree)O(log n)O(log n)动态且要求实现简单
线段树O(log n)O(log n)动态且需要区间更新/复杂查询

树状数组关键的设计是利用lowbit把一个位置的信息分散存储在若干"管辖区间"里,让每次修改只需要向上更新O(log n)个节点,每次查询只需要向下累加O(log n)个节点。它和前缀数组是同一个思想家族,只是加上了动态维护能力。

4.3 什么地方仍然该选前缀数组

看到动态场景要换结构,别急着否定前缀数组。很多实际场景里数据是只读的,或者修改频率极低,前缀数组就是最优解。

比如数据分析里的累计报表:统计每日新增用户数,最后生成"截至任意日期的总用户数",这种数据一旦落库就不再变,直接构建前缀数组,之后无论多少查询都是O(1)。再比如离线算法题:输入全部给定,没有修改操作,那前缀数组就是最简单的满分方案。

我个人选型习惯是先问一个问题:数据在查询过程中会不会变?不会变,用前缀数组;会变但修改很少,可以在修改时局部更新或定期重建;修改很频繁,再上树状数组或线段树。

5. 前缀数组和树状数组:从一份 n=16 的序列说起

5.1 同一个前缀思想,两种维护方式

为了说清楚区别,我拿一份具体的序列来演示。设n = 16,数组下标从1到16,我们要支持两个操作:

  • 查询sum(11):求下标1到11的和。
  • 单点修改add(3, x):给下标3的值加上x。

如果用前缀数组,构建时维护一个长度n+1的pre,查询sum(11)就是直接取pre[11],O(1)。但一旦执行add(3, x),下标3的值变了,那pre[3]到pre[16]全部要加上x,一共14个位置要更新。

如果用树状数组,维护一个同样长度n+1的树状数组bit,先通过add操作把原数组每个位置的值构建进去:

for i in 1..n: tree.add(i, a[i])

然后:

  • add(3, x):从下标3开始,i += lowbit(i)逐层向上更新。下标变化是 3 -> 4 -> 8 -> 16,一共4个位置。在n=16的情况下是4次更新,在更大的n下就是O(log n)次。
  • sum(11):从下标11开始,i -= lowbit(i)逐层累加。下标变化是 11 -> 10 -> 8 -> 0,共3个位置,返回的就是前11项之和。

这就是树状数组的精髓:单个元素的信息被折叠进一棵"二进制索引树"里,查询和修改都只需要沿着二进制位的路径走,而不是从头走到尾。

5.2 树状数组的代码怎么落地

树状数组核心就两个函数,其他都是围绕它们转:

class FenwickTree: def __init__(self, n): self.n = n self.bit = [0] * (n + 1) def add(self, idx, delta): # 单点修改:更新 idx 及其所有祖先 while idx <= self.n: self.bit[idx] += delta idx += idx & -idx # idx & -idx 就是 lowbit def prefix_sum(self, idx): # 前缀和查询:累加 idx 及其所有"左兄弟" res = 0 while idx > 0: res += self.bit[idx] idx -= idx & -idx return res def range_sum(self, l, r): return self.prefix_sum(r) - self.prefix_sum(l - 1)

idx & -idx取的是idx二进制最低位的1所代表的值,也就是lowbit。不理解二进制也没关系,记住它等于idx能被2整除的最多次数对应的2的幂即可。

具体看n=16的例子。查sum(11):

idx = 11, lowbit(11) = 1, 累加 bit[11] idx = 10, lowbit(10) = 2, 累加 bit[10] idx = 8, lowbit(8) = 8, 累加 bit[8] idx = 0, 停止

总共访问3个节点。手动验证时,bit的每个节点覆盖的区间长度恰好是它的lowbit:bit[8]覆盖[1..8],bit[10]覆盖[9..10],bit[11]覆盖[11..11]。三段合起来正好是[1..11],一个不多一个不少。这种区间划分方式是理解树状数组的关键。

5.3 两者取舍:为什么不能无脑用其中一个

前缀数组的优点是简单、常数极小、支持O(1)查询。缺点是修改代价高。树状数组的优点是把修改和查询都平衡到O(log n),缺点是代码稍复杂,常数也比直接取前缀数组大。

如果是一场比赛,出题人确保没有修改、只有查询,那写前缀数组拿满分,不需要考虑树状数组。如果修改和查询都在10^5级别,树状数组是首选,因为O(n)的修改完全不可接受。如果还要支持区间加、区间乘之类的复合更新,树状数组可以通过维护多个辅助树实现,或者直接上线段树。

从"凡是前缀和的题都往树状数组上套"不是一个好习惯。能用前缀数组解决的问题,用树状数组是杀鸡用牛刀;反过来,强行用前缀数组处理动态问题,则是拿冷兵器去防空。

6. 实操中容易踩的坑与我的使用习惯

6.1 建表时最常见的三类错误

第一类是数组长度的边界搞错。很多人写pre = [0] * n,然后循环for i in range(1, n),最后查询pre[r] - pre[l-1]时下标越界或是结果少了一个元素。我建议直接统一成n+1长度,宁多一个哨兵位,也不要让代码里出现if l == 0这种特殊情况。

第二类是修改操作之后忘记同步。在用前缀数组时,如果中间做了一次单点修改,很多人只改了原数组忘了改pre,结果后续查询全部错误。这个问题排查起来特别隐蔽,因为改完数据跑一遍,错得毫无规律。我的做法是封装一个更新函数,禁止在业务代码里直接改原数组。

第三类是区间定义不一致。有人用闭区间[l, r],有人用左闭右开[l, r),在写前缀和查询时混用就会差一。我自己的规矩是:代码里所有区间统一写成闭区间,配合n+1长度的pre,查询用pre[r+1] - pre[l],注释里标清楚"闭区间"三个字。注释真的能救命,尤其是过两周自己回来看代码时。

6.2 数值溢出要注意

前缀和是把大量元素累加在一起,如果原数组是int且数值范围较大,前缀和很容易超过单元素的范围。在 C++ 里int很容易溢出,要用long long。在 Python 里整数没有溢出问题,但在数值特别大的时候性能会下降,而且如果面向的是某些强类型语言,比如用 Java 写 LeetCode,也要注意用long。

判断是否可能溢出的经验法则是:看sum(nums)的数量级。所有元素都是正数且数据范围接近10^9、长度接近10^5,那和就到10^14了,int肯定不够用。直接无脑用64位类型,别在这上面省。

6.3 我的三个使用习惯

第一,凡是需要多次区间查询的场景,我第一反应永远是先画一个"修改频率 vs 查询频率"的草图。查询占绝对多数,直接前缀数组;修改占比超过10%,考虑树状数组。

第二,代码里把前缀数组的构建封装成一个函数,比如build_prefix(arr),不直接写在主逻辑里。这样语义清晰,也方便日后替换成树状数组或线段树的实现而不动查询代码。

第三,调试时我会打印出pre数组的值,手工算几个小区间的和去比对。比对了两个案例没问题,我才会继续往下写。看似多花了几十秒,实际上省的是后面定位问题的一两个小时。

前缀数组本身不难,但它背后的"预处理换性能"思想,以及它和树状数组之间的关系,值得静下心理顺。把这层关系理清了,后面再看线段树、看各种高级数据结构,都会轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询