开头
第一次在 LeetCode上刷到前缀和相关题目的时候,我其实没什么概念,觉得不就是数组求和嘛,暴力遍历也能算。直到有一次周赛栽在了一道“连续子数组”题上,两层循环超时,我才认真去把前缀和这个技巧彻底搞懂。可以说,前缀和是算法刷题中性价比极高的一个点:思路简单、代码量小、适用面广,而且从一维数组、二维矩阵到差分数组,几乎每个阶段都能碰到它的身影。
这篇分享聚焦“前缀和专题一”,也就是一维前缀和以及它最常见的两个应用场景:区间求和的高效查询、子数组和目标值的计数。文章里会包含题型识别方法、代码模板、高频真题实操(LeetCode 303、560、724),还有我在刷题过程中踩过的一些坑。不论你是刚开始刷题的新手,还是被这类题卡过的同学,这篇内容应该都能给你一些可复用的经验。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1. 前缀和到底在解决什么问题:从暴力到 O(1) 查询的思维转换
1.1 核心公式与一次预处理的思路
先说我理解的前缀和最朴素的含义:对原数组 nums,我们额外维护一个数组 pre,pre[i] 表示原数组从第 0 个位置到第 i 个位置的元素之和。说白了吧,就是把“累加结果”提前算好存起来,后续任何区间和都能用一次减法搞定,不需要循环累加。
如果用数学式子表达,一维前缀和一般这样定义:
text复制pre[i] = nums[0] + nums[1] + ... + nums[i]
那么要查询区间 [l, r] 的和,也就是从下标 l 到下标 r 的所有元素之和,公式就变成了:
text复制sum(l, r) = pre[r] - pre[l - 1]
这里注意边界:当 l = 0 时,pre[l - 1] 就是 pre[-1],这在数组里不存在。所以很多代码实现里会直接把前缀和数组整体往后挪一位,也就是 pre[i] 表示 nums[0] 到 nums[i-1] 的和,这样查询 [l, r] 就变成 pre[r+1] - pre[l],避开了负数下标的分支判断问题。
这个点看似不起眼,实际在写代码时特别重要。我一开始没做偏移,每次查询都要先判断 l == 0,代码又多又容易出错。后来统一改成偏移一位的写法,清爽很多,而且在后面讲到的“前缀和+哈希表”问题里,这种写法还能顺手减少一个边界分支,属于是提升代码稳定性的好习惯。
1.2 为什么说“空间换时间”在这里特别划算
暴力解法里,一次区间求和是 O(n) 的遍历,如果查询 m 次,总复杂度是 O(m * n)。而预处理前缀和只需要 O(n),之后每次查询都是 O(1),总复杂度直接降到 O(n + m)。这个提升在数据量起来之后是跨越式的。
举个形象的例子,就像你买了一堆票据,每次想知道某几天的总花销,如果每次都翻出那一叠票据重新相加,就很慢。前缀和相当于你提前做了张流水表,把每个时间节点的累计金额都记下来。之后要算任意时间段的金额,只需要做一次减法:用较晚时间点的累计金额减去较早时间点之前的累计金额。
这个思路听起来简单,但它的价值其实是“复用计算”。算法题里很多看似需要重复计算的部分,本质都可以通过预处理来避免,前缀和是其中最容易理解也最容易上手的一种,很适合作为训练这种思维的第一个模型。
2. 一维前缀和的代码模板:三种常见写法
2.1 标准 C++ 写法
cpp复制class PrefixSum {
private:
vector<int> pre;
public:
PrefixSum(vector<int>& nums) {
int n = nums.size();
pre.resize(n + 1, 0);
for (int i = 0; i < n; i++) {
pre[i + 1] = pre[i] + nums[i];
}
}
int query(int l, int r) {
// 区间 [l, r],下标从 0 开始
return pre[r + 1] - pre[l];
}
};
这套模板的最关键地方就是 pre.size() = n + 1,然后 pre[i+1] 存的是 nums[0] 累加到 nums[i] 的和。这样查询任意 [l, r] 时,直接用 pre[r + 1] - pre[l],完全不需要额外判断。
注意,上面代码里 pre 数组是用了 n + 1 的长度,初始位置 pre[0] = 0。这个多出来的“哑巴位置”就是用来统一公式的,不是可有可无的,建议养成习惯。
2.2 Python 写法与 itertools.accumulate
Python 里写前缀和可以直接手写循环,也可以用标准库:
python复制from itertools import accumulate
nums = [1, 2, 3, 4, 5]
pre = [0] + list(accumulate(nums))
# pre = [0, 1, 3, 6, 10, 15]
def query(l, r):
return pre[r + 1] - pre[l]
accumulate 返回的是迭代器,前面手动补一个 0,正好对应上面说的偏移一位写法。这个写法很简洁,但笔试或面试时还是建议能手写循环,因为有些场景需要在累加过程中做额外判断,依赖库函数反而限制思路。
2.3 对“偏移一位”的理解与建议
偏移一位这套处理,本质是把“减去前面所有元素和”变成了“减去之前某个前缀”。它不改变任何数学含义,只是让代码实现抹去了负数下标的分支。
个人建议是:在刷题阶段就固定用一种下标体系,不要一会从 0 开始一会从 1 开始,不然很容易在边界上出昏招。如果你看到有些题解里从 1 开始读入数组,那也是一种风格,核心就一句话:查询 [l, r] 的和,等于“前缀和数组中第 r 个位置减去第 l-1 个位置”,无论下标体系怎么变,这个逻辑不变。
3. 高频真题实操:三道题吃透前缀和
3.1 直接套模板:LeetCode 303 区域和检索 - 数组不可变
LeetCode 303 是一道非常标准的前缀和入门题。题目要求实现一个类,初始化时接收一个整数数组,然后反复调用 sumRange(left, right) 查询左闭右闭区间 [left, right] 的和,并且数组在初始化之后不可变。
如果你第一次看到这题,肯定会想:那不是直接在 sumRange 里从 left 加到 right 吗?功能上没错,但题目里有个隐含的设计意图:sumRange 会被多次调用。如果每次都从头遍历,一旦调用次数很多,性能就会很差。在 LeetCode 上虽然 n 和调用次数可能不大,暴力也能过,但这类题真正想考的就是预处理思维。
我的实现直接套模板:
python复制class NumArray:
def __init__(self, nums: List[int]):
self.pre = [0]
for num in nums:
self.pre.append(self.pre[-1] + num)
def sumRange(self, left: int, right: int) -> int:
return self.pre[right + 1] - self.pre[left]
初始化 O(n),每次查询 O(1)。这就是前缀和最经典的“一次预处理,反复快速查询”场景。做这道题时,建议把整个过程在纸上自己画一遍,特别是 right + 1 和 left 这两个下标怎么对应原数组,一定要搞清楚。后面很多复杂题都是从这一步延伸出来的。
实操心得:在自测时,可以用这个函数循环调用一万次,对比暴力解的耗时。只要数据量稍微大一点,差距就会很明显。这个直观感受会让前缀和的优势变得特别真实。
3.2 前缀和 + 哈希表:LeetCode 560 和为 K 的子数组
LeetCode 560 是前缀和专题里绕不开的一道经典题,题目是:给定一个整数数组 nums 和一个整数 k,请返回该数组中和为 k 的连续子数组的个数。输入输出示例:
text复制输入:nums = [1, 1, 1], k = 2
输出:2
为什么说这题是前缀和的进阶?因为暴力枚举所有子数组需要 O(n^2) 甚至 O(n^3),n 稍微大一点就超时。而前缀和能帮我们快速算出任意区间和,但如果枚举所有左右端点,仍然是 O(n^2),还是没有本质提升。这里就需要再往前走一步,引入哈希表。
核心推导很简洁:用 pre[j] 表示 nums[0] 到 nums[j] 的前缀和,那么子数组 [i, j] 的和是 pre[j] - pre[i-1]。题目要求这个差等于 k,也就是 pre[i-1] == pre[j] - k。所以我在遍历 j 时,只需要知道在之前的遍历过程中,有多少个前缀和的值等于 pre[j] - k,这些数量之和就是答案。
具体写法如下:
python复制def subarraySum(nums, k):
ans = 0
pre = 0
count = {0: 1}
for num in nums:
pre += num
if pre - k in count:
ans += count[pre - k]
count[pre] = count.get(pre, 0) + 1
return ans
注意一个细节:我先查询 count 里有没有 pre - k,再把当前 pre 放进 count。这个顺序不能反过来。如果先把当前 pre 放进去,再查询 pre - k,那么当 k = 0 时,会把当前子数组自身也算进去,导致答案多算。我一开始就在 k = 0 的用例上栽过这个跟头。
还有初始化时 count = {0: 1} 的位置很有讲究。它的意思是:在还没有遍历任何元素时,前缀和为 0 出现过一次。这样当第一个元素刚好等于 k 时,pre - k == 0,可以直接在 count 里查到,从而把以第一个元素结尾的合法子数组也统计进去。
另外用“前缀和 + 哈希表”解决这题时,有一个特别常见的问题:为什么不能用滑动窗口?因为 nums 里可能有负数。滑动窗口的前提是窗口向右移动时和单调变化,而负数会破坏这个性质。这个我在第四节里还会专门提到。
3.3 中心下标与变形:LeetCode 724 寻找数组的中心下标
LeetCode 724 的题目是:找到数组的一个下标,使得这个下标左侧所有元素之和等于右侧所有元素之和,如果不存在就返回 -1。比如:
text复制输入:nums = [1, 7, 3, 6, 5, 6]
输出:3
这题看起来和前两题不太一样,没有直接出现“区间查询”的字样,但它本质上依然可以用前缀和来快速求解。先算出数组总和 total,然后从左到右遍历时维护一个 leftSum,表示当前下标左侧元素之和,右侧元素之和就是 total - leftSum - nums[i]。当 leftSum == total - leftSum - nums[i] 时,i 就是中心下标。
如果用前缀和数组来表达,就是在每个位置 i 计算 pre[i] 和 pre[n] - pre[i+1] 是否相等,逻辑是等价的。所以我更建议初学者先把 total + leftSum 这种方法掌握,因为它不依赖额外数组,代码更短,也更直观。前缀和在这道题里属于“隐藏解法”,能一眼看穿它的本质,说明你已经能主动用前缀和思维去分析问题了。
同时,这题还可以延伸到一个常见变形:LeetCode 238 除自身以外数组的乘积。那个题用前缀积和后缀积两个数组来做,思路跟“左右两侧的累积值”完全一致。刷完 724,再去做 238,你会觉得异常丝滑。
4. 容易踩的 5 个坑:从错误现场理解边界
4.1 查询区间时下标偏移搞错
这是前缀和最经典的坑。查询 [l, r] 的和,正确的写法是 pre[r + 1] - pre[l],但很多初学者会写成 pre[r] - pre[l - 1]。其实两种写法对应的是不同的下标定义,只要全程一致就能对,就怕一会儿用这个一会儿用那个。
如果你用的是偏移一位的前缀和数组,也就是 pre[i + 1] 存 nums[0] 到 nums[i] 的和,那么查询 [l, r] 就一定是要 pre[r + 1] - pre[l]。这里可以自己推一遍:pre[r+1] 是 0 到 r 的和,pre[l] 是 0 到 l-1 的和,两者相减得到 l 到 r 的和。推导一遍心里就有底,别死记公式。
4.2 哈希表更新顺序导致 k = 0 时结果错误
前面提到过,LeetCode 560 这类题里,如果先更新 count 再查询,会把当前子数组自身也算进去,导致结果偏大。以 nums = [1, -1, 0], k = 0 为例,如果先更新再查询,可能会把长度为 0 的“空子数组”算进去,结果就会多 1。
这里我分享一个排查技巧:当答案只差 1 或只多 1 时,优先检查 count 的更新顺序。另外,还有一个习惯可以养成,就是当 k = 0 时,单独用几个小用例验证一下,比如 [0]、[0, 0]、[1, -1, 0],这些都是容易出错的典型输入。
4.3 使用 int 类型导致溢出
在 Java 和 C++ 里,如果数组的元素和超过 int 范围,前缀和数组就会溢出。LeetCode 上很多测试用例的设计不会太极端,但实际业务或竞赛中完全可能遇到。一个稳妥做法是:前缀和数组直接用 long long(C++)或 long(Java),不要省这点空间。
同样的道理也适用于哈希表的 value,它存的是前缀和出现次数,子数组数量理论上可能达到 O(n^2) 级别,用 int 也可能不够,建议直接使用 64 位整数。
4.4 负数和零存在时,不能无脑滑动窗口
“子数组和为 k”这类问题,如果数组中全部是非负数,确实可以用滑动窗口以 O(n) 解决。但一旦出现负数,滑动窗口的收缩逻辑就不成立了,因为加入一个负数会让窗口和变小,你无法判断“是继续扩张还是收缩”。我在一次模拟面试里遇到过面试官故意追问:同样的题,数组里有负数怎么办?如果只是条件反射地回答“用滑动窗口”,就会露馅。正确回答是:有负数时首选前缀和 + 哈希表。
4.5 空间优化时把原数组覆盖导致后面查询错误
有些同学为了省空间,会直接在原数组上累加,也就是原地把 nums[i] 变成前缀和。如果题目保证只处理一次,那没问题。但像 LeetCode 303 这类需要反复查询的场景,如果在原数组上原地修改,后续查询可能就只能依赖当前数组,一不小心就会用错值。
我自己的习惯是:面试或笔试时优先保证逻辑清晰,空间多开一个 O(n) 数组就是 O(n),一般都能接受,没必要为了省这点空间把自己绕晕。只有在真正写工程代码且内存敏感时,才去考虑原地复用的优化。
5. 怎么识别“这题能用前缀和”:刷题方法论
5.1 三个典型信号:区间、连续、多次查询
我在刷题时,一般看到这几个信号,就会往前缀和上去想:
- 题目要求某个连续子数组的和、平均值、累积效果;
- 查询次数不止一次,或者允许先预处理;
- 数组本身是静态的,没有频繁修改的需求。
满足这些条件时,前缀和往往是一个不错的候选方案。比如“给定一个数组,回答多个区间和问题”“找和为 k 的子数组个数”“判断是否有子数组求和等于 0”等等,都是前缀和的常见出题方向。
当然,如果数组在查询过程中会频繁更新,那前缀和就不再是首选,这个场景应该换树状数组或线段树。这也是一个常考的比较点,面试时被问到“什么时候用前缀和,什么时候用线段树”,本质就是在考察你对静态与动态问题的理解。
5.2 从暴力到前缀和的推导步骤
我个人的刷题思路是三层递进:
- 先写暴力解法,明确复杂度瓶颈在哪里;
- 观察瓶颈是不是重复计算区间和;
- 如果是,就尝试用前缀和把区间和查询变成 O(1)。
这个思路听起来很简单,但真的能解决不少问题。很多同学一上来就背模板,遇到题目还是不会套,就是因为缺少“推导”这一步。你在草稿纸上把暴力解写出来,再标出哪些计算是重复的,前缀和的思路就会自然浮现。
比如 LeetCode 560,暴力枚举所有左右端点,每个子数组求和是 O(n),总复杂度 O(n^3)。即使优化成“固定左端点,右端点逐渐右移时动态维护和”,也只是 O(n^2)。这时候你发现,无论怎么优化暴力,左右端点的枚举都存在大量重复。再进一步想到前缀和能快速得到任意区间和,但枚举两个端点仍然是 O(n^2),于是你才会想到用哈希表把左端点的信息存下来,最终把枚举过程压缩到 O(n)。这一步步推导下来,你对“为什么要用前缀和 + 哈希表”的理解,会比直接背题解深刻得多。
5.3 前缀和专题的学习路线建议
前缀和本身是一整条知识线,我的建议路线是:
- 一维前缀和:从模板题 303 开始;
- 一维前缀和 + 哈希表:560、974、525 这类;
- 二维前缀和:LeetCode 304 二维区域和检索;
- 前缀和的扩展应用:724、238、以及“前缀和 + 差分”问题。
这个顺序遵循“先线性再平面再动态”的递进关系。如果你刚开始接触,不要一上来就啃二维前缀和或差分,先确保一维模板和边界处理已经滚瓜烂熟,后面学起来才会顺畅。
6. 两个值得延伸的思路:二维前缀和与差分思想
6.1 二维前缀和:区域求和的高维版
二维前缀和的核心思路和一维基本一样,只是把数组从一维扩展到了二维矩阵。它需要计算每个位置 (i, j) 从 (0, 0) 到 (i, j) 这个矩形区域的和,查询任意子矩形 [r1, r2] × [c1, c2] 的和时,利用容斥原理:
text复制sum(r1, c1, r2, c2) = pre[r2][c2] - pre[r1-1][c2] - pre[r2][c1-1] + pre[r1-1][c1-1]
这个公式初看很绕,但如果你在纸上画一个矩形,用“加加减减”的容斥逻辑去理解,就很容易记住。二维前缀和是 LeetCode 304、以及矩阵类区域查询问题的基础,也是“前缀和专题二”的重头戏。这一讲先不展开,但建议你在掌握一维之后,自己试着推导一遍二维公式。
6.2 差分:前缀和的“逆运算”
差分和前密切相关的概念。简单来说,前缀和是“求累计值”,差分则是“还原变化量”。在遇到“多次对区间进行加减操作,最后统一查询”这类问题时,差分数组可以在 O(1) 时间内完成区间更新,最后通过一次前缀和还原结果。这个方向也属于前缀和专题的进阶内容,后面可以单独成篇。
我提这些的目的,是想说明前缀和这个技巧不会“学完就结束”,它会在很多更复杂的算法题里反复出现。前期把一维基础打牢,后面学二维、学差分、学扩展应用,都会事半功倍。
7. 实战中我常用的调试与验证方法
7.1 用随机小数据对拍暴力解法
做前缀和题目时,我常用一个笨但有效的方法:写一个暴力函数,再写一个前缀和解法,然后生成随机小数组,循环比对两者的输出。这样可以快速定位边界错误。
比如对 LeetCode 560,验证逻辑大致是:
python复制def brute(nums, k):
n = len(nums)
ans = 0
for i in range(n):
s = 0
for j in range(i, n):
s += nums[j]
if s == k:
ans += 1
return ans
def fast(nums, k):
# 前缀和 + 哈希表实现
...
然后跑随机测试,比较结果。这个方法在很多算法题里都有效,特别适合刚学一种新思路时建立信心。
7.2 刻意练习“手推样例”
我在刷前缀和题时,习惯在纸上手动推导几个小样例,把前缀和数组一步步写出来,再对照代码里的查询过程。比如 nums = [3, 1, 4, 1, 5],前缀和数组就是 [0, 3, 4, 8, 9, 14],查询 [1, 3] 的和就是 pre[4] - pre[1] = 9 - 3 = 6,也就是 1 + 4 + 1 = 6。这种手推练习能帮你建立“下标偏移”的直觉,比单纯看题解管用得多。
8. 写在最后的实际技巧
我个人在刷前缀和专题时,还有一个小习惯:把前缀和数组的名称统一写成 pre / prefix,不要一会儿 sum 一会儿 acc。命名统一之后,代码读起来更省脑力,尤其在比赛时能降低低级错误概率。另外,对“前缀和 + 哈希表”这类题,可以总结成一个固定动作:每到一个位置,先算当前前缀和,再查目标差值,最后把当前前缀和写入哈希表。这个顺序练到肌肉记忆之后,很多同类题都能快速套用。
