前缀和专题学习

本文深入探讨了前缀和思想在解决数组子序列和子串问题中的应用,以及如何结合哈希表优化算法。通过实例解析LeetCode上的724.寻找数组的中心索引、560.和为K的子数组、1248.统计优美子数组等题目,展示了如何利用前缀和计算子数组和,以及如何通过哈希表实现O(1)查找,降低时间复杂度。文章还讨论了处理负数取模、数组长度限制等特殊情况的策略。
摘要由CSDN通过智能技术生成

今天我们来说一下刷题时经常用到的前缀和思想,前缀和思想和滑动窗口会经常用在求子数组和子串问题上,当我们遇到此类问题时,则应该需要想到此类解题方式,该文章深入浅出描述前缀和思想,读完这个文章就会有属于自己的解题框架,遇到此类问题时就能够轻松应对。

下面我们先来了解一下什么是前缀和。

前缀和其实我们很早之前就了解过的,我们求数列的和时,Sn = a1+a2+a3+...an; 此时Sn就是数列的前 n 项和。例 S5 = a1 + a2 + a3 + a4 + a5; S2 = a1 + a2。所以我们完全可以通过 S5-S2 得到 a3+a4+a5 的值,这个过程就和我们做题用到的前缀和思想类似。我们的前缀和数组里保存的就是前 n 项的和。见下图

前缀和数组初始化:

 for (int i = 0; i < nums.length; i++) {
      presum[i+1] = nums[i] + presum[i];
 }

例如我们需要获取 nums[2] 到 nums[4] 这个区间的和,我们则完全根据 presum 数组得到

leetcode 724. 寻找数组的中心索引

724. 寻找数组的中心下标 - 力扣(LeetCode)

 本题比较简单,让前缀和等于总和减去后缀和,再减去当前下标对应的数即可:

class Solution {
public:
    int pivotIndex(vector<int>& nums) {
        int len=nums.size();
        vector<int> presum(len);
        presum[0]=nums[0];
        for(int i=1;i<len;i++){
            presum[i]=presum[i-1]+nums[i];
        }
        int sum=presum[len-1];
        if(sum-nums[0]==0)return 0;
        for(int i=1;i<len;i++){
            int right=sum-presum[i];
            int left=presum[i-1];
            if(left==right){
                return i;
            }
        }
        return -1;
    }
};

leetcode 560. 和为K的子数组

首先暴力求法直接枚举,双重循环:

class Solution {
    public int subarraySum(int[] nums, int k) {
         int len = nums.length;
         int sum = 0;
         int count = 0;
         //双重循环
         for (int i = 0; i < len; ++i) {
             for (int j = i; j < len; ++j) {
                 sum += nums[j];
                 //发现符合条件的区间
                 if (sum == k) {
                     count++;
                 }
             }
             //记得归零,重新遍历
             sum = 0;
         }
         return count;
    }
}

那么本题怎么使用前缀和呢?本题要求是某个连续子数组等于k,那么最简单的方式就是通过前缀和相减的方式得到不同的子数组。

比如对于pre[1],那么我们让pre[len-1]去减pre[1],即可得到i~n的子数组的和,让pre[len-2]去减pre[1]可以得到1~n-1的子数组和。就这样遍历判断是否为k即可。

class Solution {
    public int subarraySum(int[] nums, int k) {
        //前缀和数组
        int[] presum = new int[nums.length+1];
        for (int i = 0; i < nums.length; i++) {
            //这里需要注意,我们的前缀和是presum[1]开始填充的
            presum[i+1] = nums[i] + presum[i];
        }
        //统计个数
        int count = 0;
        for (int i = 0; i < nums.length; ++i) {
            for (int j = i; j < nums.length; ++j) {
                //注意偏移,因为我们的nums[2]到nums[4]等于presum[5]-presum[2]
                //所以这样就可以得到nums[i,j]区间内的和
                if (presum[j+1] - presum[i] == k) {
                    count++;
                }
            }
        }
        return count;
    }
}

但是这样的方法时间复杂度为O(n²),有没有办法可以优化呢? 

前缀和+哈希算法

在做这题之前先来看看以前做过的这道题

 除了暴力法,但时间复杂度为O(n²),可以使用哈希表法来做

注意到方法一的时间复杂度较高的原因是寻找 target - x 的时间复杂度过高。因此,我们需要一种更优秀的方法,能够快速寻找数组中是否存在目标元素。如果存在,我们需要找出它的索引。

使用哈希表来做的话,也就是map。

在map中使用find函数查找的方法查找的是key值

因此我们存入map中,是将nums[i]存入map的key中,value值则设为i

class Solution {
public:
    vector<int> twoSum(vector<int>& nums, int target) {
        unordered_map<int,int> arrMap;
        for(int i=0;i<nums.size();i++){
            auto it=arrMap.find(target-nums[i]);
            if(it==arrMap.end()){
                arrMap[nums[i]]=i;
            }
            else{
                return {it->second,i};
            }
        }
        return {};
    }
};

