【算法】前缀和

【ps】本篇有 2 道 Newcode OJ、6 道 LeetCode OJ。

目录

一、算法简介

二、相关例题

1)一维前缀和

.1- 题目解析

.2- 代码编写

2)二维前缀和

.1- 题目解析

.2- 代码编写

3)寻找数组的中心下标

.1- 题目解析

.2- 代码编写

4)除自身以外数组的乘积

.1- 题目解析

.2- 代码编写

5)和为 K 的子数组

.1- 题目解析

.2- 代码编写

6)和可被 K 整除的子数组

.1- 题目解析

.2- 代码编写

7)连续数组

.1- 题目解析

.2- 代码编写

8)矩阵区域和

.1- 题目解析

.2- 代码编写


一、算法简介

        前缀和是指,从数组的起始位置到某一位置的所有元素的和,主要应用于数组,有一维前缀和、二维前缀和之分,其本质是简易版的动态规划,使用时也会频繁涉及递推公式。

【ps】做前缀和类型的题目,切忌死记硬背、生搬硬套,关键在于学会前缀和的思想和推导过程,能在不同的题目中化用。

 

二、相关例题

1)一维前缀和

【模板】前缀和_牛客题霸_牛客网

 

.1- 题目解析

        题目要求计算一个一维数组中,[ l,r ] 区间内元素的和。

        不难想到暴力算法,每次查询数组时,遍历数组的 [ l,r ] 区间,将元素一个一个加起来求和。但这样查询 q 次,时间复杂度就来到了 O(n*q),是一定会超时的。那么有没有一种更优的算法来对一个区间进行求和呢?

        其实,我们可以预先将所有子区间的和求出来,放到一个数组中,后续只需查询这个数组,就可以知道[ l,r ] 区间内元素的和了。而这就是前缀和的思想,对数组中某一个连续区间快速求和,并将结果保存下来,使得查询 q 次的时间复杂度降低到 O(q)。

【Tips】一维前缀和算法的一般解题步骤:

  1. 预处理出一个前缀和数组;
  2. 使用这个前缀和数组。

        设前缀和数组为 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】二维前缀和算法的一般解题步骤:

  1. 预处理出一个前缀和矩阵;
  2. 使用这个前缀和矩阵。

        设 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)寻找数组的中心下标

724. 寻找数组的中心下标

 

.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)除自身以外数组的乘积

238. 除自身以外数组的乘积

.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 的子数组

560. 和为 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 整除的子数组

974. 和可被 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)连续数组

525. 连续数组

 

.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)矩阵区域和

1314. 矩阵区域和

 

.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;
    }
};

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值