一、经验总结
何时使用前缀和算法?
当题目中要求的是一段一维区间的和或是一片二维区域的和时使用前缀和算法。先初始化前缀和数组(矩阵)O(n),再利用前缀和数组(矩阵)求和O(1)
前缀和算法模板
一维前缀和
- 初始化前缀和数组:
- 为了处理边界情况,前缀和数组的下标从1开始,因此要多开一个空间。
- 状态表示:前缀和数组元素dp[i]表示[1, i]区间内所有元素的和
- 状态转移方程:dp[i] = dp[i-1] + arr[i]
- 使用前缀和数组求区间和:
- 区间求和公式:sum(r, l) = dp[r] - dp[l-1]
- 因为下标从1开始,所以不会越界
二维前缀和
- 初始化前缀和矩阵
- 为了处理边界情况,前缀和矩阵的下标从[1, 1]开始,因此要多开1行1列空间。
- 状态表示:前缀和矩阵元素dp[i][j]表示从左上角[1, 1]到右下角[i, j]区域内所有元素的和
- 状态转移方程:dp[i][j] = dp[i-1][j] + dp[i][j-1] - dp[i-1][j-1] + mat[i][j]
- 使用前缀和矩阵求区域和
- 区域求和公式:sum([x1,y1], [x2,y2]) = dp[x2][y2] - dp[x2][y1-1] - dp[x1-1][y2] + dp[x1-1][y1-1]
- 因为下标从[1, 1]开始,所以不会越界
下标的映射关系:以上介绍的前缀和算法中,原数组和前缀和数组(矩阵)下标都是从1开始。但在实际题目当中,原数组的下标也可能从0开始,但是前缀和数组(矩阵)的下标必须从1开始,这就出现了下标不匹配的请况,需要注意在状态转移方程和区间求和公式中调整下标的映射关系。
前缀和算法的灵活应用
根据题目要求,前缀和算法需要做出适当的调整
-
如果前缀和不包含当前位置的值:
- 状态表示:dp[i]表示[0, i-1]区间内所有元素的和
- 初始化:前缀和的下标从0开始,但需要特殊处理dp[0]的值,因此就不需要多开空间了
- 状态转移方程:dp[i] = dp[i-1] + arr[i-1](0位置特殊处理,实际仍然是从1开始计算)
-
如果划分求左右两个区间的和:左区间前缀和,右区间后缀和(不包含当前位置)
- 状态表示:rdp[i]表示[i+1, n-1]区间内所有元素的和
- 初始化:后缀和的下标从n-1开始,但需要特殊处理rdp[n-1]的值,也不需要多开空间
- 状态转移方程:rdp[i] = rdp[i+1] + arr[i+1](n-1位置特殊处理,实际是从n-2开始计算)
- 填表顺序:从右向左(和前缀和相反)
-
如果不再是求区间的和而是求区间的积(不包含当前位置)
- 状态表示:dp[i]表示[0, i-1]区间内所有元素的积
- 初始化:dp[0]应该初始化为1
- 状态转移方程:dp[i] = dp[i-1] * arr[i-1](0位置特殊处理,实际仍然是从1开始计算)
-
前缀和求满足条件的子数组的数目
- 转换一:在以i位置为结尾的所有子数组中统计满足条件的子数组的数目
- 转换二:在以i位置为结尾的所有子数组中满足条件的子数组(右区间)和数组的其他部分(左区间)的和存在和差关系,就可以利用前缀和转换为求满足和差关系的左区间的个数。
- 该题型不需要创建前缀和数组,只需要计算当前位置的前缀和即可。
- 可以借助哈希表统计满足和差关系的左区间的个数,需要注意的是针对不同的题目要求哈希表存储的key, value值也不同。
- 注意我们需要在[0, i-1]的区间内统计左区间的个数,因此要统计过i位置的数目后,才能将i位置丢入哈希表。
- 需要特殊处理的是,当整个[0, i]区间就是满足条件的子数组,此时找不到左区间,但应该统计一次。
二、相关编程题
2.1 一维前缀和
题目链接
【模板】前缀和_牛客题霸_牛客网 (nowcoder.com)
题目描述
算法原理
编写代码
#include <iostream>
#include <vector>
using namespace std;
int main() {
int n, q;
cin >> n >> q;
vector<int> arr(n+1, 0);
for(size_t i = 1; i <= n; ++i)
{
cin >> arr[i];
}
// 初始化dp数组
vector<long long> dp(n+1, 0); //long long类型防溢出
for(size_t i = 1; i <= n; ++i)
{
dp[i] = dp[i-1]+arr[i];
}
while(q--)
{
int l = 0, r = 0;
cin >> l >> r;
cout << dp[r]-dp[l-1] << endl;
}
}
// 64 位输出请用 printf("%lld")
2.2 二维前缀和
题目链接
【模板】二维前缀和_牛客题霸_牛客网 (nowcoder.com)
题目描述
算法原理
编写代码
#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));
for(size_t i = 1; i <= n; ++i)
{
for(size_t j = 1; j <= m; ++j)
{
cin >> matrix[i][j];
}
}
// 初始化前缀和矩阵
vector<vector<long long>> dp(n+1, vector<long long>(m+1, 0));
for(size_t i = 1; i <= n; ++i)
{
for(size_t j = 1; j <= m; ++j)
{
dp[i][j] = dp[i-1][j] + dp[i][j-1] -dp[i-1][j-1] + matrix[i][j];
}
}
// 使用前缀和矩阵求和
while(q--)
{
int x1, y1, x2, y2;
cin >> x1 >> y1 >> x2 >> y2;
long long ret = dp[x2][y2] - dp[x1-1][y2] - dp[x2][y1-1] + dp[x1-1][y1-1];
cout << ret << endl;
}
return 0;
}
2.3 寻找数组的中心下标
题目链接
题目描述
算法原理
编写代码
class Solution {
public:
int pivotIndex(vector<int>& nums) {
int n = nums.size();
vector<int> f(n, 0);
vector<int> g(n, 0);
// 初始化前缀和、后缀和数组
for(size_t i = 1; i < n; ++i)
{
f[i] = f[i-1] + nums[i-1];
}
for(int i = n-2; i >= 0; --i)
{
g[i] = g[i+1] + nums[i+1];
}
// 使用前缀和、后缀和数组求中心点下标
for(size_t i = 0; i < n; ++i)
{
if(f[i] == g[i])
return i;
}
return -1;
}
};
2.4 除自身以外数组的乘积
题目链接
238. 除自身以外数组的乘积 - 力扣(LeetCode)
题目描述
算法原理
编写代码
class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
int n = nums.size();
vector<int> f(n, 1);
vector<int> g(n, 1);
// 初始化前缀积、后缀积数组
for(int i = 1; i < n; ++i)
{
f[i] = f[i-1]*nums[i-1];
}
for(int i = n-2; i >= 0; --i)
{
g[i] = g[i+1]*nums[i+1];
}
// 使用前缀积、后缀积数组计算每个位置的结果
vector<int> answer(n);
for(size_t i = 0; i < n; ++i)
{
answer[i] = f[i] * g[i];
}
return answer;
}
};
2.5 和为k的子数组
题目链接
题目描述
算法原理
编写代码
class Solution {
public:
int subarraySum(vector<int>& nums, int k) {
unordered_map<int, int> hash; //哈希表用于统计前缀和的个数
hash[0] = 1; //出厂自带一个前缀和0
int sum = 0;
int ret = 0;
for(size_t i = 0; i < nums.size(); ++i)
{
sum += nums[i]; //求当前位置的前缀和
if(hash.count(sum-k))
ret += hash[sum-k]; //统计以i位置为结尾的和为k的子数组
++hash[sum]; //计算过当前位置的子数组后再将前缀和丢到哈希表中
}
return ret;
}
};
2.6 和可被k整除的子数组
题目链接
974. 和可被 K 整除的子数组 - 力扣(LeetCode)
题目描述
算法原理
编写代码
class Solution {
public:
int subarraysDivByK(vector<int>& nums, int k) {
unordered_map<int,int> hash; //统计前缀和余数的出现次数
hash[0] = 1; //当整个前缀和能被k整除时,统计一次
int sum = 0;
int ret = 0;
for(auto e : nums)
{
sum += e; //计算当前位置的前缀和
int rem = (sum%k+k)%k; //计算前缀和余数(正余数)
if(hash.count(rem))
ret += hash[rem]; //统计以i位置为结尾的可被k整除的子数组
++hash[rem]; //计算过当前位置的子数组后再将前缀和余数丢到哈希表中
}
return ret;
}
};
2.7 连续数组
题目链接
题目描述
算法原理
编写代码
class Solution {
public:
int findMaxLength(vector<int>& nums) {
unordered_map<int, int> hash; //统计前缀和的最早出现位置
hash[0] = -1; //当前缀和恰好为0时,长度因比当前下标大1
int sum = 0;
int len = 0; //最长子数组的长度
for(int i = 0; i < nums.size(); ++i)
{
sum += nums[i]==0?-1:1;
if(hash.count(sum))
{
len = max(len, i - hash[sum]);
}
else
{
hash[sum] = i;
}
}
return len;
}
};
2.8 矩阵区域和
题目链接
题目描述
算法原理
编写代码
class Solution {
public:
vector<vector<int>> matrixBlockSum(vector<vector<int>>& mat, int k) {
int m = mat.size();
int n = mat[0].size();
vector<vector<int>> answer(m, vector<int>(n, 0));
//初始化前缀和矩阵
vector<vector<int>> dp(m+1, vector<int>(n+1, 0));
for(int i = 1; i <= m; ++i)
{
for(int j = 1; j <= n; ++j)
{
dp[i][j] = dp[i-1][j] + dp[i][j-1] - dp[i-1][j-1] + mat[i-1][j-1];
}
}
//利用前缀和矩阵求answer矩阵
for(int i = 0; i < m; ++i)
{
for(int j = 0; j < n; ++j)
{
int x1 = max(0,i-k)+1;
int y1 = max(0,j-k)+1;
int x2 = min(m-1, i+k)+1;
int y2 = min(n-1, j+k)+1;
answer[i][j] = dp[x2][y2] - dp[x2][y1-1] - dp[x1-1][y2] + dp[x1-1][y1-1];
}
}
return answer;
}
};