前缀和算法技法初章

315 阅读4分钟

前文我们简单学习了前缀和算法,相信各位少侠已经初具成效了。趁热打铁我们来实战运用,体会前缀和运用的不同场景。本文对应 初学者学习算法性价比最高的前缀和算法初章 先心法、后技法事半功倍哦~

724. 寻找数组的中心下标

image.png 给你一个整数数组 nums ,请计算数组的 中心下标

数组 中心下标 ****是数组的一个下标,其左侧所有元素相加的和等于右侧所有元素相加的和。

如果中心下标位于数组最左端,那么左侧数之和视为 0 ,因为在下标的左侧不存在元素。这一点对于中心下标位于数组最右端同样适用。

如果数组有多个中心下标,应该返回 最靠近左边 的那一个。如果数组不存在中心下标,返回 -1 。

示例 1:

输入: nums = [1, 7, 3, 6, 5, 6]
输出: 3
解释:
中心下标是 3 。
左侧数之和 sum = nums[0] + nums[1] + nums[2] = 1 + 7 + 3 = 11 ,
右侧数之和 sum = nums[4] + nums[5] = 5 + 6 = 11 ,二者相等。

示例 2:

输入: nums = [1, 2, 3]
输出: -1
解释:
数组中不存在满足此条件的中心下标。

示例 3:

输入: nums = [2, 1, -1]
输出: 0
解释:
中心下标是 0 。
左侧数之和 sum = 0 ,(下标 0 左侧不存在元素),
右侧数之和 sum = nums[1] + nums[2] = 1 + -1 = 0

题目解析

这道题的题意很好理解,其实就是找到一个中心下标i,满足

  • 下标在[0,i-1]的元素和 == 下标在[i+1,n-1]的元素和(n为数组长度)

示例1中用图表示就是以下这种情况:

image.png

  • 中心下标是 3 。
  • 左侧数之和 sum = nums[0] + nums[1] + nums[2] = 1 + 7 + 3 = 11 ,
  • 右侧数之和 sum = nums[4] + nums[5] = 5 + 6 = 11 ,二者相等。

理解好题意之后,我们再看一下数据量

  • 1 <= nums.length <= 10^4
  • -1000 <= nums[i] <= 1000

nums数组的长度是10^4,所以如果我们按照题意进行模拟,遍历数组计算每个下标前后的数组元素和,编写一个函数cal(int nums,int left,int right)用来遍历数组,计算下标到[left,right]中的元素和,它的时间复杂度是O(N),N是数组长度即10^4。调用次数最多是10^4次方。两者总共耗时是10^8次方,经过我们前面讲过的知识,是可以轻松通过的这道题的。(空间复杂度O(1))

力扣简单题目,我个人认为题目的简单并不纯粹是理解上的简单,好理解。更多的是数据量过于少,基本可以暴力破解,有些简单题想写的优雅难度不亚于「中等」甚至「困难」。明白了这个思想,我想少侠们或许可以多多尝试一下简单题哟,实在没有好的解法,就「一力降十会」~~

image.png

不过,我们这是「前缀和」技法篇章,相信有「悟性」高的少侠已经,想到了如何使用前缀和优化了。我们以上的思路cal()方法的时间复杂度是O(N)。而题目的题意就是要寻找中心下标i满足左侧数之和=右侧数之和

  • 左侧?右侧?不就是「区间」吗?
  • 数之和?不就是「和」吗?
  • 不就是计算「区间和」吗?

所以我们可以构建「前缀和」数组,cal()方法通过preSum[right+1] - preSum[left]快速得出区间和。

如果数组有多个中心下标,应该返回 最靠近左边 的那一个

这种题目说明其实很好懂,就是确定了我们的遍历方向是从左到右的,即下标从0开始正向遍历。找到满足条件的中心下标,直接return即可。

题目代码

暴力法

class Solution {
    public int pivotIndex(int[] nums) {
        int n = nums.length;
        for(int i=0;i<n;++i){
            //找到中心下标
            if(cal(nums,0,i-1)==cal(nums,i+1,n-1)) return i;
        }
        //没有中心下标
        return -1;
    }

    public int cal(int[] nums,int left,int right){
        int sum = 0;
        for(int i=left;i<=right;++i){
            sum += nums[i];
        }
        return sum;
    }
}

image.png

  • 时间复杂度:O(N2)O(N^2)
  • 空间复杂度:O(1)O(1)

前缀和

class Solution {
    int[] preSum;
    public int pivotIndex(int[] nums) {
        int n = nums.length;
        preSum = new int[n+1];
        //构建前缀和
        for(int i =1;i<=n;++i){
            preSum[i] = preSum[i-1] + nums[i-1];
        }

        for(int i=0;i<n;++i){
            if(cal(0,i-1)==cal(i+1,n-1)) return i;
        }
        return -1;
    }

