☰
前缀和算法详解:从比那名居的桃子到区间求和优化
2026/10/20 3:22:54 网站建设 项目流程

1. 从"比那名居的桃子"说起:前缀和到底在算什么

第一次看到"比那名居的桃子"这个标题,很多人会以为是什么游戏同人或者二次元周边项目。实际上,这是一道非常经典的算法题,核心考点就一个——前缀和。题目背景大致是这样的:有一排桃子,每颗桃子有甜度值,需要频繁查询某个区间内桃子的甜度总和,或者找出满足某种条件的连续区间。如果每次查询都老老实实从区间起点加到终点,数据量一大就会超时;而前缀和这个技巧,能把区间求和从 O(n) 降到 O(1)。

我最早接触前缀和是在做数组类题目的时候,当时觉得"不就是多开一个数组存累加值吗",后来才发现这个思路的延展性极强——二维前缀和、差分数组、前缀异或和、前缀积,甚至结合哈希表解决子数组求和问题,都是从这个朴素想法长出来的。这篇文章我会围绕"比那名居的桃子"这道题,把前缀和的原理、推导、代码实现、常见变体、踩坑经验完整讲一遍。不管你是刚学算法的新手,还是想复习一下基础技巧的老手,应该都能从中拿到点东西。

先明确一下适用人群:如果你已经能熟练写循环、知道数组下标从0开始,那这篇文章完全能看懂;如果你正在准备算法面试或者刷题,前缀和是必须掌握的基础工具之一,值得花时间吃透。下面我按"为什么需要它→怎么推导→怎么写代码→怎么应对变体→怎么避坑"的顺序展开。

2. 暴力求和为什么会超时:前缀和要解决的真实痛点

2.1 一个具体的场景:查询次数一多,暴力就崩了

假设桃子排成一排,共 n 颗,第 i 颗的甜度是 a[i]。现在有 m 次询问,每次问"第 L 颗到第 R 颗桃子的甜度总和是多少"。最直接的做法是写一个循环:

def query(a, L, R): total = 0 for i in range(L, R + 1): total += a[i] return total

单次查询的复杂度是 O(n),m 次查询就是 O(n×m)。如果 n 和 m 都是 10^5 级别,那就是 10^10 次运算,现代计算机一秒大概能跑 10^8 次简单操作,这个量级妥妥超时。这就是暴力求和的死穴:每次查询都在重复计算之前已经算过的部分。

你可以这样理解:问第1到第5颗的总和,你从第1颗加到第5颗;下一次问第1到第6颗,你又从第1颗重新加一遍。前面那5颗的累加结果明明没变,却被白白浪费了。前缀和的核心思想就是——把"从开头到当前位置的累加结果"提前存下来,用的时候直接相减。

2.2 前缀和的定义:用一个数组记住所有历史

定义前缀和数组pre,其中pre[i]表示前 i 个元素的和(注意下标约定,不同写法略有差异,后面会专门讲)。常见的一种定义是:

  • pre[0] = 0(前0个元素的和为0)
  • pre[i] = pre[i-1] + a[i-1],表示前 i 个元素的和

这样定义之后,区间 [L, R](假设用1-based描述,即第L个到第R个)的和就等于pre[R] - pre[L-1]。为什么?因为pre[R]是前 R 个的和,pre[L-1]是前 L-1 个的和,两者相减,正好把前 L-1 个元素抵消掉,剩下的就是第 L 到第 R 个的和。

这个推导看起来简单,但它是整个前缀和体系的基石。我建议你在纸上画一条数轴,把 pre 数组的每个值标出来,然后自己推一遍相减的过程,比死记公式管用得多。

2.3 复杂度对比:从 O(n×m) 到 O(n+m)

方案预处理单次查询m次查询总复杂度
暴力求和无O(n)O(n×m)
前缀和O(n)O(1)O(n+m)

预处理只需要遍历一遍数组,花 O(n);之后每次查询两次数组访问加一次减法,O(1)。当 m 很大时,这个优化是数量级的。这也是为什么几乎所有涉及"区间求和"的题目,第一反应都应该是前缀和。

提示:前缀和不是万能的。如果数组元素会被频繁修改(比如单点更新),那前缀和每次更新后都要重新预处理,反而变慢。这种"带修改的区间查询"要用树状数组或线段树。前缀和的适用前提是数组静态不变,只有查询。

3. 手把手推导:从定义到代码的完整链路

3.1 下标约定:0-based 和 1-based 的坑

这是新手最容易翻车的地方。前缀和有两种常见写法,混用就会出错。

写法一:pre 长度 n+1,pre[0]=0,pre[i] 表示前 i 个元素的和(1-based 语义)