题目就是两数之和,通过使用哈希表的查找,将时间复杂度从(n²)降到了O(n)。在上面的代码中,我们将数组的值和索引存入 map 中,当我们遍历到某一值 x 时,判断 map 中是否含有 target - x,即可。

那么本题也可以用同样的做法。

回顾我们本题的目的是什么,就是找到和为k的子数组,不过我们将子数组以另外一种更易读的方式,也就是前缀和的形式来存储,此时子数组就变成了数组中两个遍历的差值。也就是说目的是找到一个数组中两个的差值为k。

即pre[m]-pre[n]=k;

那么我们逆向思考,对于每个pre[m],想知道是否存在pre[n]满足上式,也就是说要满足pre[m]-k=pre[n],只需要看看是否存在pre[n]满足这个式子即可。那么判断是否存在的方式就可以通过哈希表来进行O(1)时间复杂度的查找!

比如,我们要找和为5,

那对于数组6、8、9、12

我们只需要在哈希表中查找是否存在6-5,8-5,9-5,12-5的数即可

另一方面,由于pre[n]这个前缀数组,我们只需要在每次生成pre[i]的时候去对应的查找即可,并不需要将pre[n]保存下来以供后续使用,所以此处可以优化空间复杂度。

最终代码如下:

class Solution {
public:
    int subarraySum(vector<int>& nums, int k) {
        unordered_map<int,int> mp;
        mp[0]=1;
        int count=0; 
        int pre=0;
        for(auto val:nums){
            pre+=val;
            if(mp.find(pre-k)!=mp.end())count+=mp[pre-k];
            if(mp.find(pre)==mp.end())mp[pre]=1;
            else mp[pre]++;
        }
        return count;
    }
};

除此之外,上面的代码中还有一点比较特别,mp[0]要设置成1,这是为什么呢?

因为假如k=6,然后出现了一个单一个数字的和就为6时,那么此时mp[6-6]=0;那么此时这组数据就不会被加入进去。

也就是说,原来的做法只考虑了两个或两个以上数字的加法的情况,那么为了使得单个数字也可以成立,或者说数组中假设说有0元素的存在。那么mp[0]=1也可以使得这种情况成立了。

自己写的代码如下:

class Solution {
public:
    int subarraySum(vector<int>& nums, int k) {
        int len=nums.size();
        vector<int> presum(len);
        presum[0]=nums[0];
        unordered_map<int,int> arrMap;
        arrMap[0]=1;
        arrMap[presum[0]]=1;
        int count=0;
        if(nums[0]==k)return 1;
        for(int i=1;i<len;i++){
            presum[i]=presum[i-1]+nums[i];
            //cout<<presum[i];
            if(arrMap.find(presum[i]-k)!=arrMap.end())count+=arrMap[presum[i]-k];

            if(arrMap.find(presum[i])==arrMap.end())arrMap[presum[i]]=1;
            else arrMap[presum[i]]++;
        }




        return count;
    }
};

leetcode1248. 统计优美子数组

接下来我们定义:

此处相比前缀和来说,前缀和是计算0~i子数组的和,此处是计算其有多少个奇数,本质是一样的。相当于遇到奇数则加1,遇到偶数则加0。

用这样的方式记录0~i的子数组有多少个奇数,然后想知道i~j的子数组有多少个奇数,只要相减即可:

既然本质和前缀和相同,那么此处也可以使用两数之和那样的思想。本题求的是哪些i到j的数组其奇数个数为k的,那么我们就使用哈希表来存储。

于是此处很简单,只需要将上面的这句代码改一下即可:

class Solution {
public:
    int numberOfSubarrays(vector<int>& nums, int k) {
        unordered_map<int,int> mp;
        mp[0]=1;
        int count=0; 
        int pre=0;
        for(auto val:nums){
            pre+=(val%2);
            if(mp.find(pre-k)!=mp.end())count+=mp[pre-k];
            if(mp.find(pre)==mp.end())mp[pre]=1;
            else mp[pre]++;
        }
        return count;
    }
};

leetcode 974 和可被 K 整除的子数组

首先我们知道,任意一个[i,j]区间的和,可以表示为:presum[j+1] - presum[i]。

那么我们想要判断区间 [i,j] 的和是否能整除 K,那么我们只需判断

(presum[j+1] - presum[i] ) % k 是否等于 0 即可,

我们假设 (presum[j+1] - presum[i] ) % k == 0;则

presum[j+1] % k - presum[i] % k == 0;

presum[j +1] % k = presum[i] % k ;

也就是说,只要两者对k取余数相等,就代表这两者的差能够整除k,例如12和7,这两个数字对5取余都是2,也就是说,12和7的差可以整除5。

所以对于presum[j+1],只需要计算出其和k的余数,比如12计算出其对5的余数为2,那么所有以2为余数的数字都可以满足。 比如2、7、12。

