作为一个经常刷题的人,我几乎每天都会跟“区间查询”打交道。无论是求数组某一段的和,还是统计矩阵某个子区域的总值,最朴素的做法就是暴力遍历,但这样做的代价是,每次查询都得重新扫一遍,时间复杂度直接拉满到 O(n) 甚至 O(n²)。今天要聊的前缀和,就是专门来终结这种低效操作的。它能在 O(1) 时间内回答任意区间的求和问题,代价仅仅是预处理时的一次 O(n) 遍历。这篇文章不是单纯堆概念,我会把一维到二维的推导过程、从和到积的变体玩法,以及那些真正能在笔试面试里直接用上的解题模板,一次性讲透。
1. 前缀和的核心思想与问题模型
1.1 为什么你需要前缀和
先看一个最经典的场景:给定一个长度为 n 的整数数组 nums,现在有 m 次询问,每次询问区间 [l, r] 的和。没人想写暴力,但大部分人的第一反应可能是“用滑动窗口”,可滑动窗口解决的是“固定窗口大小”或“单调队列维护极值”这类问题,它处理不了任意区间求和。
那前缀和到底做了什么?它的核心就一句话:用一个额外的数组 pre,把“从开头到当前位置”的累计值全部存下来。具体来说:
- pre[0] 表示前 0 个元素的和,也就是 0
- pre[i] 表示前 i 个元素的和,即 nums[0] + nums[1] + ... + nums[i-1]
有了这个 pre 数组以后,任意区间 [l, r] 的和就变成:
code复制sum([l, r]) = pre[r+1] - pre[l]
这个式子就是前缀和算法的“灵魂”。为什么用 pre[r+1] 而不是 pre[r]?因为按照我上面给的定义,pre 的下标是“个数”而不是“位置”。前 r+1 个元素减去前 l 个元素,剩下的自然就是第 l 到第 r 个位置(闭区间)的元素之和。这种偏移设计在代码里极其常见,后面我给的模板会统一采用这种写法,虽然第一眼看起来多了一个 +1,但它能帮你避开无数个 off-by-one 的 bug。
1.2 前缀和解决的是什么类型的问题
从数学本质上看,前缀和是把“区间操作”降维成“端点操作”的经典例子。任何一个区间求和问题,在得到前缀和数组之后,都变成了两个前缀值相减。它不止适用于数组求和,还适用于:
- 静态数组任意区间查询
- 数组元素不修改(或很少修改)时的批量区间统计
- 二维矩阵的子矩阵求和
- 前缀积、前缀异或等同类变体
如果你遇到的问题是“数组会频繁修改 + 频繁查询”,那前缀和就不够用了,得换用树状数组或线段树。所以使用前缀和之前,一定要确认:查询量大,但数据是静态的。这也是算法选型时的一个重要判断标准。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一维前缀和:原理、模板与实战
2.1 构造前缀和数组的细节与边界
说了这么多理论,现在就上代码。假设你有一个数组 nums,长度为 n。我们的目标是构造 pre 数组,长度是 n+1。多出来的这一个位置,正是为了容纳 pre[0] = 0 这个“前 0 个元素的和”。
cpp复制#include <vector>
using namespace std;
vector<int> buildPrefixSum(const vector<int>& nums) {
int n = nums.size();
vector<int> pre(n + 1, 0); // 多开一位,pre[0] = 0
for (int i = 1; i <= n; i++) {
pre[i] = pre[i - 1] + nums[i - 1];
}
return pre;
}
这里有一个新手常犯的错误:把循环写成 for (int i = 0; i < n; i++),然后让 pre[i+1] = pre[i] + nums[i]。这种写法也没错,但下标容易搞混,尤其是当你后续要处理二维前缀和、或者想扩展成“前缀积”的时候,我建议统一采用从 1 开始计数的方式,让 pre 数组的下标直接对应“元素个数”,这样写出来的逻辑更顺滑,也更好排查错误。
2.2 区间和查询的核心模板
有了 pre 数组之后,查询区间 [l, r] 的和只需要一行:
cpp复制int rangeSum(const vector<int>& pre, int l, int r) {
// 注意:这里 l 和 r 是 0-based 下标
return pre[r + 1] - pre[l];
}
这里我对定义稍作说明:如果题目给的 l 和 r 是从 0 开始计数的下标,那么查询公式就是 pre[r+1] - pre[l]。如果题目给你的是“第 l 个元素到第 r 个元素”(1-based),那就变成 pre[r] - pre[l-1],本质一样,但要确保和 pre 的定义对齐。
为了加深理解,我们手动推一遍:nums = [1, 3, 5, 7, 9],对应的 pre = [0, 1, 4, 9, 16, 25]。如果要查 [1, 3](即元素 3, 5, 7,和为 15),套公式就是 pre[4] - pre[1] = 16 - 1 = 15。完全正确。
2.3 经典题型:和为 K 的子数组
上面展示的是前缀和最基础的用法——查询任意区间和。但这远远不是前缀和的全部威力。在笔试题里,更常见的是“给你一个数组和一个目标值 K,问有多少个子数组的和等于 K”。如果对每个子数组都求和,复杂度是 O(n²),在数组长度为 10^5 的规模下直接超时。
但配合哈希表,前缀和能把这道题优化到 O(n)。核心思路是:我们遍历数组,一边累加“当前前缀和” cur,一边在哈希表里记录“每个前缀和出现的次数”。对于当前 cur,如果之前的某个前缀和 pre_j 满足 cur - pre_j = K,那么区间 (j, i] 的和就是 K。所以我们只需要在哈希表里查找 cur - K 出现的次数,累加进答案即可。
cpp复制#include <unordered_map>
#include <vector>
using namespace std;
int subarraySumEqualsK(const vector<int>& nums, int k) {
unordered_map<int, int> mp;
mp[0] = 1; // 空前缀,和为0,出现一次
int cur = 0, ans = 0;
for (int x : nums) {
cur += x;
// 找有多少个前缀和等于 cur - k
if (mp.count(cur - k)) {
ans += mp[cur - k];
}
mp[cur]++;
}
return ans;
}
这段代码最精妙的地方在于 mp[0] = 1 这一行的初始化。它处理了“子数组从第一个元素开始”的情况。比如 nums = [1, 1, 1], k = 2,当遍历到第二个 1 时 cur = 2,此时 mp 里有 {0:1, 1:1},查 cur - k = 0,命中 1 次,这个 1 次对应的就是从下标 0 开始的子数组。
这种“前缀和 + 哈希表”的组合非常强大,它可以扩展到“和能被 K 整除的子数组”、“和为奇数/偶数的子数组”等很多变体,本质都是把前缀和的结果作为哈希表的键来累加统计。
3. 前缀和进阶玩法:差分与哈希表优化
3.1 差分数组:前缀和的逆运算
前缀和解决的是“静态查询”,而差分数组解决的是“区间更新 + 最终单点查询”的问题。举个例子:有一个长度为 n 的数组,初始全是 0,现在要执行 m 次操作,每次给区间 [l, r] 内的所有元素加 value,最后输出整个数组。
朴素做法:每次遍历 [l, r] 加 value,时间复杂度 O(n*m)。差分数组的做法是:构造一个差分数组 diff,让 diff[l] += value,diff[r+1] -= value,这样从左到右遍历一遍即可还原出最终的数组。原理很简单:差分数组是原数组的“离散导数”,前缀和是差分数组的“积分”,两者互为逆运算。
cpp复制#include <vector>
using namespace std;
vector<int> rangeAdd(vector<int>& nums, vector<vector<int>>& ops) {
int n = nums.size();
vector<int> diff(n + 1, 0); // 多开一位,防止 r+1 越界
for (auto& op : ops) {
int l = op[0], r = op[1], val = op[2];
diff[l] += val;
diff[r + 1] -= val;
}
vector<int> ans(n);
int cur = 0;
for (int i = 0; i < n; i++) {
cur += diff[i];
ans[i] = nums[i] + cur;
}
return ans;
}
这里的细节是 diff 数组长度必须是 n+1,因为当 r = n-1 时,diff[r+1] = diff[n] 需要存在,只是因为没有被遍历到,所以不会影响结果,但越界访问是不允许的。
3.2 前缀和的进阶:带权重的前缀和
有些题目问的不是“区间和”,而是“区间内所有元素与其下标的乘积之和”。比如给你一个数组,多次询问某个区间 [l, r] 内所有 nums[i] * i 的和。这时候你得维护两个前缀和数组:
- pre1[i] = nums[0] + nums[1] + ... + nums[i-1]
- pre2[i] = nums[0]*0 + nums[1]1 + ... + nums[i-1](i-1)
然后若要查询区间 [l, r] 内 nums[i] * i 的和:
code复制sum(nums[i] * i) = (pre2[r+1] - pre2[l]) - l * (pre1[r+1] - pre1[l])
为什么需要减去 l * (pre1差分)?因为 pre2 里存的是“实际下标”的权重,但你需要的是“相对区间起点”的权重,你把每个元素的下标都减去 l,自然要从总和里刨掉 l * sum(nums)。
这种双前缀和技巧在处理“带权区间查询”的题目里特别管用。如果你在面试中遇到类似的问题,不要慌,先把需要拆解的量列出来,看看它是由哪几个“可前缀”的量组合而成的。
3.3 前缀和与哈希表组合的“最长子数组”问题
我们再来看一个变体:给定一个只包含 0 和 1 的数组,求最长的连续子数组,使得其中 0 和 1 的数量相等。这种题如果你是先去枚举子数组,复杂度又是 O(n²)。但如果把 0 看成 -1,问题就变成了“和为 0 的最长子数组”,又是一个前缀和问题。
解法思路:用哈希表记录每个前缀和第一次出现的下标。在遍历过程中,如果当前前缀和 cur 已经在哈希表里出现过,说明从哈希表记录的下标+1 到当前位置这一段的“加和为 0”,也就是 0 和 1 数量相等。更新答案为 max(ans, i - first[cur])。
cpp复制#include <vector>
#include <unordered_map>
#include <algorithm>
using namespace std;
int findMaxLength(vector<int>& nums) {
unordered_map<int, int> first;
first[0] = -1; // 前缀和为0的最早位置是 -1
int cur = 0, ans = 0;
for (int i = 0; i < nums.size(); i++) {
cur += (nums[i] == 0 ? -1 : 1);
if (first.count(cur)) {
ans = max(ans, i - first[cur]);
} else {
first[cur] = i;
}
}
return ans;
}
这里初始化 first[0] = -1 的作用是:如果整个数组恰好满足条件,比如 [0, 1],索引走到 1 时 cur = 0,i - first[0] = 1 - (-1) = 2,正确算出了整个数组的长度。如果你把 first[0] 初始化为 0,那这里会算出 1,答案就错了。这个边界细节非常容易踩坑,我当年第一次写的时候就在这里被绕晕过。
4. 二维前缀和:从一维到矩阵的推广
4.1 二维前缀和的容斥原理
一维前缀和解决的是“数组区间求和”,二维前缀和解决的是“矩阵子区域求和”。它的构造思路和一维完全一致,但在合并时多了一个“容斥”的过程。
假设我们有矩阵 matrix,行数为 m,列数为 n。定义一个二维前缀数组 pre,维度是 (m+1) x (n+1),其中 pre[i][j] 表示矩阵从 (0,0) 到 (i-1, j-1) 这个子矩阵的所有元素之和。也就是说,pre[i][j] 对应的是前 i 行、前 j 列的总和。
构造公式:
code复制pre[i][j] = pre[i-1][j] + pre[i][j-1] - pre[i-1][j-1] + matrix[i-1][j-1]
为什么是这个式子?画个图就清楚了。pre[i-1][j] 是上面部分,pre[i][j-1] 是左面部分,但这两者都把左上角 pre[i-1][j-1] 那块重叠包含了,所以要减掉一次,最后再加上当前格子的值。
4.2 二维区间和查询模板
构造好 pre 数组以后,查询任意子矩阵(左上角 (x1, y1) 到右下角 (x2, y2))的和,公式是:
code复制sum = pre[x2+1][y2+1] - pre[x1][y2+1] - pre[x2+1][y1] + pre[x1][y1]
这里的下标同样是基于“前 i 行前 j 列”的定义推导出来的。如果你不想每次推导,我教你一个简单的记忆方法:用右下角的完整前缀和,减去上方多出去的部分,减去左方多出去的部分,再加上被重复减掉的左上角部分。
cpp复制#include <vector>
using namespace std;
vector<vector<int>> build2DPrefix(const vector<vector<int>>& matrix) {
int m = matrix.size(), n = matrix[0].size();
vector<vector<int>> pre(m + 1, vector<int>(n + 1, 0));
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
pre[i][j] = pre[i - 1][j] + pre[i][j - 1] - pre[i - 1][j - 1] + matrix[i - 1][j - 1];
}
}
return pre;
}
int sumRegion(const vector<vector<int>>& pre, int x1, int y1, int x2, int y2) {
return pre[x2 + 1][y2 + 1] - pre[x1][y2 + 1] - pre[x2 + 1][y1] + pre[x1][y1];
}
把二维前缀和模板记住,LeetCode 上“二维区域和检索 - 矩阵不可变”这类题直接秒解。你甚至可以拿它去解决“最大子矩阵和”这类经典难题:枚举上下边界,把每一列压缩成一个一维数组,再对这个一维数组跑一维最大子段和,整体复杂度 O(n² * m)。这个套路在很多 Hard 题里都出现过。
4.3 二维前缀和的边界条件与优化
二维前缀和最容易出 bug 的地方就是下标越界和容斥公式记忆错误。我的建议是在一个小例子上手动推一遍再上代码,比如 matrix = [[1, 2], [3, 4]],手动算出 pre = [[0,0,0],[0,1,3],[0,4,10]],然后随便取个子矩阵验证公式对不对。这种小技巧虽然听起来很笨,但真的能在考场上帮你省下 20 分钟的调试时间。
如果你对空间要求很敏感,还可以在原来的矩阵上原地修改,把 matrix 本身当作 pre 数组用,这样可以省下一个 O(m*n) 的辅助数组。但我不建议在工程代码里这么做,因为会破坏原始数据,后续如果还要读原矩阵就麻烦了。面试时优先保证正确性和清晰度,空间够用就行。
5. 从和到积:前缀积与变体
5.1 前缀积的基本原理
既然前缀和可以快速求区间和,那能不能快速求区间积呢?答案是可以的,这就是前缀积。定义 prefixProduct[i] 表示前 i 个元素的乘积(prefixProduct[0] = 1),那么区间 [l, r] 的乘积就是:
code复制product([l, r]) = prefixProduct[r+1] / prefixProduct[l]
这个公式的前提条件是数组元素都不为 0。如果允许 0 的存在,除零错误会让这个式子崩掉。在 LeetCode 上有一道题是“除了自身以外的数组乘积”,那题用的是前后缀乘积拆分,不是本文要深入展开的前缀积模板。但如果你真的遇到了“区间查询乘积”的题目,必须考虑 0 的干扰。
5.2 含零数组的前缀积处理策略
数组里出现 0 会让区间积直接变成 0,前缀积的“可除性”也就失效了。这时候的处理技巧通常是:分段前缀积,即按 0 的位置把数组切开,每一段内没有 0,段间交界处维护一个“零出现的位置”列表。查询一个区间 [l, r] 的乘积时:
- 如果区间内包含任意一个 0,答案为 0
- 否则,找到区间覆盖的“零分段”,在段内使用前缀积相除
要快速判断“区间内是否包含 0”,可以用一个辅助数组 zeroCount 做前缀和,保存到当前位置为止 0 出现的次数。查询时只需要看 zeroCount[r+1] 是否大于 zeroCount[l],就知道区间里有没有 0。这种“前缀和 + 前缀积”的组合拳,可以说是处理含零数组区间积的标准姿势了。
5.3 前缀异或:另一种“和”的变体
既然前缀和可以区间求和,那区间异或呢?异或运算的性质和加减法很像:它有自己的逆运算(异或自己),所以也天然适合前缀化。定义 xr[i] 表示前 i 个元素的异或值,查询区间 [l, r] 的异或值就是:
code复制xor([l, r]) = xr[r+1] ^ xr[l]
这里用到的性质是:x ^ x = 0。所以前 r+1 个元素的异或再异或前 l 个元素的异或,中间那部分就“消”出来了。前缀异或在处理“数组中出现奇数次的元素”这一类问题上很有用。它的编码方式和前缀和几乎一模一样,把加号换成异或运算符就可以了。
从“和”到“积”再到“异或”,你会发现前缀思想本质上是一种“可逆聚合”的模板。只要某个运算满足结合律、并且存在“逆运算”,就能用前缀思想把区间查询打成 O(1)。加法对应减法,乘法对应除法,异或对应异或。理清这个层次以后,你再看新的前缀变体题目,就不会觉得陌生了。
6. 常见问题与实操心得
6.1 什么时候不能用前缀和
前面反复强调过,前缀和适合“静态数据 + 多次查询”的场景。如果数据在查询间隙会被修改,那 pre 数组就失效了,因为你修改一个格子的值,后面所有前缀和都要跟着变。这时候正确选择是树状数组或线段树,它们支持单点修改 + 区间查询。千万不要拿前缀和硬扛动态更新,那是拿大炮打蚊子——打不中。
另外需要注意,前缀和虽然让查询变成 O(1),但预处理是 O(n),如果查询次数很少(甚至只有一次),那直接用暴力遍历反而更省事。所以选型前先看看:查询次数多不多?数据量大不大?修改频率高不高?
6.2 前缀和数组的初始化陷阱
很多人写前缀和容易忘记 pre[0] = 0 这个初始化。这个 0 不是可有可无的,它是“空区间”的语义。对于前缀和,pre[0] = 0 表示前 0 个元素的和为 0;对于前缀积,pre[0] = 1 表示前 0 个元素的积为 1;对于前缀异或,pre[0] = 0 表示前 0 个元素异或结果为 0。这些初始化是保证公式成立的基础,漏掉任何一个,整个算法就崩了。
如果你在写代码时发现结果总是偏大或偏小,先检查一下 pre 数组的初始化对不对,再检查查询公式里的下标偏移对不对。这两个地方是前缀和错误的高发区,也是面试官最爱设置陷阱的地方。
6.3 从刷题视角看前缀和的频率与热度
前缀和在算法面试里的地位非常高,它常常与滑动窗口、二分查找、哈希表结合,形成“组合题”。比如“前缀和 + 二分”可以解决“子数组和至少为 K 的最短长度”这类问题;“前缀和 + 单调栈”可以处理“每个子数组的最小值之和”这类问题。把这些组合吃透,你的算法水平会有一个质的提升。
我个人做过的前缀和相关题目超过 50 道,最大的体会是:理解模板只是第一步,真正难的是识别“这题能不能用前缀和转化”。看到区间、连续子数组、矩阵子区域、乘积、异或这些关键词,大脑里就应该弹出前缀思想的雷达。刷题时不妨把题目归个类:哪些是“裸前缀和”,哪些是“前缀和 + 哈希”,哪些是“前缀和 + 其他数据结构”,这样复习时也更有条理。
6.4 二维前缀和的调试小技巧
二维前缀和一旦出 bug,很难像一维那样肉眼盯出来。我的调试习惯是:用一个 3x3 的小矩阵,手动算好 pre 数组,然后逐一验证查询函数的返回值。另外还可以在代码里加一个“暴力验证”的函数,随机生成查询,对比暴力结果和前缀和结果,不一致时立刻打印中间的 pre 值。这种“对拍”的思路虽然简单,但排查边界问题真的极其高效,强烈建议你在练习时用起来。
最后分享一个我在实际刷题中养成的习惯:每次写完前缀和相关代码,我会顺手在注释里把公式写出来。比如“sum(l, r) = pre[r+1] - pre[l]”,这样下次回头复习时,不用重新推导,一眼就能想起来当时的思路。别小看这个习惯,它能帮你把做过的题真正消化成自己的东西。
