知识点
知识点
前缀和解决子数组问题
问题描述
给定一个数组和一个整数和k,算出一共有几个和为 k 的子数组
解题思路:
思路很简单,我把所有子数组都穷举出来,算它们的和,看看谁的和等于 k 不就行了,但是,如何快速得到某个子数组的和呢,例如快速计算nums[i..j]的和。
因此,上述的穷举思路就不可以了,必须使用一种技巧:前缀和技巧。
前缀和技巧描述为:定义一个前缀和数组preSum[nums.length()+1],该数组的定义为preSum[i]为nums[0..i-1]个元素的和,因此preSum叫做前缀和数组。有了preSum数组,要快速求nums[i..j]的和,只需要preSum[j+1]-preSum[i]即可。
利用前缀和技巧,很容易可以得到一个新的前缀和解法:
public int subarraySum(int[] nums, int k) { int res = 0; // 定义前缀和数组 int[] preSum = new int[nums.length + 1]; for(int i = 1; i <= nums.length; i++) { preSum[i] = nums[i - 1] + preSum[i - 1]; } // 穷举所有的子数组 for (int j = 0; j < nums.length; j++) { for (int i = j; i >= 0; i--) { if (preSum[j + 1] - preSum[i] == k) { res++; } } } return res; }
该前缀和解法时间复杂度O(N^2)空间复杂度O(N),并不是最优的解法
这个前缀和解法还可以优化,优化思路为:第二层 for 循环在干嘛呢?翻译一下就是,在计算,有几个j能够使得sum[i]和sum[j]的差为 k。毎找到一个这样的j,就把结果加一。如果我直接记录下有几个sum[j]和sum[i]-k相等,直接更新结果,就避免了内层的 for 循环。优化后代码如下:
public int subarraySum(int[] nums, int k) { int res = 0; // 改用Hash表来作为前缀数组,Hash表定义为:前缀和->前缀和出现次数 HashMap<Integer, Integer> preSum = new HashMap<>(); // base case,即0出现一次 preSum.put(0, 1); // 遍历数组,求出答案 int sum0 = 0; int sum1 = 0; for (int i = 0; i < nums.length; i++) { // 求出前i个元素和 sum0 = sum0 + nums[i]; // 求出我们要找的前缀和sum0-k sum1 = sum0 - k; // 如果有该前缀和,更新答案res if (preSum.containsKey(sum1)) { res = res + preSum.get(sum1); } // 记录当前i个元素前缀和出现的次数 preSum.put(sum0, preSum.getOrDefault(sum0, 0) + 1); } return res; }
差分数组解决子数组问题
差分数组技巧一般都和前缀和技巧比较记忆。前缀和技巧,一般用于对不变数组的某个区间元素和的查询;差分数组技巧一般用于对不变数组的某个区间元素进行增减。
差分数组的一般问题描述:对一个数组的某些区间多次进行增、减操作,最后问数组的值
差分数组解题思路:
类似前缀和技巧构造的prefix数组,我们先对nums数组构造一个diff差分数组,diff[i]就是nums[i]和nums[i-1]之差:
// 构建差分数组 int[] diff = new int[nums.length]; diff[0] = nums[0]; for (int i = 1; i < nums.length; i++) { diff[i] = nums[i] - nums[i - 1]; }
通过差分数组反推回原来的数组也可以,代码如下:
// 反推回原数组 int[] nums = new int[diff.length]; nums[0] = diff[0]; for (int i = 1; i < diff.length; i++) { nums[i] = nums[i - 1] + diff[i]; }
如果要对nums数组的某个区间[i..j]进行增减操作,对应到diff数组是什么呢?根据反推的步骤,我们可以看出,假如对nums的[i..j]进行+3操作,则相当于diff[i] += 3,并且diff[j+1] -=3。
原理很简单,回想diff数组反推nums数组的过程,diff[i] += 3意味着给nums[i..]所有的元素都加了 3,然后diff[j+1] -= 3又意味着对于nums[j+1..]所有元素再减 3,那综合起来,是不是就是对nums[i..j]中的所有元素都加 3。
我们如果需要对nums某些区间的元素增减,只需要操作diff数组,然后反推回新nums即可。需要注意,如果j+1>=nums.length,说明区间为i到数组最后,所以不用diff[j+1] -=val这个过程。
快速选择算法
快速选择算法和快速排序算法相似,它一般用于解决无序数组中快速取第k个大小的元素这种问题
快速选择算法解释:
快速选择算法就是快速排序的简化版,复用了排序的函数,快速定位第 k 大的元素。相当于对数组部分排序而不需要完全排序,从而提高算法效率
复用的排序函数如下:
public int sort(int[] nums, int left, int right) { // 将left索引的值作为分界值 int mid = nums[left]; // 定义两个指针用来遍历。因为要进行--j,因此j定义为right+1 int i = left, j = right + 1; // 开始排序 while(true) { while(nums[++i] < mid) { if (i == right) break; } while(nums[--j] > mid) { if (j == left) break; } if (i >= j) break; // 交换元素 int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp; } // 将nums[left]即mid交换到应该在的地方 int temp = nums[j]; nums[j] = nums[left]; nums[left] = temp; // 最后,返回分界点的索引 return j; }
LeetCode算法
LeetCode算法
560.【和为K的子数组】
解题思路:
使用优化后的前缀和解法。
1109.【航班预订统计】
解题思路:
该问题核心就是对数组的某些区间增减,因此使用差分数组技巧解题即可。
215.【数组中的第K个最大元素】
解题思路:
有两种解题思路:二叉堆(优先级队列)解法和快速选择算法解法。
二叉堆解法:
利用小顶堆,让数组中所有元素过一遍小顶堆,在这个过程中,如果堆中元素大于k个,则删除堆顶元素,直到过完数组所有元素,并且堆内元素只有k个,则堆顶的元素就是第k大的元素(因为小顶堆特性,堆顶的是整个堆中最小的元素)。
二叉堆插入和删除的时间复杂度和堆中的元素个数有关,在这里我们堆的大小不会超过k,所以插入和删除元素的复杂度是O(logK),再套一层 for 循环,总的时间复杂度就是O(NlogK)。空间复杂度很显然就是二叉堆的大小,为O(K)。
二叉堆解法简单不易出错,因此推荐使用该解法
快速选择算法解法:
该解法更加巧妙,时间复杂度更低,是快速排序的简化版
我们知道,快速排序的关键是如下的函数,它可以将分界点索引p前后的元素变成前面全部都是小于p索引的,后边都是大于p索引的:
public int process(int[] nums, int l, int r) { // 左右索引相同,分界索引p为l或r if (l == right) return l; // 将l索引的元素作为分界元素 int mid = nums[l]; // 开始排序。因为先执行--j,因此j应该初始化为r+1 int i = l, j = r + 1; while(true) { while(nums[++i] < mid) { if (i == r) break; } while(nums[--j] > mid) { if (j == l) break; } if (i >= j) break; // 交换nums[i]和nums[j] int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp; } // 将mid值交换到正确的位置,即中间的索引j的位置 int temp = nums[j]; nums[j] = nums[l]; nums[l] = temp; // 最后,返回中间索引即j return j; }
因此,可以看做经过该函数后,中间索引p的元素一定是第p+1大的元素。那么我们可以把p和k进行比较,如果p < k说明第k大的元素在nums[p+1..hi]中,如果p > k说明第k大的元素在nums[lo..p-1]中,我们可以多次调用该函数,这就是快速选择。