作者:fuxuemingzhu;审核:liweiwei1419。
前缀和(preSum
)算法是一种数据预处理方法,可用于快速求数组的区间和。前缀和是一种典型的空间换时间思想的应用。
前缀和可以简单地理解为数组的前
前缀和可以应用在:
- 快速求数组前
$i$ 位之和; - 快速求数组的
$[i, j]$ 范围内之和; - 求二维矩阵中某个子矩阵之和。
本文提供两个模板:
- 模板一:基础模板,这个模板定义的
preSum
数组长度等于$nums$ 数组长度。 - 模板二:常用模板,这个模板定义的
preSum
数组长度等于$nums$ 数组长度$+ 1$ 。
在刷题的时候,常见的写法是模板二。建议先阅读模板一,了解前缀和原理,然后再看模板二。实际使用的时候,两个模板并没有本质区别,选择自己喜欢的即可。
例题 1:「力扣」的 1480. 一维数组的动态和。
给你一个数组
nums
。数组「动态和」的计算公式为:preSum[i] = sum(nums[0]…nums[i])
。请返回
nums
的动态和。示例 :
输入:
nums = [1,2,3,4]
输出:[1,3,6,10]
解释:动态和计算过程为[1, 1+2, 1+2+3, 1+2+3+4]
。
这个题让我们求 preSum[i] = sum(nums[0]…nums[i])
,如果你没有了解过「前缀和」,可能会写出两重循环:每个 preSum[i]
,累加从 nums[i]
。即,写出下面的代码:
class Solution {
public int[] runningSum(int[] nums) {
int N = nums.length;
int[] preSum = new int[N];
for (int i = 0; i < N; ++i) {
int sum = 0;
for (int j = 0; j <= i; ++j) {
sum += nums[j];
}
preSum[i] = sum;
}
return preSum;
}
}
vector<int> runningSum(vector<int>& nums) {
const int N = nums.size();
vector<int> preSum(N, 0);
for (int i = 0; i < N; ++i) {
int sum = 0;
for (int j = 0; j <= i; ++j) {
sum += nums[j];
}
preSum[i] = sum;
}
return preSum;
}
class Solution(object):
def runningSum(self, nums):
N = len(nums)
preSum = [0] * N
for i in range(N):
curSum = 0
for j in range(i + 1):
curSum += nums[j]
preSum[i] = curSum
return preSum
两重循环的时间复杂度是
其实我们只要稍微转变一下思路,就发现没必要用两重循环。我们使用类似「动态规划」的思想,从一个小问题推导出更大的问题:
当已知 preSum[i]
是数组前 preSum[i + 1] = preSum[i] + nums[i + 1]
。
一个简单的转换,让我们可以省去内层的 for
循环。
于是我们就得到了「前缀和」数组——
「前缀和」 就是从
nums
数组中的第 0 位置开始,累加到第$i$ 位置的结果,我们常把这个结果保存到数组preSum
中,记为preSum[i]
。
写出的代码如下:
class Solution {
public int[] runningSum(int[] nums) {
int N = nums.length;
int[] preSum = new int[N];
for (int i = 0; i < N; ++i) {
if (i == 0) {
preSum[i] = nums[i];
} else {
preSum[i] = preSum[i - 1] + nums[i];
}
}
return preSum;
}
}
vector<int> runningSum(vector<int>& nums) {
const int N = nums.size();
vector<int> preSum(N, 0);
for (int i = 0; i < N; ++i) {
if (i == 0) {
preSum[i] = nums[i];
} else {
preSum[i] = preSum[i - 1] + nums[i];
}
}
return preSum;
}
class Solution(object):
def runningSum(self, nums):
N = len(nums)
preSum = [0] * N
for i in range(N):
if i == 0:
preSum[i] = nums[i]
else:
preSum[i] = preSum[i - 1] + nums[i]
return preSum
- 时间复杂度:$O(N)$;
- 空间复杂度:$O(N)$。
上文是「前缀和」的基本求法。
那我们怎么用「前缀和」数组求数组的区间和呢?
根据前缀和的定义 preSum[i]
是数组 nums
的前
- 数组
$[0, i]$ 区间的和 =preSum[i]
; - 数组
$[i, j]$ 区间的和 =preSum[j] - preSum[i - 1]
;
至此,我们已经把如何求「前缀和」以及如何用「前缀和」求数组的区间和讲解清楚了。
模板一中,定义的 preSum[i] = nums[0] + nums[1] + ...+ nums[i]
,那么区间
$sum(i, j) = preSum[j] - preSum[i - 1]$
当要计算区间 preSum[i - 1]
会越界,因此需要对以
为了避免分类讨论,实际在使用前缀和时,经常把前缀和的数组长度定义为数组长度 + 1。
即令 preSum[0] = 0
,
$$preSum[i] = nums[0] + nums[1] + ... + nums[i - 1]$$
那么,就可以把 preSum
的公式统一为 **preSum[i] = preSum[i - 1] + nums[i - 1]
,**此时的 preSum[i]
表示 nums
中
下面以 [1, 12, -5, -6, 50, 3]
为例,用动图讲解一下如何求 preSum
。
求 preSum
数组的过程是——
preSum[0] = 0;
preSum[1] = preSum[0] + nums[0];
preSum[2] = preSum[1] + nums[1];
...
那么区间
$sum(i, j) = preSum[j + 1] - preSum[i]$
什么原理呢?其实就是消除公共部分即
注意上面的式子中,使用的是 preSum[j + 1]
和 preSum[i]
,原因是:
-
preSum[j + 1]
表示的是nums
数组中$[0, j]$ 的所有数字之和(包含$0$ 和$j$ )。 -
preSum[i]
表示的是nums
数组中$[0, i - 1]$ 的所有数字之和(包含$0$ 和$i - 1$ )。 - 当两者相减时,结果留下了
nums
数组中$[i, j]$ 的所有数字之和。
求前缀和的代码如下:
class Solution {
public int[] runningSum(int[] nums) {
int N = nums.length;
int[] preSum = new int[N + 1];
for (int i = 0; i < N; ++i) {
preSum[i + 1] = preSum[i] + nums[i];
}
return preSum;
}
}
vector<int> runningSum(vector<int>& nums) {
const int N = nums.size();
vector<int> preSum(N + 1, 0);
for (int i = 0; i < N; ++i) {
preSum[i + 1] = preSum[i] + nums[i];
}
return preSum;
}
class Solution(object):
def runningSum(self, nums):
N = len(nums)
preSum = [0] * (N + 1)
for i in range(0, N):
preSum[i + 1] = preSum[i] + nums[i]
return preSum
注意,上面的代码中没有给 preSum[0]
赋值,在 C++ 中 vector<int>
的默认值为 0。
-
时间复杂度:$O(N)$;
-
空间复杂度:$O(N)$。
「前缀和」的思想比较简单,分为两个步骤,需要注意细节:
- 预处理得到前缀和
preSum
数组(这一步很少出问题); - 通过
preSum
数组计算数组中某个区间的和(这一步可能出问题,需要注意定义的preSum[i]
是否包含了$nums[i]$)。
刷题时,难点在于怎么想到使用「前缀和」—— 如果题目考察了「区间和」,那么可以考虑「前缀和」;另外也可以考虑用滑动窗口。
利用 preSum
数组,可以在 nums
任意区间
例题 1.「力扣」的 303. 区域和检索 - 数组不可变。
给定一个整数数组
nums
,求出数组从索引i
到j
(i ≤ j
)范围内元素的总和,包含i
、j
两点。实现
NumArray
类:
NumArray(int[] nums)
使用数组nums
初始化对象int sumRange(int i, int j)
返回数组nums
从索引i
到j
(i ≤ j
)范围内元素的总和,包含i
、j
两点(也就是sum(nums[i], nums[i + 1], ... , nums[j])
)示例:
输入: ["NumArray", "sumRange", "sumRange", "sumRange"] [[[-2, 0, 3, -5, 2, -1]], [0, 2], [2, 5], [0, 5]] 输出: [null, 1, -1, -3]
解释: NumArray numArray = new NumArray([-2, 0, 3, -5, 2, -1]); numArray.sumRange(0, 2); // return 1 ((-2) + 0 + 3) numArray.sumRange(2, 5); // return -1 (3 + (-5) + 2 + (-1)) numArray.sumRange(0, 5); // return -3 ((-2) + 0 + 3 + (-5) + 2 + (-1))
提示:
0 <= nums.length <= 104
-105 <= nums[i] <= 105
0 <= i <= j < nums.length
最多调用
$10^4$ 次sumRange
方法
题意是给出了一个整数数组 nums
,当调用 sumRange(i, j)
函数的时候,求数组 nums
中
本题考察了利用「前缀和」计算数组的区间和,可以套用模板二。
class NumArray {
private int[] preSum;
public NumArray(int[] nums) {
final int N = nums.length;
preSum = new int[N + 1];
for (int i = 0; i < N; ++i) {
preSum[i + 1] = preSum[i] + nums[i];
}
}
public int sumRange(int i, int j) {
return preSum[j + 1] - preSum[i];
}
}
class NumArray {
public:
NumArray(vector<int>& nums) {
const int N = nums.size();
preSum.resize(N + 1);
for (int i = 0; i < N; ++i) {
preSum[i + 1] = preSum[i] + nums[i];
}
}
int sumRange(int i, int j) {
return preSum[j + 1] - preSum[i];
}
private:
vector<int> preSum;
};
class NumArray:
def __init__(self, nums: List[int]):
N = len(nums)
self.preSum = [0] * (N + 1)
for i in range(N):
self.preSum[i + 1] = self.preSum[i] + nums[i]
def sumRange(self, i: int, j: int) -> int:
return self.preSum[j + 1] - self.preSum[i]
复杂度分析:
- 空间复杂度:定义「前缀和」数组,需要
$N + 1$ 的空间,所以空间复杂度是$O(N)$ ; - 时间复杂度:
- 初始化「前缀和」数组,需要把数组遍历一次,时间复杂度是
$O(N)$ ; - 求
$[i, j]$ 范围内的区间和,只用访问preSum[j + 1]
和preSum[i]
,时间复杂度是$O(1)$ 。
- 初始化「前缀和」数组,需要把数组遍历一次,时间复杂度是
例题 1.「力扣」的 560. 和为 K 的子数组。
给你一个整数数组 nums 和一个整数 k ,请你统计并返回该数组中和为 k 的连续子数组的个数。
示例 1:
输入:nums = [1,1,1], k = 2 输出:2
示例 2:
输入:nums = [1,2,3], k = 3 输出:2
提示:
- 1 <= nums.length <= 2 * 10^4
- -1000 <= nums[i] <= 1000
- -10^7 <= k <= 10^7
如果本题不使用「前缀和」,那么需要使用三重循环:两重循环用于遍历所有区间,一重循环用于区间求和。时间复杂度是
其中,区间求和的循环可以用「前缀和」代替,把整体的时间复杂度降低为
还可以进一步优化:类似于「两数之和」的做法,我们可以用一个字典(哈希表)保存已经遇到过的 preSum
的出现次数,那么对于一个位置
对于遍历刚开始的时候,下标 visited[0] = 1;
以保证当
class Solution {
public int subarraySum(int[] nums, int k) {
int preSum = 0;
Map<Integer, Integer> visited = new HashMap<>();
visited.put(0, 1);
int res = 0;
for (int i = 0; i < nums.length; ++i) {
preSum += nums[i];
if (visited.containsKey(preSum - k)) {
res += visited.get(preSum - k);
}
visited.put(preSum, visited.getOrDefault(preSum, 0) + 1);
}
return res;
}
}
####C++
class Solution {
public:
int subarraySum(vector<int>& nums, int k) {
int preSum = 0;
unordered_map<int, int> visited;
visited[0] = 1;
int res = 0;
for (int num : nums) {
preSum += num;
if (visited.count(preSum - k)) {
res += visited[preSum - k];
}
visited[preSum] ++;
}
return res;
}
};
class Solution(object):
def subarraySum(self, nums, k):
preSum = 0
visited = collections.defaultdict(int)
visited[0] = 1
res = 0
for num in nums:
preSum += num
if preSum - k in visited:
res += visited[preSum - k]
visited[preSum] += 1
return res
另外一种拓展,是求二维矩阵的「前缀和」。
例题 2. 「力扣」的 304. 二维区域和检索 - 矩阵不可变。
给定一个二维矩阵
matrix
,以下类型的多个请求:计算其子矩形范围内元素的总和,该子矩阵的 左上角 为
(row1, col1)
,右下角 为(row2, col2)
。 实现NumMatrix
类:
NumMatrix(int[][] matrix)
给定整数矩阵matrix
进行初始化int sumRegion(int row1, int col1, int row2, int col2)
返回 左上角(row1, col1)
、右下角(row2, col2)
所描述的子矩阵的元素 总和 。示例 1:
输入:
["NumMatrix","sumRegion","sumRegion","sumRegion"]
[[[[3,0,1,4,2],[5,6,3,2,1],[1,2,0,1,5],[4,1,0,1,7],[1,0,3,0,5]]],[2,1,4,3],[1,1,2,2],[1,2,2,4]]
输出:
[null, 8, 11, 12]
解释:
NumMatrix numMatrix = new NumMatrix([[3,0,1,4,2],[5,6,3,2,1],[1,2,0,1,5],[4,1,0,1,7],[1,0,3,0,5]]]);
numMatrix.sumRegion(2, 1, 4, 3); // return 8 (红色矩形框的元素总和)
numMatrix.sumRegion(1, 1, 2, 2); // return 11 (绿色矩形框的元素总和)
numMatrix.sumRegion(1, 2, 2, 4); // return 12 (蓝色矩形框的元素总和)
当「前缀和」拓展到二维区间时,可以用下面的思路求解。
我们定义 preSum[i][j]
表示 从 [0,0]
位置到 [i,j]
位置的子矩形所有元素之和。
如果求 preSum[i][j]
的递推公式为:
可以用下图帮助理解:
减去
前面已经求出了数组中从 [0,0]
位置到 [i,j]
位置的 preSum
。
可以用 preSum
计算 [row1, col1]
到 [row2, col2]
的子矩形的所有元素之和:
同样利用一张图来说明:
加上子矩形
代码如下。
class NumMatrix {
int[][] preSum;
public NumMatrix(int[][] matrix) {
int M = matrix.length;
if (M > 0) {
int N = matrix[0].length;
preSum = new int[M + 1][N + 1];
for (int i = 0; i < M; ++i) {
for (int j = 0; j < N; ++j) {
preSum[i + 1][j + 1] = preSum[i][j + 1] + preSum[i + 1][j] - preSum[i][j] + matrix[i][j];
}
}
}
}
public int sumRegion(int row1, int col1, int row2, int col2) {
return preSum[row2 + 1][col2 + 1] - preSum[row2 + 1][col1] - preSum[row1][col2 + 1] + preSum[row1][col1];
}
}
class NumMatrix {
private:
vector<vector<int>> preSum;
public:
NumMatrix(vector<vector<int>>& matrix) {
const int M = matrix.size();
if (M > 0) {
const int N = matrix[0].size();
preSum.resize(M + 1, vector<int>(N + 1));
for (int i = 0; i < M; ++i) {
for (int j = 0; j < N; ++j) {
preSum[i + 1][j + 1] = preSum[i + 1][j] + preSum[i][j + 1] - preSum[i][j] + matrix[i][j];
}
}
}
}
int sumRegion(int row1, int col1, int row2, int col2) {
return preSum[row2 + 1][col2 + 1] - preSum[row2 + 1][col1] - preSum[row1][col2 + 1] + preSum[row1][col1];
}
};
class NumMatrix:
def __init__(self, matrix: List[List[int]]):
if not matrix or not matrix[0]:
M, N = 0, 0
else:
M, N = len(matrix), len(matrix[0])
self.preSum = [[0] * (N + 1) for _ in range(M + 1)]
for i in range(M):
for j in range(N):
self.preSum[i + 1][j + 1] = self.preSum[i][j + 1] + self.preSum[i + 1][j] - self.preSum[i][j] + matrix[i][j]
def sumRegion(self, row1: int, col1: int, row2: int, col2: int) -> int:
return self.preSum[row2 + 1][col2 + 1] - self.preSum[row2 + 1][col1] - self.preSum[row1][col2 + 1] + self.preSum[row1][col1]
复杂度分析:
- 空间复杂度:定义二维的「前缀和」数组,需要
$(M + 1) * (N + 1)$ 的空间,所以空间复杂度是$O(M*N)$ ; - 时间复杂度:
- 初始化「前缀和」数组,需要把二维数组遍历一次,时间复杂度是
$O(M*N)$ ; - 求
[row1, col1]
到[row2, col2]
的子矩形的所有元素之和,只用访问preSum
中的四个元素,时间复杂度是$O(1)$ 。
- 初始化「前缀和」数组,需要把二维数组遍历一次,时间复杂度是
题目 | 类型 |
---|---|
303. 区域和检索 - 数组不可变 | 简单,必做 |
724. 寻找数组的中心下标 | 简单,必做。 |
974. 和可被 K 整除的子数组 | 中等,必做,前缀和+哈希表 |
238. 除自身以外数组的乘积 | 中等,必做,把「前缀和」的思想拓展到「前缀积」 |
523. 连续的子数组和 | 中等,必做,「前缀和」拓展 |
209. 长度最小的子数组 | 中等,选做。结合二分查找;也可以用滑动窗口。 |
1248. 统计「优美子数组」 | 中等,选做。 |
思考:上面我们讨论的 nums
数组都是不可变的,如果 nums
是可变的,该怎么办呢?
题目 | 类型 |
---|---|
307. 区域和检索 - 数组可修改 | 中等,选做。「前缀和」已经无法解决,需要用线段树 |