    public int cal(int left,int right){
        return preSum[right+1] - preSum[left];
    }
}

image.png

由于题目对中心下标的定义:左侧数之和=右侧数之和所以我们其实还可以换一种符合条件的判断

  • 数组元素总和 = 数组中心下标元素 + 左侧数之和 + 右侧数之和
  • 而左侧数之和 = 右侧数之和
  • 即最终可得到数组元素总和 = 数组中心下标元素 + 2*左侧数之和

image.png

class Solution {
    int[] preSum;
    public int pivotIndex(int[] nums) {
        int n = nums.length;
        preSum = new int[n+1];
        //构建前缀和
        for(int i =1;i<=n;++i){
            preSum[i] = preSum[i-1] + nums[i-1];
        }
        //nums元素总和
        int sum = preSum[n];
        for(int i=0;i<n;++i){
            //preSum[i]:nums下标从[0,i-1]的元素之和,即左侧元素之和
            if(sum==nums[i]+2*preSum[i]) return i;
        }
        return -1;
    }
}

image.png 这种条件判断可能没有前面这种好理解,需要对「前缀和」数组的性质有更深层次的理解,同时又对题目条件进行了变换,这种条件「等式变换」的思想还是很重要的,可以慢慢体会。不过还是比较简单的。两种的时间、空间复杂度都是一样的。

  • 时间复杂度:O(N)O(N)
  • 空间复杂度:O(N)O(N)

1413. 逐步求和得到正数的最小值

image.png

给你一个整数数组 nums 。你可以选定任意的 正数 startValue 作为初始值。

你需要从左到右遍历 nums 数组,并将 startValue 依次累加上 nums 数组中的值。

请你在确保累加和始终大于等于 1 的前提下,选出一个最小的 正数 作为 startValue 。

示例 1:

输入: nums = [-3,2,-3,4,2]
输出: 5
解释: 如果你选择 startValue = 4,在第三次累加时,和小于 1 。
累加求和
                startValue = 4 | startValue = 5 | nums                  (4 -3 ) = 1  | (5 -3 ) = 2    |  -3
                  (1 +2 ) = 3  | (2 +2 ) = 4    |   2
                  (3 -3 ) = 0  | (4 -3 ) = 1    |  -3
                  (0 +4 ) = 4  | (1 +4 ) = 5    |   4
                  (4 +2 ) = 6  | (5 +2 ) = 7    |   2

示例 2:

输入: nums = [1,2]
输出: 1
解释: 最小的 startValue 需要是正数。

示例 3:

输入: nums = [1,-2,-3]
输出: 5

 

提示:

  • 1 <= nums.length <= 100
  • -100 <= nums[i] <= 100

题目解析

首先我们来看下这个题目,定义的累加和其实就是从左到右构建一个「前缀和」数组。

image.png

为保证累加和+startValue>=1且选择的startValue是符合条件中最小的。我们就只要找到前缀和中最小的值min。使得min+startValue>=1得出startValue>=1-min。数据量过小,所以通过是完全没问题的。

当然以上构建「前缀和」是需要额外定义数组的,空间复杂度是O(N)且并没有需要计算「区间和」,而只单单是遍历。所以我们也可以直接遍历,通过变量sum记录数组累加和同时使用min变量不断比较得到最小的累加和这样空间复杂度就为O(1)了。

题目代码

前缀和

class Solution {
    public int minStartValue(int[] nums) {
        int n = nums.length;
        int[] pre = new int[n+1];
        //startValue为最小正数,最小正数为1。所以初始化为0即可。
        int min = 0;
        for(int i=1;i<=n;++i) {
            pre[i] = pre[i-1] + nums[i-1];
            min = Math.min(pre[i],min);
        }
        return 1-min;
    }
}

image.png

  • 时间复杂度:O(N)O(N)
  • 空间复杂度:O(N)O(N)

遍历

class Solution {
    public int minStartValue(int[] nums) {
        int n = nums.length;
        //startValue为最小正数,最小正数为1。所以初始化为0即可。
        int min = 0,sum = 0;
        for(int i=0;i<n;++i) {
            sum += nums[i];
            min = Math.min(sum,min);
        }
        return 1-min;
    }
}

image.png

  • 时间复杂度:O(N)O(N)
  • 空间复杂度:O(1)O(1)

这道题目其实并没有涉及到「前缀和」的使用场景,即快速求「区间和」而是遍历求得累加和即可完成。不过还是运用了结果「等式变换」的思想。

1588. 所有奇数长度子数组的和

image.png 给你一个正整数数组 arr ,请你计算所有可能的奇数长度子数组的和。

子数组 定义为原数组中的一个连续子序列。

请你返回 arr 中 所有奇数长度子数组的和 。

 

示例 1:

