目录
前缀和(Prefix Sum)是一种在数组或序列上的计算技巧,通过对数组的预处理,可以快速计算给定区间内元素的总和。
一、一维前缀和
前缀和概念:前缀和是指对于一个数组或序列,每个位置的前缀和表示该位置之前所有元素的总和。例如,对于数组 [1,2,3,4,5,6]
,其前缀和可以表示为 [1,3,6,10,15,21]
。
我们在使用前缀和时,往往会把数组从下标一开始记录,这样可以避免边界情况的判断
计算公式:prefixSum[i] = prefixSum[i - 1] + element[i]
连续区间[l,r]的和:sum = pre[r] - pre[l-1]
通过前缀和数组的性质,我们可以用递推的方式来得到一个完整的前缀和数组
即当前 i 位置的前缀和等于 i - 1 位置的前缀和加上 i 位置的原数组元素
二、二维前缀和
故名思意,二维前缀和就是对二维数组做预处理
概念:二维前缀和pre[ i ][ j ]表示原数组中从(0,0)到(i,j)对应矩形上所有元素的和
计算公式:pre[i][j] = pre[i-1][j] + pre[i][j-1] - pre[i-1][j-1] + src[i][j]
矩阵(x1,y1)到(x2,y2)的区域和公式:
公式看上去有些复杂,让我们来理解一下:
此时我们要求(i,j)位置的前缀和,上图是对原数组划分
也就是:pre[i][j] = 红+蓝+绿+⻩
黄色就是原数组对应位置的元素 src[i][j]
通过我们定义的前缀和的状态,单独求蓝色、绿色不好求,但是我们可以退而求其次,求红+蓝和红+绿,最后再减去多加的一块红色就大功告成了!
红+蓝:也就是从(0,0)到(i-1,j)的元素总和 pre[i-1][j]
红+绿:也就死从(0,0)到(i,j-1)的元素总和 pre[[i][j-1]
红色:通过前缀和的状态定义,pre[i-1][j-1]
红蓝 + 红绿 - 红 + 黄 加也就得到了前缀和的递推公式!
由此我们可以知道,要计算当前位置的前缀和,需要知道左上角、正上方、左侧三个位置的前缀和;求和时按照从上自下,从左至右的顺序即可求得前缀和数组
矩阵区域和也是同样的道理,只是划分对象变成了前缀和数组
三、题目练习
1、一维前缀和模板题---点击跳转题目
要求一段区间的和,通过先对原数组预处理得到前缀和数组后
【l,r】区间的和:pre[r[ - pre[l-1]
代码:
#include <iostream>
using namespace std;
const int N = 1e5 + 10;
long long a[N], pre[N];
int main()
{
int n,q;
cin>>n>>q;
for(int i=1;i<=n;i++) cin>>a[i];
for(int i=1;i<=n;i++) pre[i] = pre[i-1] + a[i];
while(q--)
{
int l ,r;
cin>>l>>r;
cout<<pre[r] - pre[l-1]<<endl;
}
return 0;
}
2、二维前缀和模板题----点击跳转题目
求出前缀和数组后,每次对一个矩形区域的求和查询都是O(1)的
以(x1,y1)为左上角,(x2,y2)为右下角的子矩阵的和: pre[x2][y2] - pre[x1-1][y1-1]
代码:
#include <iostream>
using namespace std;
long long a[1010][1010],dp[1010][1010];
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int n,m,q;
cin>>n>>m>>q;
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++) cin>>a[i][j];
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
dp[i][j] = a[i][j] + dp[i-1][j] + dp[i][j-1] - dp[i-1][j-1];
int x1,y1,x2,y2;
while(q--)
{
cin>>x1>>y1>>x2>>y2;
cout<<dp[x2][y2] - dp[x1-1][y2] - dp[x2][y1-1] + dp[x1-1][y1-1]<<endl;
}
return 0;
}
3、寻找数组中心下标----点击跳转题目
本题我们利用前缀和的思想,重新定义一个前缀和与后缀和
lsum[i] : i 位置之前所有元素的和(不包含i位置)
rsum[i] i 位置之后所有元素的和(不包含i位置)
做完预处理后我们只需遍历每一个位置判断 lsum[i] 与 rsum[i] 是否相等
代码:
class Solution {
public:
int pivotIndex(vector<int>& nums)
{
int n = nums.size();
vector<int> lsum(n+10),rsum(n+10);
lsum[0] = 0;//初始化,使得循环有初始值可递推
for(int i=1;i<n;i++)
lsum[i] = lsum[i-1] + nums[i-1];
rsum[n-1] = 0;//初始化,同上
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;
}
};
4、除⾃⾝以外数组的乘积----点击跳转题目
还是利用前缀和公式的递推思想,结合题意,我们要计算i位置除自身以外的所有元素乘积,只需计算出i位置之前的乘积和i位置之后的乘积,两者再相乘即可
先做预处理:
f[i] : i位置之前的元素乘积(不包含i位置)
g[i]: i位置之后的元素乘积(不包含i位置)
遍历所有位置,结果为:f[i]*g[i]
代码:
class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums)
{
int n = nums.size();
vector<int> f(n),g(n);
f[0] = 1;//初始化,使得循环内能递推,因为是乘积,所以初始为1
for(int i=1;i<n;i++)
f[i] = f[i-1]*nums[i-1];
g[n-1] = 1;
for(int i=n-2;i>=0;i--)
g[i] = g[i+1]*nums[i+1];
vector<int> ans(n);
for(int i=0;i<n;i++)
{
ans[i] = f[i]*g[i];
}
return ans;
}
};
5、和为k的⼦数组----点击跳转题目
要求一段连续区间的和为k,因为数组可能存在0和负数,是不能使用滑动窗口的(和不存在单调性,指针不是同向双指针)
暴力做法就是枚举每一个子区间,计算它们的和来判断是否等于k,也就以每个位置为起点去枚举子数组,时间复杂度是O(n^2),不考虑
由于前缀和的性质是以i为结尾来表示状态,那么我们以i为结尾来分析一下:
要求0到i有多少个子数组和为k,假设x3到i的和为k,0到i的和为sum[i](前缀和),那么0到x2的和为sum-k(前缀和);如果我们从前往后枚举每一个i,要求每个0到i上有多少个和为k的子数组等价于针对每个i,0到i-1区间有多少个和为sum[i]-k的的前缀和
遍历时,每次把计算的前缀和sum[i]存进哈希表中,计算sum[i]时,哈希表中已经存了0到i-1的sum,查询sum[i] - k的个数,就是0到i-1中有多少个满足的子数组
特别要注意的是,当sum[i]=k时,代表此时的0到i区间只有一个子数组满足和为k,此时sum[i]-k=0,哈希表中是没有记录前缀和为0的,我们需要初始化hash[0] = 1
代码:
class Solution {
public:
int subarraySum(vector<int>& nums, int k)
{
unordered_map<int,int> hash;
hash[0] = 1;//初始化,前缀和为0时有一个数组
int sum = 0, ret = 0;
for(auto x : nums)
{
sum += x;
//存在前缀和为sum-k时,把个数添加到结果中
if(hash.count(sum - k)) ret += hash[sum - k];
hash[sum]++;
}
return ret;
}
};
6、和被k整除的子数组---点击跳转题目
做本题需要用到的前置知识:
同余定理:如果(a-b)%p 为整数,则a%p = b%p
C++中负数%正数为负数,修正方法:(a%p+p)%p --->此时正负数的结果都正确
思路和上一题一致,从前往后遍历i,求0到i有多少个子数组和被k整除
设x到i和sum被k整除,设0到x-1的和为a,也就是要找(sum-a)%k=0的子数组
由同余定理可得,sum%k=a%k
此时问题就等价于找到针对每个sum[i],0到i-1有多少个前缀和满足sum[i]%k=a%k
创建一个哈希表,每次把sum[i]存进哈希表中,每次查询满足前缀和取模k相等的数组来计数
同样,要注意sum=k时,此时只有一个数组满足,需要初始化
代码:
class Solution {
public:
int subarraysDivByK(vector<int>& nums, int k)
{
unordered_map<int,int> hash;
int ret = 0, sum = 0;
hash[0] = 1;
for(auto x : nums)
{
sum += x;
int t = (sum%k+k)%k;
if(hash.count(t)) ret += hash[t];
hash[t]++;
}
return ret;
}
};
7、连续数组----点击跳转题目
0和1的数量相等的子数组,我们变一下形,把所有的0变成-1,题目就转化成了求和为0的最长子数组,和上面两题很类似,只是此时哈希表中不再存个数而是存<前缀和,对应下标>
同样的,初始化问题,当sum[i]=0时,代表0到i就是满足的数组,此时如何计算长度呢?hash[0]=-1,此时i -(-1)就是当前数组的长度
代码:
class Solution {
public:
int findMaxLength(vector<int>& nums)
{
for(auto& x : nums)
{
if(x == 0) x = -1;
}
unordered_map<int,int> hash;
hash[0] = -1;//默认有一个前缀和为0的,针对以i结尾的前缀和为0,此时长度为以i结尾的长度
int len = 0, n = nums.size(),sum = 0;
for(int i=0;i<n;i++)
{
sum += nums[i];
if(hash.count(sum)) len = max(len,i - hash[sum] );
else hash[sum] = i;
}
return len;
}
};
8、矩阵区域的和----点击跳转题目
题目要求返回一个ans矩阵,ans矩阵的每个元素是mat矩阵中一个子矩阵的和,所以需要快速查询mat矩阵中子矩阵的和,需要用到二维前缀和
本题因为题目给的原数组是从下标0开始的,但是我们在计算前缀和为了避免边界情况都是从下标1开始,所以需要注意下标的映射关系,返回的ans数组要求和原数组大小相等,也就是也从下标0开始记录,在查询前缀和数组填表时也要注意下标的映射关系
代码:
class Solution {
public:
vector<vector<int>> matrixBlockSum(vector<vector<int>>& mat, int k)
{
int m = mat.size();
int n = mat[0].size();
//二维vector的定义
vector<vector<int>> ans(m,vector<int>(n));
vector<vector<int>> pre(m+1,vector<int>(n+1));
//从1开始,避免边界问题
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] + mat[i-1][j-1];
for(int i=0;i<m;i++)
for(int j=0;j<n;j++)
{
//使用前缀和数组,下标映射,+1
int x1 = max(0,i-k) + 1, y1 = max(0,j-k) + 1;
int x2 = min(m - 1, i + k) + 1, y2 = min(n - 1, j + k) + 1;
ans[i][j] = pre[x2][y2] - pre[x1 - 1][y2]
- pre[x2][y1 - 1] + pre[x1 - 1][y1 - 1];
}
return ans;
}
};
创建前缀和数组时pre中(i,j)位置的值需要到mat中的(i-1,j-1)查找
创建ans数组时,ans中(i,j)位置的值需要到pre中的(i+1,j+1)查找
通过以上题目得练习,我们会发现,前缀和更多得是一种思想,提前对数组预处理可以优化后续的解题;或者是很多题目并不是考察前缀和本身,而是需要我们理解前缀和的递推思想,因地制宜地根据题目来改写前缀和