从头再来!社招找工作——算法题复习十:双指针/前缀和/滑动窗口
- 双指针/前缀和/滑动窗口
- 双指针
- 判断是否为回文字符串(Easy)
- 盛水最多的容器(Middle)
- 前缀和
- 数组的平均数值(Easy)
- 除自身外数组的乘积(Middle)
- 二维整数数组的左上角部分的前缀和(Hard)
- 滑动窗口
- 最长无重复字符的子串长度(Easy)
- 总和大于某个值的长度最小的子数组(Easy)
- 最大连续的1的个数(Hard)
双指针/前缀和/滑动窗口
本篇博客我们将把三个重要解题技巧放在一起讲,它们分别是双指针、前缀和和滑动窗口。为什么放在一起呢,因为实际上前缀和和滑动窗口都在双指针的范畴内,在具体实现时也会用到两个下标或者索引或者指针,所以我们先介绍明确使用了双指针的一些算法题,然后针对前缀和和滑动窗口讲解几道有意思的算法题。
双指针
双指针是一种解题技巧,它可以灵活运用在很多算法题题解中。
如果仔细阅读了我前面几篇博客的朋友们,相信对双指针一定不陌生了。我们在第二篇博客链表中介绍“如何判断链表中是否存在环”这道题目时,曾经提到过用快慢指针的方法。另外,在贪心算法中,也多次在代码注释中提到了双指针。其实,双指针的内涵很明确,就是用到了两个下标、索引或者指针,你可以给它们取名快、慢指针,也可以取名左、右指针,或者就是 i 和 j,反正只要是两个指针相互配合相互操作的,都可以叫做运用了双指针的解题技巧。
所以广泛上来说,我们已经运用过许多许多次双指针技巧了:二分查找中的左下标left和右下标right,链表的“合并有序链表”中一个指针指向A链表中的结点而另一个指向B链表中的结点,排序中快速排序确定哨兵元素后要设置左右指针,等等等。所以说,双指针的技巧,大家一定不陌生了,今天我们再来展示一些比较有趣的、用双指针可以快速解题的算法题。
判断是否为回文字符串(Easy)
牛客网
给定一个长度为 n 的字符串,请编写一个函数判断该字符串是否回文。
这道题不需要我来讲解了吧,相信大家都会做吧,左指针指向最左下标 0,右指针指向最右下标 length-1,然后向中间靠拢。代码如下:
public boolean isPalindrome(String s) {char[] cs = s.toCharArray();int left = 0;int right = cs.length - 1;while (left < right && cs[left] == cs[right]) {left++;right--;}return left >= right;
}
Leetcode上有一道题稍微对题目做了修改,但是内核思想是一样的。
盛水最多的容器(Middle)
牛客网
Leetcode
给定一个长度为 n 的整数数组 height。有 n 条垂线,第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。
找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水,返回容器可以储存的最大水量。你不能倾斜这个容器。
给大家一个 Leetcode 中例子,以便大家理解:
height[8] = {1, 8, 6, 2, 5, 4, 8, 3, 7}
left = 1, right = 8 是能盛最多水的容器。
这道题在Leetcode中是 Hard 难度的,但我觉得其实也就 Middel 难度。大家只需要知道两点,就能用双指针轻松结这道题:
(1)已知 left 和 right 了,当前能盛多少水?这个比较简单,能盛 min{height[left], height[right]} * (right - left) 的水。
(2)已知 left 和 right 了,双指针如何移动?这个大家根据例子自行演变,相信也能得出答案:哪个挡板矮,哪个挡板就向中间移动。
public int maxArea(int[] height) {int left = 0;int right = height.length - 1;int max = 0;while (left < right) {if (height[left] <= height[right]) {max = Math.max(height[left] * (right - left), max);left++;}else {max = Math.max(height[right] * (right - left), max);right--;}}return max;
}
前缀和
前缀和是双指针的一种特殊运用手段。它对于一维数组的解题过程一般是这样的:固定左指针 left 始终为0,右指针 right 则从 0 或者 1 开始向右遍历,在遍历的过程中不断对 (left, right) 区间的子数组进行某种操作。由于左指针不动、右指针向右,这个子数组就像是整个数组的前缀一样;又由于最常见的操作是加法,所以给这种方法取名叫“前缀和”。
数组的平均数值(Easy)
给定一个浮点数数组 nums,请返回一个浮点数数组,该数组中第 i 个元素是 nums 数组中前 i 个值的平均数。
这是一道我自己编出来的题目,用来给大家展示前缀和的基础应用。直接给出代码:
public float[] preAverage(float[] nums) {float[] result = new float[nums.length];result[0] = nums[0];for (int i = 1;i < len;i++) {nums[i] += nums[i-1]; // 相当于左指针一直为0,右指针为 iresult[i] = sum / (i+1);}return result;
}
除自身外数组的乘积(Middle)
Leetcode
给定一个整数数组 nums,返回数组 answer,其中 answer[i] 等于 nums 中除了 nums[i] 之外其余各元素的乘积。保证数组 nums之中任意元素的全部前缀元素和后缀元素的乘积都在 32 位整数范围内。
要求:请不要使用除法,且在 O(n) 时间复杂度内完成此题。
如果没有题目中最后一句话的限制,我们其实能想到两种基础方法:第一种,先计算出所有元素的总乘积,然后遍历数组进行除法运算;第二种,循环两遍,外部循环是确定当前元素不参与成绩,内部循环对所有非外部循环确定的元素求得总乘积。
但是很遗憾,题目中最后一句话把这两种方法的路都给堵上了。我们再细细读一下题目,发现有个关键部分“前缀元素和后缀元素”,这就是在提示我们要采用前缀和的技巧来解题。
有些朋友们会说了,这里是乘法不是加法啊,而且不止有“前缀”还有“后缀”呢!这里啊大家不要陷入思维定势,不要说“前缀和”就只能解决“前缀”与“和”的问题。如果把这个数组倒过来看,后缀不就是前缀嘛!或者说,我们固定右指针 right = nums.length - 1,左指针不断向左遍历,这也是一种前缀和的变体。另外,求和只是前缀和最常见的一种应用,还可以求乘积、求平均值,都是可以的。
所以这道题的解法,就是先求出某个元素的不包含自身的“前缀积”数组,再同样取得“后缀积”数组,最后二者相乘,即得到题目要求的除自身外数组的乘积。
public int[] productExceptSelf(int[] nums) {int len = nums.length;int[] prefix = new int[len];prefix[0] = 1; // 由于是乘法运算,nums[0]左边的总乘积应为 1 而不是 0for (int i = 1;i < len;i++) {prefix[i] = prefix[i-1] * nums[i-1];}int[] postfix = new int[len];int[] answer = new int[len];postfix[len-1] = 1;answer[len-1] = prefix[len-1] * postfix[len-1];for (int i = len-2;i >= 0;i--) {postfix[i] = postfix[i+1] * nums[i+1];answer[i] = prefix[i] * postfix[i];}return answer;
}
二维整数数组的左上角部分的前缀和(Hard)
给定一个二维整数数组 nums,请返回一个数组 sum,sum[i, j] 是 nums 数组中 (i, j) 的左上角(包含)部分的总和。
这也是一道我自己编出来的题目,用来给大家展示二维前缀和的基础应用。
我们先根据定义,写出 sum 数组中每个元素是由哪些 nums 元素相加得到的:
sum[0, 0] = nums[0, 0]
sum[i, 0] = nums[0, 0] + nums[1, 0] + … + nums[i, 0] = sum[i-1, 0] + nums[i, 0], i >= 1
sum[0, j] = nums[0, 0] + nums[0, 1] + … + nums[0, j] = sum[0, j-1] + nums[0, j], j >= 1
sum[i, j] = nums[0, 0] + nums[1, 0] + … + nums[0, j]
+ nums[1, 0] + nums[1, 1] + … + nums[1, j]
+ …
+ nums[i, 0] + nums[i, 1] + … + nums[i, j], i >= 1 and j >= 1
大家可以看一下,sum[i, j] 确实可以被形象地称为“前缀和”,将 nums[i, j] 的左上角的数字都加起来了。这里求 sum 数组的过程还有点动态规划的味道,先是 sum[0, 0],再是 sum[i, 0],接着 sum[0, j],最后 sum[i, j]。不过有个问题,就是 sum[i, j] 具体怎么求呢?总不能真的再来个二重循环把所以 nums 元素加起来吧,应该想一个和已有的 sum 元素相关的等式,这才是动态规划的思想。而这点,也正是二维数组前缀和的 keypoint!
这里直接给出公式:
sum[i, j] = sum[i-1, j] + sum[i, j-1] - sum[i-1, j-1] + nums[i, j];
一种不完备的证明如下:
上述问题可以画成这样的图:
我们要证明的是:sum[x2, y2] = sum[x1, y2] + sum[x2, y1] - sum[x1, y1] + Area((x1, y1)-(x2,y2))
先看等式左边 sum[x2, y2] = x2 * y2
再看等式右边
sum[x1, y2] + sum[x2, y1] - sum[x1, y1] + Area((x1, y1)-(x2,y2))
= x1 * y2 + x2 * y1 - x1 * y1 + (x2-x1)*(y2-y1)
= x1 * y2 + x2 * y1 - x1 * y1 + x2 * y2 - x2 * y1 - x1 * y2 + x1 * y1
= (x1 * y2 - x1 * y2) + (x2 * y1 - x2 * y1) + (x1 * y1 - x1 * y1) + x2 * y2
= x2 * y2
得证。
用动态规划来获得二维前缀和的代码如下:
public int[][] maxSideLength(int[][] mat, int threshold) {int m = mat.length;int n = mat[0].length;int[][] sum = new int[m][n];sum[0][0] = nums[0][0];for (int i = 1;i < m;i++) {sum[i][0] = sum[i-1][0] + nums[i][0];}for (int j = 1;j < n;j++) {sum[0][j] = sum[0][j-1] + nums[0][j];}for (int i = 1;i < m;i++) {for (int j = 1;j < n;j++) {sum[i][j] = sum[i-1][j] + sum[i][j-1] - sum[i-1][j-1] + nums[i][j];}}return sum;
}
由刚才的等式:sum[x2, y2] = sum[x1, y2] + sum[x2, y1] - sum[x1, y1] + Area((x1, y1)-(x2,y2)),
还可以扩展出:Area((x1, y1)-(x2,y2)) = sum[x2, y2] - sum[x1, y2] - sum[x2, y1] + sum[x1, y1],
就是通过二维前缀和,计算二维数组中任意一个矩阵区域内元素之和的公式。
滑动窗口
当看到题目中要求数组的子数组、字符串的子串等这种取整体的部分连续内容时,而且想要的值是子数组/子串满足某个条件的最长子数组/子串的长度或者满足条件的子数组/子串个数,我们就应该想到是不是可以用滑动窗口来做。
滑动窗口的解法有其特定的流程,以一维数组为例:先设定左指针 left = 0,右指针从 0 开始向右遍历直到找到满足条件的右边界为止;然后固定左指针,将右指针继续向右移动,直到不满足该条件为止;再将左指针向右移动,使得条件又被重新满足为止;接着右指针向右移动,直到不满足为止……这就像左右指针形成的窗口在不断滑动,寻找那些满足条件的窗口,并取最值或者计数。我们将这个过程形象地称为“滑动窗口”。
最长无重复字符的子串长度(Easy)
牛客网
Leetcode
给定一个字符串 s,请你找出其中不含有重复字符的最长子串的长度。
按照上述描述的流程,我们可以借助哈希思想来判断是否有重复字符,写出用滑动窗口解题的代码:
public int lengthOfLongestSubstring(String s) {char[] cs = s.toCharArray();int len = cs.length;if (len <= 1) {return len; // 特殊情况}Set<Character> set = new HashSet<>(); // 哈希,用集合来判断是否出现了重复字符int left = 0;int max = 0;for (int right = 0;right < len;right++) { // 固定左指针,移动右指针if (!set.contains(cs[right])) {set.add(cs[right]);}else {max = Math.max((right-1) - left + 1, max); // (left, right-1)就是要求的子串while (cs[left] != cs[right]) { // 固定右指针,移动左指针。左指针移动到 cs[left] == cs[right] 为止,且期间遇到的所有字符都要移出 set。set.remove(cs[left++]);}left++; // 因为 当前cs[left] == cs[right],所以左指针仍需自增一次}}max = Math.max((len - 1) - left + 1, max); // 右指针将整个数组遍历完之后,(left, len-1)也是个符合要求的子串return max;
}
中间的细节处理还是挺耐人寻味的,我就写错了很多细节,大家自行写一遍尝试一下。
总和大于某个值的长度最小的子数组(Easy)
Leetcode
给定一个含有 n 个正整数的数组和一个正整数 target。找出该数组中满足总和大于等于 target 的长度最小的子数组,并返回其长度。如果不存在这样的子数组则返回 0。
有了前一天的铺垫,本题还算简单了,不过注意代码中 if (sum >= target) {...}
为什么要做这个 sum >= target
判断。
public int minSubArrayLen(int target, int[] nums) {int len = nums.length;if (len == 1) { // 特殊情况return nums[0] <= target ? 0 : 1;}int left = 0;int sum = 0;int min = len + 1; // 设置一个绝不可能达到的较大值for (int right = 0;right < len;right++) { // 固定左指针,移动右指针sum += nums[right];if (sum >= target) {while (left <= right && sum >= target) { // 固定右指针,移动左指针sum -= nums[left++];}if (left > right) { // 特殊情况,nums[right] 一个值就大于 target 了return 1;}min = Math.min(right-(left-1)+1, min); // (left-1, right)就是要求的子数组}}if (min == len + 1) {return 0; // 极特殊情况:数组中所有元素加起来都达不到 target}else {return min;}
}
最大连续的1的个数(Hard)
Leetcode
给定一个二进制数组 nums 和一个整数 k,假设最多可以翻转 k 个 0,则返回执行操作后数组中连续 1 的最大个数。
这道题看起来很棘手,没有切入点。实际上,我们应该换个角度来看这个问题。
如果[left, right]区间内可以通过换 k 个以内的 0 使得其成为连续 1 的子数组,那么说明该区间内原来的 0 的个数小于等于 k。基于这一点,让我们对原题目换一种描述方法:
给定一个二进制数组 nums 和一个整数 k,请寻找 0 的个数小于等于 k 的最长子数组,并返回其长度。
这样一来,本题就变成了一道标标准准的滑动窗口题目,参照上面两题的解法就可以了,代码如下:
public int longestOnes(int[] nums, int k) {int len = nums.length;if (k >= len) {return len;}int left = 0;int cnt = 0; // 统计遇到的 0 的个数int max = 0;for (int right = 0;right < len;right++) { // 固定左指针,移动右指针cnt += 1-nums[right];if (cnt > k) { // // 固定右指针,移动左指针。由于 cnt 只可能等于 k+1,因此不需要 while、只减一次即可cnt -= 1-nums[left++];}max = Math.max(right - left + 1, max); // (left, right)正好是所求的最长子数组的范围}return max;
}