场景引入
对于子数组问题,其实我们有很多的解决方法,如:
- 暴力法:这是最简单的方法,通过多层循环遍历实现,但这种方法在实际编程中的效率是比较慢的,而且经常会出现超时的问题。
- 前缀和:如果问题涉及到求子数组的和,可以使用前缀和技巧优化算法。首先,计算出数组的前缀和数组,其中每个位置的值是原来数组从开头到当前位置元素和。然后,可以通过计算两个前缀和之间的差值得到子数组和。这种方法的时间复杂度是O(1).
- 双指针法:特定的子数组问题(特别是有序数组),可以使用双指针来追踪子数组的起始位置和结束位置。通过移动指针并根据特定条件判断是否满足要求。
- 动态规划:使用动态规划的关键是定义好状态和状态方程,通常,状态表示以当前位置为结果的子数组的性质,然后通过状态转移方程更新状态
- 分治法:将数组划分为两个或多个子数组,并在子数组中递归的解决子问题。然后将子问题的结果合并到原问题的解
本章主要是总结归纳前缀和技巧,对于子数组问题的解法和总结,后续会在自己学习和总结中做出更新。本章目的:
- 熟悉前缀和思想
- 能够利用前缀和解决子数组问题。
前缀和的实现
那么到底什么是前缀和呢?什么又是子数组问题?简单引入几个小例子或许更好理解。
子数组问题?
- 示例1
- 示例2
给定一个数组 nums 和一个正整数 target , 找出满足和大于等于 target 的长度最短的连续子数组并返回其长度,如果不存在这种子数组则返回 0。
对于这个问题,一般思路,我们需要使用双层循环遍历,把所有的子数组穷举出来,计算他们的和,比较记录大于等于target的最短子数组。
- 示例3:
给定一个长度为 n 的数组 nums ,返回一个数组 res,res[i]是nums数组中除了nums[i]本身以外其余所有元素的乘积,即:𝑟𝑒𝑠[𝑖]=𝑛𝑢𝑚𝑠[1]×𝑛𝑢𝑚𝑠[2]×......×𝑛𝑢𝑚𝑠[𝑖−1]×𝑛𝑢𝑚𝑠[𝑖+1]......×𝑛𝑢𝑚𝑠[𝑛] res[i]=nums[1]×nums[2]×......×nums[i−1]×nums[i+1]......×nums[n]
而对于这道题,其实也是在考察前缀的思想,尤其看题目要求,不能使用除法,要求时间复杂度,那其实就是限制了我们使用暴力法。
那么我们再回头看一下第一个例子,我们想想如果我们每一次求子数组和的时候都要去做一个累加是不是效率就比较慢?那有什么方法可以快速得到某个子数组的和呢?其实我们只需要在做第一遍循环的时候就先计算出nums[0,i]的和,那么我们需要计算子数组和的时候只需要进行索引相减就可以了。这就是前缀和的思想。
前缀和技巧
int n = nums.length;
// 前缀和数组
int[] preSum = new int[n + 1];
preSum[0] = 0;
for (int i = 0; i < n; i++)
preSum[i + 1] = preSum[i] + nums[i];
看图,加参考代码,其实很好理解,其实就是对原数组进行一个预处理,预先将数组的下标和求出来存放到preSum前缀和数组中。那么当我们需要计算nums[i..j]区间和的时候,我们只需要nums[j+1]-nums[i]就可以得出结果了。
回到示例1,560. 和为 K 的子数组 - 力扣(LeetCode)要解决这个问题,首先我们使用前前缀和预先计算出数组和。后面根据题意,它需要我们找出和为k的子数组个数。那那么就是对preSum数组进行遍历相减,那么又是一个双层循环的遍历加判断。如下。
int subarraySum(int[] nums, int k) {
int n = nums.length;
// 构造前缀和
int[] sum = new int[n + 1];
sum[0] = 0;
for (int i = 0; i < n; i++)
sum[i + 1] = sum[i] + nums[i];
int ans = 0;
// 穷举所有子数组
for (int i = 1; i <= n; i++)
for (int j = 0; j < i; j++)
// sum of nums[j..i-1]
if (sum[i] - sum[j] == k)
ans++;
return ans;
}
优化解法
显然我们可以看见尽管我们使用了前缀和的思想,但是在计算个数的时候,我们还是用来嵌套for循环,在效率上面也没有很大的提升,时间复杂度仍然是O(n^2)。那有什么办法可以进行优化? 我们可以把 if 语句里的条件判断移项,这样写:
if (sum[j] == sum[i] - k)
ans++;
我直接记录下有几个sum[j]和sum[i]-k相等,直接更新结果,就避免了内层的 for 循环。我们可以用哈希表,在记录前缀和的同时记录该前缀和出现的次数。
int subarraysum(int[]nums,int k){
// map:前缀和-> 该前缀和出现的次数
int n = nums.length;
HashMap<Integer,Integer>preSum = new HashMap<>();
// base case
presum.put(0,1);
int ans=0;sum0_i= 0;
for(int i=0;i<n; i++){
sum0_i += nums[i];
//这是我们想找的前缀和nums[0..j]
int sum0_j=sum0_i-k;
// 如果前面有这个前缀和,则直接更新答案
if(presum.containskey(sum_j))
ans += preSum.get(sumo_j);
//把前缀和 nums[0..i]加入并记录出现次数
preSum.put(sum0_i,presum.getorDefault(sum0_i,0)+1);
}
return ans;
}
比如说下面这个情况,需要前缀和 8 就能找到和为 k 的子数组了,之前的暴力解法需要遍历数组去数有几个 8,而优化解法借助哈希表可以直接得知有几个前缀和为 8。
这样,就把时间复杂度降到了O(N),是最优解法了。
实战
这里leetcode已经整理的很好了,有需要可以直接在这里直接练手前缀和知识点题库 - 力扣(LeetCode) 接下来我根据上面对前缀和的总结,做几道题。当然这几道题都是labuladong 推荐的。
303. 区域和检索 - 数组不可变 - 力扣(LeetCode)
题目分析:对于这道题,题目并没有其他寻找要求,所以其实我们并不需要引入HashMap优化,直接构建一个数组类型的前缀和就欧克了。
class NumArray {
private int[] preNum;
public NumArray(int[] nums) {
preNum = new int[nums.length+1];
preNum[0] = 0;
// 前缀和预处理
for(int i =0;i<nums.length;i++){
preNum[i+1] = preNum[i] + nums[i];
}
}
public int sumRange(int left, int right) {
return preNum[right+1] - preNum[left];
}
}
下面是一道二维数组的题,看看前缀和是如何在二维数组中应用的。
304. 二维区域和检索 - 矩阵不可变 - 力扣(LeetCode)
其实这道题和一维数组是很相似的,也是计算出他们的和,进行相减
如果我想计算红色的这个子矩阵的元素之和,可以用绿色矩阵减去蓝色矩阵减去橙色矩阵最后加上粉色矩阵,而绿蓝橙粉这四个矩阵有一个共同的特点,就是左上角就是
(0, 0)原点。
那么此时我们维护的不再是一个一维数组啦,而是一个二维的数组preNum。
class NumMatrix {
private int[][] preNum;
public NumMatrix(int[][] matrix) {
int n = matrix.length, m = matrix[0].length;
preNum = new int[n+1][m+1];
for(int i = 1;i<= n;i++){
for(int j = 1;j<= m;j++){
preNum[i][j] = preNum[i-1][j] + preNum[i][j-1] - preNum[i-1][j-1] + matrix[i-1][j-1];
}
}
}
public int sumRegion(int row1, int col1, int row2, int col2) {
return preNum[row2+1][col2+1] - preNum[row1][col2+1] - preNum[row2+1][col1] + preNum[row1][col1];
}
}
preNum[i][j] = preNum[i-1][j] + preNum[i][j-1] - preNum[i-1][j-1] + matrix[i-1][j-1];这里解释一下为什么还需要减preNum[i-1][j-1],主要是因为我们在计算的时候出现了重复相加的情况。
其实在后面计算这个区间和的时候,其实就是将也来计算的操作倒过来就可以出结果了。
剩下的题型大家可以自己去做做
资料
本文笔记资料来源于leetcode,labuladong小站。