前缀和是一种经典的算法技巧,用于高效地计算数组的某一区间内的元素和。它通过预处理一个前缀和数组,将区间求和的问题转化为常数时间的查询操作。本篇博客将详细讲解前缀和的原理,并结合题目解析,让大家掌握这一高效的算法方法。
题目链接:【模板】一维前缀和
题目描述:
给定一个长度为 n
的整数数组 arr
和 q
个查询,每个查询由两个整数 l
和 r
组成,表示区间 [l, r]
。请计算出每个区间内所有元素的和。
示例 1:
arr = [1, 2, 3, 4, 5]
, q = 2
, 查询区间为 [(1, 3), (2, 4)]
[6, 9]
[1, 3]
的元素和为 1 + 2 + 3 = 6
,区间 [2, 4]
的元素和为 2 + 3 + 4 = 9
。提示:
1 <= n, q <= 100000
-10000 <= arr[i] <= 10000
算法思路:
a. 预处理前缀和数组:
使用 dp[i]
表示从数组起始位置到第 i
个元素的累加和。
递推公式为:
dp[i] = dp[i - 1] + arr[i];
通过一次遍历即可构建前缀和数组,时间复杂度为 O(n)
。
b. 利用前缀和快速计算区间和:
使用前缀和数组,可以在 O(1)
的时间内计算出任意区间 [l, r]
的和:
sum(l, r) = dp[r] - dp[l - 1];
这个公式的核心在于利用 dp[r]
存储了 [1, r]
区间的和,而 dp[l - 1]
则存储了 [1, l-1]
区间的和,二者相减即得 [l, r]
区间内的和。
假设 arr = [1, 2, 3, 4, 5]
,查询区间为 [(1, 3), (2, 4)]
:
dp[1] = arr[1] = 1
dp[2] = dp[1] + arr[2] = 1 + 2 = 3
dp[3] = dp[2] + arr[3] = 3 + 3 = 6
dp[4] = dp[3] + arr[4] = 6 + 4 = 10
dp[5] = dp[4] + arr[5] = 10 + 5 = 15
[1, 3]
:sum(1, 3) = dp[3] - dp[0] = 6
[2, 4]
:sum(2, 4) = dp[4] - dp[1] = 9
前缀和数组:
Index | arr[i] | dp[i] |
---|---|---|
1 | 1 | 1 |
2 | 2 | 3 |
3 | 3 | 6 |
4 | 4 | 10 |
5 | 5 | 15 |
#include <iostream>
#include <vector>
using namespace std;
const int N = 100010;
vector<long long> arr(N), dp(N); // 使用 vector 存储数组和前缀和
int n, q; // n 为数组大小,q 为查询次数
int main()
{
cin >> n >> q;
// 读取数组元素
for(int i = 1; i <= n; i++)
cin >> arr[i];
// 构建前缀和数组,dp[i] 表示从 arr[1] 到 arr[i] 的累加和
for(int i = 1; i <= n; i++)
dp[i] = dp[i - 1] + arr[i];
// 处理每个查询
while(q--)
{
int l, r;
cin >> l >> r;
// 输出区间和 [l, r]
cout << dp[r] - dp[l - 1] << endl;
}
return 0;
}
dp[i]
表示从 arr[1]
到 arr[i]
的累加和,因此在构建前缀和数组时需要从 i = 1
开始,而非 0
。读取 arr
时也应从 1
开始。l = 1
时,dp[l - 1]
为 0
。确保 dp[0]
初始化为 0
,以避免边界查询时产生错误。arr
和 dp
的长度都最少需要定义为 n+1
以确保不会越界。尤其在大规模数据时,需要合理定义 N
以避免内存溢出。在这段代码中,我们首先通过输入构建了原数组 arr
和相应的前缀和数组 dp
。然后通过预处理后的 dp
数组,能够快速计算出任意查询区间 [l, r]
的和。
整个过程只需要 O(n)
的时间构建前缀和数组,再通过 O(1)
的时间解决每个区间和查询,使得在多次查询场景下效率非常高。
前缀和是一种非常常用的算法技巧,特别是在处理区间求和问题时,能够显著优化计算效率。通过一次遍历构建前缀和数组,我们可以在后续查询中轻松地利用前缀和的特性,实现对任意区间的快速求和。 这道题作为前缀和的模板题,帮助我们掌握了前缀和的核心思想与基本操作。通过它,我们能为后续更复杂的区间问题打下坚实的基础。
题目链接:【模板】二维前缀和
题目描述:
给定一个大小为 n × m
的矩阵 matrix
和 q
个查询,每个查询由四个整数 x1, y1, x2, y2
组成,表示一个子矩阵的左上角 (x1, y1)
和右下角 (x2, y2)
。请计算出每个子矩阵内所有元素的和。
示例 1:
matrix = [[1, 2], [3, 4]]
, q = 1
, 查询区间为 [(1, 1, 2, 2)]
[10]
1 + 2 + 3 + 4 = 10
。提示:
1 <= n, m <= 1000
-10000 <= matrix[i][j] <= 10000
算法思路:
类似于一维前缀和,我们可以预处理一个前缀和矩阵 sum
,使得 sum[i][j]
表示从矩阵起点 (1, 1)
到位置 (i, j)
的所有元素的累加和。利用这个前缀和矩阵,可以在 O(1)
时间内求出任意子矩阵的和。
步骤分为两部分:
构建前缀和矩阵:
构建时,我们在矩阵的顶部和左侧添加一行和一列的 0
,以简化边界处理。
前缀和矩阵的递推公式为:
sum[i][j] = sum[i - 1][j] + sum[i][j - 1] - sum[i - 1][j - 1] + matrix[i - 1][j - 1];
利用前缀和矩阵计算子矩阵和:
对于左上角 (x1, y1)
和右下角 (x2, y2)
的查询,我们可以通过以下公式计算该子矩阵的和:
result = sum[x2][y2] - sum[x1 - 1][y2] - sum[x2][y1 - 1] + sum[x1 - 1][y1 - 1];
类比小学就学过的求面积
假设 matrix = [[1, 2], [3, 4]]
,q = 1
,查询区间为 [(1, 1, 2, 2)]
:
构建前缀和矩阵:
原始矩阵:
1 2
3 4
构建前缀和矩阵:
sum =
0 0 0
0 1 3
0 4 10
查询子矩阵和:
对于 x1 = 1, y1 = 1, x2 = 2, y2 = 2
:
result = sum[2][2] - sum[0][2] - sum[2][0] + sum[0][0] = 10 - 0 - 0 + 0 = 10
#include <iostream>
#include <vector>
using namespace std;
int main()
{
int n, m, q;
cin >> n >> m >> q;
vector<vector<int>> matrix(n + 1, vector<int>(m + 1, 0));
vector<vector<long long>> sum(n + 1, vector<long long>(m + 1, 0));
// 读取矩阵数据
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= m; j++) {
cin >> matrix[i][j];
}
}
// 构建前缀和矩阵
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= m; j++) {
sum[i][j] = sum[i - 1][j] + sum[i][j - 1] - sum[i - 1][j - 1] + matrix[i][j];
}
}
// 处理查询
while(q--) {
int x1, y1, x2, y2;
cin >> x1 >> y1 >> x2 >> y2;
long long result = sum[x2][y2] - sum[x1 - 1][y2] - sum[x2][y1 - 1] + sum[x1 - 1][y1 - 1];
cout << result << endl;
}
return 0;
}
matrix
的基础上偏移一行和一列,以简化边界处理。查询时也需调整下标。sum[i][j]
时,记得同时减去重复计算的 sum[i - 1][j - 1]
。n, m
较大的输入,使用 long long
类型存储累加和,以避免整数溢出。O(n * m)
,每次查询时间为 O(1)
,适用于大量查询场景。sum
需要 O(n * m)
的额外空间。二维前缀和是处理矩阵区域和问题的利器,通过一次性构建前缀和矩阵,可以高效地解决任意子矩阵的求和问题。相比于逐个元素累加的方法,前缀和能大幅减少计算次数,使得算法在面对多次查询时表现更佳。
题目链接:724. 寻找数组的中⼼下标
题目描述:
给你⼀个整数数组 nums
,请计算数组的 中⼼下标 。
数组 中⼼下标 是数组的⼀个下标,其左侧所有元素相加的和等于右侧所有元素相加的和。
如果中⼼下标位于数组最左端,那么左侧数之和视为 0
,因为在下标的左侧不存在元素。这⼀点对中⼼下标位于数组最右端同样适⽤。
如果数组有多个中⼼下标,应该返回 最靠近左边 的那⼀个。如果数组不存在中⼼下标,返回 -1
。
示例 1:
nums = [1, 7, 3, 6, 5, 6]
3
3
。sum = nums[0] + nums[1] + nums[2] = 1 + 7 + 3 = 11
,sum = nums[4] + nums[5] = 5 + 6 = 11
,⼆者相等。示例 2:
nums = [1, 2, 3]
-1
示例 3:
nums = [2, 1, -1]
0
0
。sum = 0
,(下标 0
左侧不存在元素),sum = nums[1] + nums[2] = 1 + -1 = 0
。提示:
1 <= nums.length <= 10^4
-1000 <= nums[i] <= 1000
算法思路:
根据中⼼下标的定义,除了中⼼下标的元素外,该元素左边的「前缀和」等于该元素右边的「后缀和」。
因此,我们可以先预处理两个数组,一个表示前缀和,另一个表示后缀和。然后,通过遍历来找到满足条件的中⼼下标。
构建前缀和数组 lsum
:
lsum[i]
表示 nums
从开始到位置 i - 1
的所有元素的和,即 [0, i - 1]
区间的累加和。
构建前缀和数组 lsum
的递推公式为:
lsum[i] = lsum[i - 1] + nums[i - 1];
构建后缀和数组 rsum
:
rsum[i]
表示 nums
从位置 i + 1
到最后一个元素的所有元素的和,即 [i + 1, n - 1]
区间的累加和。
构建后缀和数组 rsum
的递推公式为:
rsum[i] = rsum[i + 1] + nums[i + 1];
枚举中⼼下标:
lsum[i]
和后缀和 rsum[i]
是否相等。如果相等,说明该位置就是中⼼下标,直接返回。-1
。假设 nums = [1, 7, 3, 6, 5, 6]
:
lsum[0] = 0
(表示 nums
的左侧没有元素)lsum[1] = lsum[0] + nums[0] = 0 + 1 = 1
lsum[2] = lsum[1] + nums[1] = 1 + 7 = 8
lsum[3] = lsum[2] + nums[2] = 8 + 3 = 11
lsum[4] = lsum[3] + nums[3] = 11 + 6 = 17
lsum[5] = lsum[4] + nums[4] = 17 + 5 = 22
rsum[5] = 0
(表示 nums
的右侧没有元素)rsum[4] = rsum[5] + nums[5] = 0 + 6 = 6
rsum[3] = rsum[4] + nums[4] = 6 + 5 = 11
rsum[2] = rsum[3] + nums[3] = 11 + 6 = 17
rsum[1] = rsum[2] + nums[2] = 17 + 3 = 20
rsum[0] = rsum[1] + nums[1] = 20 + 7 = 27
lsum[3] == rsum[3]
,即下标 3
满足条件,因此输出 3
。前缀和、后缀和数组:
Index | nums[i] | lsum[i] | rsum[i] |
---|---|---|---|
0 | 1 | 0 | 27 |
1 | 7 | 1 | 20 |
2 | 3 | 8 | 17 |
3 | 6 | 11 | 11 |
4 | 5 | 17 | 6 |
5 | 6 | 22 | 0 |
class Solution {
public:
int pivotIndex(vector<int>& nums) {
// lsum[i] 表示 [0, i - 1] 区间的累加和
// rsum[i] 表示 [i + 1, n - 1] 区间的累加和
int n = nums.size();
vector<int> lsum(n), rsum(n);
// 预处理前缀和数组
for(int i = 1; i < n; i++)
lsum[i] = lsum[i - 1] + nums[i - 1];
// 预处理后缀和数组
for(int i = n - 2; i >= 0; i--)
rsum[i] = rsum[i + 1] + nums[i + 1];
// 查找中⼼下标
for(int i = 0; i < n; i++) {
if(lsum[i] == rsum[i])
return i;
}
return -1;
}
};
该问题还可以通过更为简洁的解法实现,仅需一个变量记录累加的前缀和,节省空间。
优化思路:
遍历数组时,如果一个位置 i
满足 2 * 前缀和 + nums[i] == 总和
,则它就是中心下标。其原理在于:
i
,数组的左侧和 tmp
与右侧和(总和 - tmp - nums[i])相等。2 * tmp + nums[i] == 总和
。class Solution {
public:
int pivotIndex(vector<int>& nums) {
int totalSum = 0, tmp = 0;
// 计算总和
for(int num : nums) {
totalSum += num;
}
// 遍历数组,判断中心下标条件
for(int i = 0; i < nums.size(); i++) {
if(2 * tmp + nums[i] == totalSum) {
return i; // 找到中心下标
}
tmp += nums[i]; // 更新前缀和
}
return -1; // 没有找到中心下标
}
};
lsum[i]
表示 [0, i - 1]
区间累加和,而 rsum[i]
表示 [i + 1, n - 1]
区间累加和。因此,遍历中我们直接使用 lsum[i] == rsum[i]
即可判断条件。lsum
或 rsum
是 0
,这样才能保证正确的判断。我们先通过遍历构建了 lsum
和 rsum
数组,然后再次遍历数组,找到第一个满足 lsum[i] == rsum[i]
的位置。
O(n)
,遍历数组的次数为常数次,适合于长度较大的数组。O(n)
,额外的前缀和和后缀和数组 lsum
和 rsum
。对于优化后的解法:
O(n)
,仅需一次遍历。O(1)
,只使用一个临时变量记录前缀和,显著节省了空间。题目链接:238. 除⾃⾝以外数组的乘积
题目描述:
给你⼀个整数数组 nums
,返回数组 answer
,其中 answer[i]
等于 nums
中除 nums[i]
之外其余各元素的乘积。
题⽬数据保证数组 nums
中任意元素的全部前缀元素和后缀的乘积都在 32 位整数范围内。
请不要使⽤除法,且在 O(n)
时间复杂度内完成此题。
示例 1:
nums = [1, 2, 3, 4]
[24, 12, 8, 6]
示例 2:
nums = [-1, 1, 0, -3, 3]
[0, 0, 9, 0, 0]
提示:
2 <= nums.length <= 10^5
-30 <= nums[i] <= 30
nums
中任意元素的全部前缀元素和后缀的乘积都在 32 位整数范围内。进阶:你可以在 O(1)
的额外空间复杂度内完成这个题⽬吗?(出于对空间复杂度分析的⽬的,输出数组不被视为额外空间。)
算法思路:
由于题目要求不能使用除法,同时要求
O(n)
的时间复杂度,因此我们不能用求出整个数组的乘积然后除以单个元素的方式求解。
可以利用前缀和思想,使用两个数组来记录每个元素的前缀积和后缀积,然后将两者相乘得到每个元素除自身以外的乘积。
定义前缀积数组 lprod
:
lprod[i]
表示 nums
从开始到 i - 1
的所有元素的乘积,即 [0, i - 1]
区间内所有元素的乘积。
构建前缀积数组 lprod
的递推公式为:
lprod[i] = lprod[i - 1] * nums[i - 1];
定义后缀积数组 rprod
:
rprod[i]
表示 nums
从 i + 1
到数组末尾的所有元素的乘积,即 [i + 1, n - 1]
区间内所有元素的乘积。
构建后缀积数组 rprod
的递推公式为:
rprod[i] = rprod[i + 1] * nums[i + 1];
计算结果数组:
nums
,计算每个位置 i
的结果 ret[i]
为 lprod[i] * rprod[i]
。lprod[i]
包含的是 nums[0]
到 nums[i - 1]
的乘积,而 rprod[i]
包含的是 nums[i + 1]
到末尾的乘积,两者相乘即为除 nums[i]
外的所有元素乘积。假设 nums = [1, 2, 3, 4]
,期望的结果为 [24, 12, 8, 6]
:
lprod[0] = 1
(初始条件,表示没有元素的乘积)lprod[1] = lprod[0] * nums[0] = 1 * 1 = 1
lprod[2] = lprod[1] * nums[1] = 1 * 2 = 2
lprod[3] = lprod[2] * nums[2] = 2 * 3 = 6
rprod[3] = 1
(初始条件,表示没有元素的乘积)rprod[2] = rprod[3] * nums[3] = 1 * 4 = 4
rprod[1] = rprod[2] * nums[2] = 4 * 3 = 12
rprod[0] = rprod[1] * nums[1] = 12 * 2 = 24
ret[0] = lprod[0] * rprod[0] = 1 * 24 = 24
ret[1] = lprod[1] * rprod[1] = 1 * 12 = 12
ret[2] = lprod[2] * rprod[2] = 2 * 4 = 8
ret[3] = lprod[3] * rprod[3] = 6 * 1 = 6
前缀积、后缀积数组:
Index | nums[i] | lprod[i] | rprod[i] | ret[i] |
---|---|---|---|---|
0 | 1 | 1 | 24 | 24 |
1 | 2 | 1 | 12 | 12 |
2 | 3 | 2 | 4 | 8 |
3 | 4 | 6 | 1 | 6 |
class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
int n = nums.size();
vector<int> lprod(n, 1), rprod(n, 1), ret(n);
// 构建前缀积数组
for(int i = 1; i < n; i++) {
lprod[i] = lprod[i - 1] * nums[i - 1];
}
// 构建后缀积数组
for(int i = n - 2; i >= 0; i--) {
rprod[i] = rprod[i + 1] * nums[i + 1];
}
// 计算结果数组
for(int i = 0; i < n; i++) {
ret[i] = lprod[i] * rprod[i];
}
return ret;
}
};
优化思路:
我们可以进一步优化空间复杂度到 O(1)
。通过仅使用一个 ret
数组来存储结果,并利用它保存前缀积,再遍历一次通过累积的后缀积来更新结果:
ret
中。lprod
和 rprod
数组的情况下得到。class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
int n = nums.size();
vector<int> ret(n, 1);
// 计算前缀积
for(int i = 1; i < n; i++) {
ret[i] = ret[i - 1] * nums[i - 1];
}
// 计算后缀积并更新结果
int suffixProd = 1;
for(int i = n - 1; i >= 0; i--) {
ret[i] *= suffixProd;
suffixProd *= nums[i];
}
return ret;
}
};
lprod[0]
和 rprod[n-1]
都初始化为 1
,表示没有元素的乘积。ret
数组存储前缀积,后续遍历时逐个乘以后缀积。在此解法中,我们通过构建前缀积和后缀积的方式实现了在 O(n)
时间复杂度下计算每个位置的乘积。在优化方案中,通过巧妙地在结果数组中存储前缀积并逐步累加后缀积,实现了空间复杂度的优化。
O(n)
,无论是初始计算前缀积和后缀积,还是单次遍历,时间复杂度都为 O(n)
。O(n)
,优化方案达到 O(1)
的额外空间复杂度。在这片数列的流动之中,我们从前缀和的入门,渐次深入,直抵算法思想的核心。四道基础题如同桥梁,串联起前缀和与后缀积的巧妙应用,从区间求和的简明优雅到排除自身后的乘积演算,每一步都指向数据处理的无限可能。这是算法的序曲,数字的暗涌,如流水般轻盈而深邃。随着思维渐入佳境,我们将在下篇中进一步探索数列的复杂美,揭开更深层的优化思路,与算法之光同行。