n = len(a) pre = [0] * (n + 1) for i in range(1, n + 1): pre[i] = pre[i - 1] + a[i - 1] # 查询原数组下标 [l, r](0-based,闭区间)的和 # 对应前 r+1 个元素减去前 l 个元素 def query(l, r): return pre[r + 1] - pre[l]

写法二:pre 长度 n,pre[i] 表示 a[0] 到 a[i] 的和(含自身)

n = len(a) pre = [0] * n pre[0] = a[0] for i in range(1, n): pre[i] = pre[i - 1] + a[i] def query(l, r): if l == 0: return pre[r] return pre[r] - pre[l - 1]

写法一多开一个位置,但查询时不用特判 l==0,代码更干净,我个人更推荐。写法二省一个空间,但每次查询都要判断边界,容易漏。关键是一套代码里只用一种约定,别混着来。

3.2 完整代码实现与逐行注释

以"比那名居的桃子"这类区间求和题为例,完整解法如下:

def solve(a, queries): n = len(a) # 构建前缀和,pre[i] 表示前 i 个桃子的甜度总和 pre = [0] * (n + 1) for i in range(1, n + 1): pre[i] = pre[i - 1] + a[i - 1] res = [] for L, R in queries: # 题目若给的是1-based下标,直接用 pre[R] - pre[L-1] # 若给的是0-based下标,用 pre[R+1] - pre[L] res.append(pre[R] - pre[L - 1]) return res

这段代码里,pre[i] = pre[i-1] + a[i-1]是核心。注意a[i-1]这个下标,因为 pre 是1-based语义,而原数组是0-based,所以要用 i-1 去取原数组的值。这个错位是初学者最常写错的地方,我当年也是debug了半天才发现。

3.3 用具体数字走一遍,确认逻辑无误

假设桃子甜度a = [3, 1, 4, 1, 5],构建 pre:

  • pre[0] = 0
  • pre[1] = pre[0] + a[0] = 3
  • pre[2] = pre[1] + a[1] = 4
  • pre[3] = pre[2] + a[2] = 8
  • pre[4] = pre[3] + a[3] = 9
  • pre[5] = pre[4] + a[4] = 14

查询第2到第4颗(1-based)的和,即 a[1]+a[2]+a[3] = 1+4+1 = 6。用公式pre[4] - pre[1] = 9 - 3 = 6,正确。查询第1到第5颗,pre[5] - pre[0] = 14 - 0 = 14,也正确。

手动走一遍的价值在于:当你写代码出错时,能快速定位是 pre 构建错了还是查询公式错了。我习惯在本地测试时先打印整个 pre 数组,肉眼核对前几个值,往往一眼就能看出问题。

4. 前缀和的家族成员:不止区间求和这一种玩法

4.1 二维前缀和:矩阵区域求和

一维玩明白了,二维就是自然的推广。给定一个矩阵,多次查询某个子矩形的元素和。定义pre[i][j]表示从 (0,0) 到 (i-1,j-1) 这个矩形内所有元素的和(同样用多开一行一列的写法)。

构建公式:

pre[i][j] = pre[i-1][j] + pre[i][j-1] - pre[i-1][j-1] + a[i-1][j-1]

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

sum = pre[r2+1][c2+1] - pre[r1][c2+1] - pre[r2+1][c1] + pre[r1][c1]

那个"减两次加一次"的容斥原理,是二维前缀和的灵魂。我第一次看的时候也绕,后来画了个矩形图,把重叠部分标出来,就明白了:pre[i-1][j]和pre[i][j-1]都把pre[i-1][j-1]那块算了两遍,所以要减掉一次。

4.2 差分数组:前缀和的逆运算

如果题目变成"多次对区间 [L,R] 的每个元素加上 v,最后问整个数组变成什么样",这时候用差分数组。定义diff[i] = a[i] - a[i-1],那么对区间 [L,R] 加 v,只需要diff[L] += v和diff[R+1] -= v,最后对 diff 求前缀和就还原出结果。

差分和前缀和是一对互逆操作:前缀和是"累加",差分是"记录变化量"。理解这一点,很多区间修改的题目就能秒杀。比如"比那名居的桃子"如果加一个条件——每次操作给一段区间的桃子增加甜度,最后统一查询——那就得用差分。

4.3 前缀和 + 哈希表:解决子数组求和问题

这是前缀和最经典的进阶用法。题目:找出数组中和为 k 的连续子数组个数。暴力枚举是 O(n²),用前缀和加哈希表能降到 O(n)。