但是我们不可能去一个个的找2、7、12……因为这有无数个,我们应该找其余数为2的前缀和,所以我们将数存储在map中,我们将存储在哈希表中的key不再是前缀和,而是前缀和对k的取余。

于是代码如下:

但是此处可以注意到存在问题,问题是对负数取模会存在问题,对负数取模还是负数,那么这样在寻找key的时候则无法寻找到,所以我们需要将取余变为负数的值让它变为正数:

于是代码如下:

class Solution {
public:
    int subarraysDivByK(vector<int>& nums, int k) {
        unordered_map<int,int> mp;
        mp[0]=1;
        int count=0; 
        int presum=0;
        for(auto val:nums){
            presum+=val;
            int remainder=(presum%k+k)%k;
            if(mp.find(remainder)!=mp.end())count+=mp[remainder];
            if(mp.find(remainder)==mp.end())mp[remainder]=1;
            else mp[remainder]++;
        }
        return count;
    }
};

leetcode 523 连续的子数组和

本题与上题很类似,也是子数组为k的倍数,但是有两点区别,区别在于这一题要求子数组长度至少为2,第二点是本题只需判断是否存在,而无需计算数量。

方法一:

在之前计数的方法上进行改动!

此处要求子数组长度至少为2,那我们只需要在最终计数完成后,剔除那种单个元素即组成k的倍数的情况!如果最终计数大于0,说明有子数组长度>=2的子数组满足这个条件的情况!

改动如下:

此处为什么设置为long?因为k很大,使用int会溢出,所以设为long,并且假如long和int型混用会导致还是int型,所以presum也得设置为long。

class Solution {
public:
    bool checkSubarraySum(vector<int>& nums, int k) {
        unordered_map<int,int> mp;
        mp[0]=1;
        int count=0; 
        long presum=0;
        for(auto val:nums){
            presum+=val;
            long remainder=(presum%k+k)%k;
            if(mp.find(remainder)!=mp.end())count+=mp[remainder];
            if(mp.find(remainder)==mp.end())mp[remainder]=1;
            else mp[remainder]++;
        }
        for(auto val:nums)if(val%k==0)count--;
        return count>0;
    }
};

方法二:

方法一仅仅只能对长度为2的数组进行限制,其实就是剔除了长度为1的情况,如果要求长度不止为2,那么第一种方法就不好做了。此处用一种更为通用的方法。

回顾上题中为了计算数量,我们在哈希表中存储的是key为余数,value为数量。而本题我们不需要计算数量,但是需要知道长度差,为了知道长度差,我们需要知道该余数所对应的下标是否和当前遍历到的index,差值为2。

(为什么要求长度差值>=2而不是>=1?这是因为这里计算下标的差值,是得到的是长度,是前面前缀和得到的那个key值所对应的下标,而这个下标不包含在子数组里:如这个例子中k=5,

数组23、2、 4、 6、7

前缀和23、25、29、35、42

其中第0个前缀和为23,取余为5,当下标到2时候,前缀和为29,取余也为5,此时下标的差值是:2-0,即长度为2

那如何解决呢?就是将哈希表中由原来的每个余数对应的key存储数量,改为存储下标即可。

代码如下:

class Solution {
public:
    bool checkSubarraySum(vector<int>& nums, int k) {
        unordered_map<int,int> mp;
        mp[0]=-1;
        bool findFlag=false; 
        int presum=0;
        //int remainder=0;
        for(int i=0; i<nums.size();i++){
            presum+=nums[i];
            int remainder=(presum)%k;
            //remainder=presum%k;
            if(mp.find(remainder)!=mp.end()){
                int preIndex=mp[remainder];
                if(i-preIndex>=2)findFlag=true;
            }
            else if(mp.find(remainder)==mp.end()){
                mp[remainder]=i;//记录这个余数对应的下标,并且只有第一次时需要保存
            }
            
        }
        return findFlag;
    }
};

注意到上面的mp[0]=-1,此处比较难理解!理解不了此处可以暂时跳过!

原来mp[0]=1,是因为哈希表中存储的是次数,这是为了使得单个数字时也是一种解法。但是此处我们记录的是下标

我们需要添加一个mp[0]=-1,如果不添加,就会导致下面这种例子过不了:

为什么这种情况过不了呢?

换句话说,为什么设定mp[0]=-1就能过呢?

回想一下,我们判断子数组长度时,是通过当前下标减去原来同key第一次存储的下标,就得到了子数组长度。

如k=6,

数组    【1,1,2,4】

前缀和 【1,2,4,8】

key为  【1,2,4,2】

出现两个key相同时,子数组长度的计算方式如下:

此时子数组长度为2

但是例如k=6,数组[2,4]这种情况,此时

数组    【2,4】

前缀和 【2,6】

key      【2,0】

则此时导致map中最开始没有key为0的值。

那么我们就手动补一个,让mp[0]=-1

此时相减就能保证长度为2了。 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值