输入: arr = [1,4,2,5,3]
输出: 58
解释: 所有奇数长度子数组和它们的和为:
[1] = 1
[4] = 4
[2] = 2
[5] = 5
[3] = 3
[1,4,2] = 7
[4,2,5] = 11
[2,5,3] = 10
[1,4,2,5,3] = 15
我们将所有值求和得到 1 + 4 + 2 + 5 + 3 + 7 + 11 + 10 + 15 = 58

示例 2:

输入: arr = [1,2]
输出: 3
解释: 总共只有 2 个长度为奇数的子数组,[1][2]。它们的和为 3 。

示例 3:

输入: arr = [10,11,12]
输出: 66

 

提示:

  • 1 <= arr.length <= 100
  • 1 <= arr[i] <= 1000

题目解析

首先我们要知道一个概念,就是子数组。子数组是原数组中的一个连续子序列。子序列是什么呢?子序列就是不需要连续而是在数组中保持相对顺序的元素组成的一个集合,子序列包含了子数组,即子数组是属于子序列的。

例如给出一个数组1,2,3,4,5下面这几个例子:

  • 1,3,5(子序列)
  • 1,2,4,5(子序列)
  • 1,2,3(子序列、子数组)
  • 2,3,4,5(子序列、子数组)
  • 2,1,4(无,元素出现的顺序不是数组中的相对顺序,1应该在2的前面出现)

我们明白了「子序列」和「子数组」概念,那么题目「奇数长度子数组的和」其实可以分为两部分

  1. 找到所有的奇数长度子数组。
  2. 计算奇数长度子数组的和。

第二点相信各位少侠能很快反应过来了,就是求数组的「区间和」,那么这道题的难点就在于如何找到所有奇数长度子数组。

其实就是每个区间的长度要为奇数,也就是1,3,5,7... 然后下标是从0开始的所以就是,0,2,4,6...

[1] = 1               长度为1
[4] = 4               长度为1
[2] = 2               长度为1
[5] = 5               长度为1
[3] = 3               长度为1
[1,4,2] = 7           长度为1
[4,2,5] = 11          长度为3
[2,5,3] = 10          长度为3
[1,4,2,5,3] = 15      长度为5

将以上区间用[开始下标、结束下标],并把相同开始下标的放在一行以便于观察,得出:

[0,0][0,2][0、4]
[1,1][1,3]
[2,2][2,4]
[3,3]
[4,4]

通过以上坐标我们可以发现规律,i代表横坐标、j代表纵坐标。n为数组长度

  • i的范围为[0,n-1]i+1的速度移动。
  • j的范围为[i,n-1]j+2的速度移动。

所以如果要打印以上的坐标,就可以使用两个for循环控制即可。

 for(int i =0;i<n;++i){
     for(int j =i;j<n;j=j+2){
       System.out.print("["+i+","+j+"]"+"\t");
    }
    System.out.prinln();
}

image.png

以上这种方法非常实用,如果需要用代码控制打印出一定规律的坐标,可以把坐标列出来,然后i代表横坐标,j代表纵坐标。发现它们的「所属范围」和「移动速度」。

构建出所有的奇数长度子数组后,我们就直接把它的「开始下标、结束下标」这个「区间和」通过「前缀和」快速计算即可。

题目代码

前缀和

class Solution {
    int[] pre;
    public int sumOddLengthSubarrays(int[] arr) {
        int n = arr.length;
        pre = new int[n+1];
        //构建前缀和
        for(int i =1;i<=n;++i) {
            pre[i] = pre[i-1] + arr[i-1];
        }
        int res = 0;
        //枚举出所有的奇数长度子数组
        for(int i =0;i<n;++i){
            for(int j =i;j<n;j=j+2){
                //累加得到的「区间和」
                res += cal(i,j);
            }
        }
        return res;
    }


    public int cal(int l,int r){
        return pre[r+1] - pre[l];
    }
}

image.png

由于数据量只有1000所有我们O(N2)O(N^2)也只有10^6是可以通过该题的。

  • 时间复杂度:O(N2)O(N^2)
  • 空间复杂度:O(N)O(N)

那么有没什么O(N)O(N)的解法呢?答案是:有的。大家可以想想,不过这里不太适合我们掌握,有兴趣的可以去官方评论去学习哦~

总结

  1. 前缀和属于空间换时间的算法,适用于频繁计算「区间和」。
  2. 题目给出的条件可以进行「等式变换」从而求得结果值。
  3. 想要表达出一定规律的坐标,可以列举出来从而进一步的发现规律写出相应的代码。

题目虽然简单但带来的方法和启示还是很多的,「千里之堤,溃于蚁穴」,额...应该是「千里之行,始于足下」「不积以跬步,无以至千里」!

心法链接:初学算法性价比最高的前缀和算法初章

image.png