【ps】本篇有 2 道 Newcode OJ、6 道 LeetCode OJ。
目录
一、算法简介
前缀和是指,从数组的起始位置到某一位置的所有元素的和,主要应用于数组,有一维前缀和、二维前缀和之分,其本质是简易版的动态规划,使用时也会频繁涉及递推公式。
【ps】做前缀和类型的题目,切忌死记硬背、生搬硬套,关键在于学会前缀和的思想和推导过程,能在不同的题目中化用。
二、相关例题
1)一维前缀和
.1- 题目解析
题目要求计算一个一维数组中,[ l,r ] 区间内元素的和。
不难想到暴力算法,每次查询数组时,遍历数组的 [ l,r ] 区间,将元素一个一个加起来求和。但这样查询 q 次,时间复杂度就来到了 O(n*q),是一定会超时的。那么有没有一种更优的算法来对一个区间进行求和呢?
其实,我们可以预先将所有子区间的和求出来,放到一个数组中,后续只需查询这个数组,就可以知道[ l,r ] 区间内元素的和了。而这就是前缀和的思想,对数组中某一个连续区间快速求和,并将结果保存下来,使得查询 q 次的时间复杂度降低到 O(q)。
【Tips】一维前缀和算法的一般解题步骤:
- 预处理出一个前缀和数组;
- 使用这个前缀和数组。
设前缀和数组为 dp,数组元素 dp[i] 表示 [ 1,i ] 区间内的元素之和。以下图为例:
arr 是一个原始数组,dp 是 arr 的前缀和数组,index 是数组下标,dp[1] 表示 arr 第一个元素之和,dp[2] 表示 arr 第一、二个元素之和,dp[3] 表示 arr 第一、二、三个元素之和......
其中不难发现,如果要计算 dp[2],其实无须将 arr 的第一、二个元素重新加一遍,只需让 已求出的 dp[1] 加上 arr[2] 即可得到;同理,要计算 dp[3],其实无须将 arr 的第一、二、三个元素重新加一遍,只需让已求出的 dp[2] 加上 arr[3] 即可得到......以此类推,我们就可以从中得到一个计算 arr 数组前缀和递推公式:dp[i] = dp[i-1] + arr[i]。
预处理完前缀和数组 dp 后,就是求出了 arr 的所有前缀和,而此时要求 [ l,r ] 区间内元素的和,其实只需将 r 下标的前缀和减去 l - 1 下标的前缀和,即 dp[r] - dp[l-1] 。
.2- 代码编写
#include <iostream>
#include<vector>
using namespace std;
int main() {
int n,q;
cin>>n>>q;
vector<int> arr(n+1);
vector<long long>dp(n+1);
for(int i=1;i<=n;i++)cin>>arr[i];
for(int 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;
}
}
2)二维前缀和
.1- 题目解析
题目要求,从一个二维数组中求出由左上、右下两个端点划出的、一块子矩阵上的所有元素之和。
如果用暴力解法,先找到这块矩阵,然后再遍历其中的元素进行求和,又要求和 q 次,时间复杂度就为 O(n*m*q),那么是一定会超时的。
与上道题类似,我们也可以预先将所有子矩阵的和求出来,并放到一个数组中,后续只需查询这个数组,就可以知道某个子矩阵内的元素之和了。这也会使求和 q 次的时间复杂度降低到 O(q)。
【Tips】二维前缀和算法的一般解题步骤:
- 预处理出一个前缀和矩阵;
- 使用这个前缀和矩阵。
设 arr 为原始矩阵,dp 为 arr 的前缀和矩阵,dp[i][j] 表示从 [ 1,1 ] 到 [ i,j ] 这块矩阵内的所有元素之和。
我们可以用中学时所学的面积公式,来推导 dp[i][j] 的递推公式:
由此可得:dp[i][j] = dp[i-1][j] + dp[i][j-1] + arr[i][j] - dp[i-1][j-1]。
而要求题目中的 [x1,x2] 到 [x2,y2] 之间的矩阵元素之和,也可以用中学时所学的面积公式来推导:
.2- 代码编写
#include <iostream>
#include<vector>
using namespace std;
int main() {
int n=0, m=0,q=0;
cin>>n>>m>>q;
vector<vector<int>> arr(n+1,vector<int>(m+1));
for(int i=1;i<=n;i++)
{
for(int j=1;j<=m;j++)
{
cin>>arr[i][j];
}
}
vector<vector<long long>> dp(n+1,vector<long long>(m+1));
for(int i=1;i<=n;i++)
{
for(int j=1;j<=m;j++)
{
dp[i][j]=dp[i-1][j]+dp[i][j-1]-dp[i-1][j-1]+arr[i][j];
}
}
while(q--)
{
int x1=0,y1=0,x2=0,y2=0;
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)寻找数组的中心下标
.1- 题目解析
由题,在一个无序数组中,中心下标左侧所有元素之和等于其右侧所有元素之和。
观察题目示例,要寻找中心下标,其实就是寻找以中心下标为界线的两个子数组,它们的和是相等的,那么我们可以分别从数组头部开始,向后依此求出数组的前缀和,然后从数组的尾部开始,向前依此求出数组的后缀和。如果前缀和数组和后缀和数组中有一个数是相等的,说明这个数其实就对应了中心下标。
另外,为了 f[i] 和 g[i] 的递推公式不发生越界访问,应将 f[0] 初始化为 0,g[n - 1] 也初始化为 0。因为 f[i] 表示的是 [ 0,i - 1 ] 区间所有元素之和,则 f[1] = nums[0],于是 f[0] 就应该初始化为 0;而 g[i] 表示的是 [ 0,i - 1 ] 区间所有元素之和,则 g[n - 2] = nums[n - 1],于是 g[n - 1] 也应该初始化为 0。
.2- 代码编写
class Solution {
public:
int pivotIndex(vector<int>& nums) {
int n=nums.size();
vector<int> f(n),g(n);
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];
for(int i=0;i<n;i++)
{
if(f[i]==g[i])return i;
}
return -1;
}
};
4)除自身以外数组的乘积
.1- 题目解析
这道题相当于上道题的变形,思路整体还是一样的,只不过从求和变成了求积。
要求数组中除自己以外的元素之积,这个过程其实就以某个下标元素,将数组划分成了两部分,只需将目标元素两边的子数组的元素之积相乘,就得到除自己以外的元素之积了。
因此,我们还是需要求前缀积和后缀积,然后将各个下标位置的前缀积和后缀积相乘,就能得到除某个下标元素以外的元素之积了。但特别的,为了 f[i] 和 g[i] 的递推公式不发生越界访问,应将 f[0] 初始化为 1,g[n - 1] 也初始化为 1。
.2- 代码编写
class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
int n=nums.size();
vector<int> f(n),g(n),ret(n);
f[0]=g[n-1]=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];
for(int i=0;i<n;i++)ret[i]=f[i]*g[i];
return ret;
}
};
5)和为 K 的子数组
.1- 题目解析
不难先想到暴力方法,将所有以 i 位置为结尾的子数组枚举一遍,就能找到和为 k 的子数组了。可是这样时间复杂度就有 O(n^2),是一定会超时的。
既然是要看所有以 i 位置为结尾的子数组,和是否为 k,那其实不难想到用前缀和来优化这个暴力方法。
假设这个数组的前缀和数组为 sum,则 sum[i] 表示 [ 0,i ] 区间内所有元素之和。如果和为 k 的子数组出现在了 [ 0,i ] 区间内并以 i 为结尾,那么这个子数组左侧的所有元素之和就等于 sum[i] - k。由此,就将“找到以 i 位置为结尾的、和为 k 的子数组”转化为了“在 [ 0,i - 1 ] 内找到前缀和为 sum[i] - k 的子数组”。
但仅仅预处理好前缀和数组,后续还需要遍历前缀和数组,来找出前缀和为 sum[i] - k 的子数组,并统计它们的个数,这个操作的时间复杂度就为 O(n),再加上预处理时其实也要将所有以 i 位置为结尾的子数组枚举一遍,整个过程的时间复杂度就来到了 O(n^2 + n),甚至还不如暴力方法。
因此,还需要对“找出前缀和为 sum[i] - k 的子数组”这个过程进行优化,以期能快速找到前缀和为 sum[i] - k 的子数组的个数。此时,可以引入哈希表,来为前缀和为 sum[i] - k 与 子数组个数之间建立映射关系,这样查找子数组个数的时间复杂度就降到了 O(1)。
前缀和加入哈希表的时机,应该是在计算完 i 位置的前缀和之后,在计算 i 位置的前缀和之前,哈希表中只保存 [ 0,i - 1 ] 位置的前缀和,这样哈希表才不会统计到多余的值。
而对于前缀和数组,其实并不用真的要创建。在计算 i 位置的前缀和时,仅需知道 i - 1 位置,即前一个位置的前缀和即可,至于 i - 2、i - 3 等位置的前缀和其实是无须关心的。因此,用一个变量 sum 来标记前一个位置的前缀和,也是足够的。
特别的,如果整个数组的和为 k ,此时就相当于在 [ 0,-1 ] 区间上找一个前缀和为 0 的子数组,但实际上是存在一个和为 k 的子数组的,即原数组本身,因此,为了避免漏算这种情况,哈希表在初始化时,需要特别加入<0,1>这个键值对。
.2- 代码编写
class Solution {
public:
int subarraySum(vector<int>& nums, int k) {
unordered_map<int,int> hash;
hash[0]=1; //特别初始化
int sum=0,ret=0;
for(auto e:nums)
{
sum+=e; //计算当前位置的前缀和
if(hash.count(sum-k)) //如果有子数组的前缀和为sum - k,就统计结果
ret+=hash[sum-k];
hash[sum]++; //把当前位置的前缀和计入哈希表
}
return ret;
}
};
6)和可被 K 整除的子数组
.1- 题目解析
这道题其实与上一道类似,也会用到“前缀和 + 哈希表”,只不过还涉及一点除法和取模的场外知识:
- 同余定理:若两数之差能够整除一个数,那么两数分别模这个数的结果均相等。
- C++ 和 Java 中,负数 % 正数 = 负数。要进行正负数统一的修正,需要公式 (a % p + p) % p
回到本题, 假设这个数组所有元素之和为 sum,x 为一个以 i 为结尾的子数组的前缀和,且 sum - x 等于一个可被 k 整除的数,则可得到以下等式:
由此,“找到和可被 K 整除的子数组”就转化成了“在 [ 0,i - 1 ] 内找到前缀和的余数为 sum % k 的子数组”。
而其余的就跟上一道题一模一样。
.2- 代码编写
class Solution {
public:
int subarraysDivByK(vector<int>& nums, int k) {
unordered_map<int,int> hash; //哈希表:<前缀和的余数,频次>
hash[0]=1; //特别初始化
int sum=0,ret=0;
for(auto x:nums)
{
sum+=x; //计算当前位置的前缀和
int r=(sum%k+k)%k; //取当前前缀和的余数
if(hash.count(r)) //统计结果
ret+=hash[r];
hash[r]++; //将当前位置前缀和的余数放入哈希表
}
return ret;
}
};
7)连续数组
.1- 题目解析
我们可以将数组中所有的 0 都改为 -1,这样一来,问题就转化成了“找到和为 0 的最长子数组”。类似于上文中《和为 K 的子数组》。
我们同样也用哈希表来存 <前缀和,下标>,以便快速找到符合题目条件的下标。如果又遇到了重复的<前缀和,下标>,就只保留已经遇到过的那一对。
特别的,如果整个数组的和为 0 ,此时就相当于在 [ 0,-1 ] 区间上找一个前缀和为 0 的子数组,但实际上是存在一个和为 0 的子数组的,即原数组本身,因此,为了避免漏算这种情况,哈希表在初始化时,需要特别加入<0,-1>这个键值对。
.2- 代码编写
class Solution {
public:
int findMaxLength(vector<int>& nums) {
unordered_map<int,int> hash; //<前缀和,下标>
hash[0]=-1; //特别的初始化
int sum=0,ret=0;
for(int i=0;i<nums.size();i++)
{
sum+=nums[i]==0?-1:1; // 计算当前位置的前缀和
if(hash.count(sum))ret=max(ret,i-hash[sum]); //统计结果
else hash[sum]=i; //没有存前缀和就将其存入哈希表
}
return ret;
}
};
8)矩阵区域和
.1- 题目解析
由题目示例,题目的意思其实是在原数组的基础上,按题目要求通过矩阵求和来衍生出一个同等规模的数组。
本题势必要用到二维前缀和及其递推公式:
设原数组为 mat,mat 的前缀和数组为 dp,最终返回的区域和数组为 ret。我们只需要根据二维前缀和公式,由 mat 数组来预处理好 dp,再用 dp 填充好 ret即可。但特别的,在根据公式用 dp 数组填充 ret 数组时,dp 数组中左上角和右下角的坐标要根据题目要求进行特殊处理,以防越界访问:
此外,为了方便处理边界,如果数组 mat 的大小为 n,我们就将前缀和数组 dp 的大小设为 n + 1,由此,dp 和 mat 之间下标的映射关系就会使公式发生变化:
最终,我们将 dp 数组中的结果根据公式填入 ret 数组即可,但由于 ret 数组和 mat 数组是同等规模的,因此同样要注意下标的映射关系。
.2- 代码编写
class Solution {
public:
vector<vector<int>> matrixBlockSum(vector<vector<int>>& mat, int k) {
int m=mat.size(),n=mat[0].size();
vector<vector<int>> dp(m+1,vector<int>(n+1));
vector<vector<int>> ret(m,vector<int>(n));
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];
}
}
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;
ret[i][j]=dp[x2][y2]-dp[x1-1][y2]-dp[x2][y1-1]+dp[x1-1][y1-1];
}
}
return ret;
}
};