思路:子数组 [i+1, j] 的和等于pre[j] - pre[i],要它等于 k,就是pre[j] - pre[i] = k,即pre[i] = pre[j] - k。遍历 j 的时候,看哈希表里有多少个pre[j] - k,就说明有多少个子数组以 j 结尾且和为 k。

def subarray_sum(nums, k): count = 0 pre = 0 seen = {0: 1} # 前缀和为0出现1次(空前缀) for num in nums: pre += num if pre - k in seen: count += seen[pre - k] seen[pre] = seen.get(pre, 0) + 1 return count

注意seen = {0: 1}这个初始化,它代表"空前缀的和为0"。如果漏了它,那些从数组开头就开始的子数组会被漏掉。这个细节我踩过坑,当时怎么都对不上答案,后来才发现是初始化的问题。

4.4 前缀异或和:把加法换成异或

异或也有类似前缀和的性质,因为异或满足x ^ x = 0,所以区间异或和[L,R] = pre[R] ^ pre[L-1]。这个性质在"找出异或和为某值的子数组"这类题里非常好用,配合哈希表或者字典树还能解决最大异或对问题。

5. 实战中的坑与经验:这些细节文档不会告诉你

5.1 数据范围与溢出:别让答案在最后一步崩掉

前缀和最容易忽略的问题是数值溢出。如果数组元素是 int 范围,n 又很大,累加和可能超出 int 上限。比如 n=10^5,每个元素接近 10^9,总和就是 10^14,早就爆 int 了。Python 不用担心,但 C++/Java 里必须用 long long。

我的习惯是:只要涉及累加,先估算最大可能和,超过 2×10^9 就无脑上 long long。这个判断花不了几秒,但能省掉一次 WA。

5.2 下标越界:R+1 和 L-1 的边界处理

用pre[R+1] - pre[L]这种写法时,如果 R 是数组最后一个下标,R+1 正好等于 pre 的长度,不会越界(因为 pre 多开了一位)。但如果用pre[R] - pre[L-1]且 L=0,L-1 就是 -1,Python 里会取到最后一个元素,C++ 里直接越界。这就是为什么我推荐多开一位的写法——用空间换掉边界判断,减少出错概率。

5.3 前缀和不是"万能钥匙":什么时候不该用

前面提过,带修改的区间查询不适合前缀和。还有一种情况:如果查询的是"区间最大值/最小值",前缀和也无能为力,因为减法对 max/min 不成立(max(a,b) - max(c,d)没有意义)。这类问题要用稀疏表、线段树或单调队列。前缀和只对满足"可减性"的运算有效,比如加法、异或,但不适用于最大值、最小值、乘法(除法有精度问题)。

5.4 调试技巧:打印前缀和数组肉眼核对

我调试前缀和代码的标准流程是:先用一个 5 到 6 个元素的小数组,手动算出 pre 数组,然后打印程序里的 pre,逐个对比。如果 pre 对了但答案错了,那就是查询公式的问题;如果 pre 就错了,那就是构建逻辑的问题。这个二分定位法能省下大量时间。

注意:有些题目给的下标是 1-based,有些是 0-based,读题时一定要看清楚。我见过太多人代码逻辑全对,就因为下标约定没对齐而 WA 的。

6. 从这道题延伸出去:前缀和思维的实际价值

"比那名居的桃子"这道题本身不难,但它背后的前缀和思维,在真实工程里到处都是影子。比如统计系统里"某段时间内的累计请求量",日志分析里"某个区间的事件计数",财务系统里"某段日期的流水汇总"——本质上都是区间求和,都能用前缀和把重复计算消掉。

再往深了说,前缀和体现的是一种**"预处理换查询效率"**的通用思路。类似的还有:提前建索引加速数据库查询、缓存计算结果避免重复运算、预计算特征加速模型推理。这些工程手段和前缀和是同一个哲学——把贵的操作提前做一次,之后用便宜的操作反复取用。

我自己在做数据处理时,经常先用一次遍历算出各种前缀统计量(前缀和、前缀计数、前缀最值),后面无论怎么切区间查询,都是 O(1)。这个习惯让我在处理大规模数据时省了很多时间。所以别小看这道"桃子题",它训练的是你识别"重复计算"并消除它的能力,这个能力比记住一个公式值钱得多。

如果你想把前缀和练到条件反射,我的建议是:先把一维区间求和写熟,再练二维矩阵求和,然后做几道"前缀和+哈希表"的子数组题,最后试试差分数组。这四步走完,前缀和这一块基本就通了。刷题的时候别只求 AC,多想想"这题为什么能用前缀和""换成别的运算还成立吗",想清楚这些,下次遇到新题才能自己推出来。

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

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

立即咨询