滑动窗口和双指针I
滑动窗口与双指针 I
「滑动窗口」和「双指针」,其实「滑动窗口」也可以称为「双指针」,在这个专题里会着重介绍这两类题型的思考路径。
引入
循环不变量
循环不变量」是指我们在编写代码的过程中,要一直循序不变的性质,这样的性质是根据要解决的问题,由我们自己定义的。「循环不变量」是我们写对一个问题的基础,保证了在「初始化」「循环遍历」「结束」这三个阶段相同的性质,使得一个问题能够被正确解决。
例题:
- 删除有序数组中的重复项
给你一个有序数组 nums ,请你原地删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。
不要使用额外的数组空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。
1 | 示例: |
思路:
给出两种写法,两种写法里 i 都是用于遍历的下标,而 j 的含义不同,
定义1:j 指向马上要赋值的元素的下标,因此我们定义区间 [0..j) (注意是左闭右开区间,j 不能取到)里没有重复元素。pre 永远指向第 1 个不重复的数字;
- 初始化:为了保证区间 [0..j) (注意这里是左闭右开区间)里没有重复元素。初始的时候,i = 0 ,下标为 0 的位置只有一个数,区间 [0..j) 一定不会出现重复,这件事情表示为区间 [0..0] 没有重复数字,即区间 [0..1) 没有重复数字,因此 j 初始化的时候需要等于 1;
- 保持:遇到和 pre 指向的数字相等元素,i++ 直接看到下一个元素,如果 nums[i] != pre ,表示程序看到了第 1 个不重复的数字,此时需要赋值 nums[j] = nums[i] 和 pre = nums[j] ,然后让 j++ 指向下一个需要赋值的下标;
- 终止:循环结束以后 i = len ,程序看完了输入数组的所有元素,此时区间 [0..j) 里没有重复元素,它的长度为 j ,返回 j。
- 参考代码1:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19public class Solution {
public int removeDuplicates(int[] nums) {
int len = nums.length;
if(len<2){
return len ;
}
int j=1;
int pre = nums[0];
for(int i=1; i < len;i++){
if(nums[i]!=pre){
nums[j] = nums[i];
pre = nums[j];
j++;
}
}
return j;
}
}
定义2 : 我们定义:区间 [0..j] (注意这里是左闭右闭区间)没有出现重复元素,此时变量 j 是上一轮找到的第 1 次出现的元素的下标。因此我们不需要像「定义 1」一样给 pre 定义。循环变量 i 看到的数值 nums[i] 永远和变量 j 看到的数值 nums[j] 进行比较。 大家可以对照上面的「定义 1」自行写出「初始化」「保持」「终止」的逻辑。
- 参考代码2:
1 | public class Solution { |
- 最长连续递增序列
给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。
连续递增的子序列 可以由两个下标 l 和 r(l < r)确定,如果对于每个 l <= i < r,都有 nums[i] < nums[i + 1] ,那么子序列 [nums[l], nums[l + 1], …, nums[r - 1], nums[r]] 就是连续递增子序列。1
2
3
4
5
6示例:
输入:nums = [1,3,5,4,7]
输出:3
解释:最长连续递增序列是 [1,3,5], 长度为3。
尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为 5 和 7 在原数组里被 4 隔开。
思路:
题目要求我们找的子序列是连续的,并且子序列里的元素要求 严格单调递增。在遍历的时候,从第 2 个元素开始;
如果当前遍历到的元素比它左边的那一个元素要严格大,「连续递增」的长度就加 11;
否则「连续递增」的起始位置就需要重新开始计算。
参考代码1
1 | public class Solution { |
参考代码2
1 | public class Solution { |
扩展练习
- 移除元素
给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素,并返回移除后数组的新长度。
1 | 示例: |
代码如下:
1 | class Solution { |
优化之后的代码
我们依然使用双指针,两个指针初始时分别位于数组的首尾,向中间移动遍历该序列。
算法: 如果左指针 left 指向的元素等于 val,此时将右指针 right 指向的元素复制到左指针 left 的位置,然后右指针 right 左移一位。如果赋值过来的元素恰好也等于 val,可以继续把右指针 right 指向的元素的值赋值过来(左指针 left 指向的等于 val 的元素的位置继续被覆盖),直到左指针指向的元素的值不等于 val 为止。
当左指针 left 和右指针 right 重合的时候,左右指针遍历完数组中所有的元素。
1 | class Solution { |
- 删除排序数组中的重复项 II
给你一个有序数组 nums ,请你 原地 删除重复出现的元素,使每个元素 最多出现两次 ,返回删除后数组的新长度。
不要使用额外的数组空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。
1 | 示例: |
思路:
因为给定数组是有序的,所以相同元素必然连续。我们可以使用双指针解决本题,遍历数组检查每一个元素是否应该被保留,如果应该被保留,就将其移动到指定位置。具体地,我们定义两个指针 slow 和 fast 分别为慢指针和快指针,其中慢指针表示处理出的数组的长度,快指针表示已经检查过的数组的长度,即 nums[fast] 表示待检查的第一个元素,nums[slow−1] 为上一个应该被保留的元素所移动到的指定位置。
因为本题要求相同元素最多出现两次而非一次,所以我们需要检查上上个应该被保留的元素 \textit{nums}[\textit{slow} - 2] 是否和当前待检查元素 \textit{nums}[\textit{fast}] 相同。当且仅当 \textit{nums}[\textit{slow} - 2] = \textit{nums}[\textit{fast}] 时,当前待检查元素 \textit{nums}[\textit{fast}]nums[fast] 不应该被保留(因为此时必然有 \textit{nums}[\textit{slow} - 2] = nums[\textit{slow} - 1] = \textit{nums}[\textit{fast}]。最后,\textit{slow} 即为处理好的数组的长度。
代码如下:
1 | class Solution { |
- 移动零
给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
请注意,必须在不复制数组的情况下原地对数组进行操作。1
2
3示例:
输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]
思路:
使用双指针,左指针指向当前已经处理好的序列的尾部,右指针指向待处理序列的头部。
右指针不断向右移动,每次右指针指向非零数,则将左右指针对应的数交换,同时左指针右移。
注意:
- 左指针左边均为非零数
- 右指针左边至左指针均为零
因此每次交换,都是将左指针的零与右指针的非零数交换,且非零数的相对顺序并未改变。
代码如下:
1 | class Solution{ |
使用循环不变量写对代码
我们在写代码的时候一定要明确自己对变量以及区间的定义是什么,并且在编写代码的过程中保持定义不变。
例题
- 颜色分类
给定一个包含红色、白色和蓝色,一共 n 个元素的数组,原地 对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。
此题中,我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。
1 | 示例: |
思路:
可以在区间上设置两个表示分界的位置,并且定义循环不变量;
- 所有元素在区间【0···Zero】= 0;
- 所有元素在区间【zero···i】= 1;
- 区间 [i..two) 是程序没有看到的部分;
- 所有的元素在区间 [two..len - 1] = 2,这里 len 表示数组的长度。
为了让初始化的时候三个区间都为空区间,zero = 0,two = len,程序没有看到的部分是整个数组。程序什么时候终止呢?当 i == two 时,三个子区间正好覆盖了整个数组,程序没有看到的部分为空,因此循环可以继续的条件是:i < two 。
代码如下:
1 | public class Solution { |
- 数组中的第 K 个最大元素
在未排序的数组中找到第 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
1
2
3
4示例:
输入: [3,2,1,5,6,4] 和 k = 2
输出: 5思路:
这道题可以使用优先队列(堆)完成。我们这里展示另一种使用快速排序 partition 的知识解决的办法。我们给出三种循环不变量的定义。
循环不变量定义 1: 把等于切分元素的所有元素分到了数组的同一侧。
我们定义 pivot = nums[left] ,剩下的区间 [left + 1..right] 被变量 le 分成三个部分:
- [left + 1 .. le] <= pivot;
- (le..i] > pivot;
- (i..right] 是程序没有看到的部分。
参考代码1:
1 | public class Solution { |
循环不变量定义 2 : 把等于切分元素的所有元素 等概率 地分到了数组的两侧,避免了递归树倾斜,递归树相对平衡。
我们定义 pivot = nums[left] ,剩下的区间 [left + 1..right] 被变量 le 、ge 分成三个部分:
- [left..le) <= pivot;
- [le..ge] 是程序没有看到的部分;
- (ge..right] >= pivot。
代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57public class Solution {
private static Random random = new Random(System.currentTimeMillis());
public int findKthLargest(int[] nums, int k) {
int len = nums.length ;
int left = 0;
int right = len -1 ;
// 第k个元素的下标是 len - k;
int target = len - k;
while(true) {
int index = partition(nums,left,right);
if(index == target){
return nums[index];
}else if(index < target){
left = index + 1;
}else{
right = index - 1;
}
}
}
public int partition(int[] nums ,int left , int right){
// 在区间随机选择一个元素作为标定点
int randomIndex = left + random.nextInt(right - left + 1 );
swap(nums, left, randomIndex);
int pivot = nums[left];
// 将等于 pivot 的元素分散到两边
// [left..le) <= pivot
// (ge..right] >= pivot
int le = left + 1;
int ge = right;
while(true){
// 遇到 nums[le] >= pivot 的时候停下来
// 遇到与 pivot 相等的元素,是通过交换被等概率分到两边的
while(le <= ge && nums[le] < pivot){
le++;
}
while(le <= ge && nums[ge] > pivot){
ge--;
}
if(le > ge){
break;
}
swap(nums,le,ge);
le++;
ge--;
}
swap(nums,left,ge);
return ge ;
}
}
循环不变量定义 3: 把等于切分元素的所有元素挤到了数组的中间,在有很多元素和切分元素相等的情况下,递归区间大大减少。
我们定义 pivot = nums[left] ,剩下的区间 [left + 1..right] 被变量 lt 、gt 分成四个部分:
- [left + 1..lt] < pivot;
- [lt + 1..i) = pivot ;
- [i..gt) 是程序没有看到的部分;
- [gt..right] > pivot。
代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63public class Solution {
private static Random random = new Random(System.currentTimeMillis());
public int findKthLargest(int[] nums, int k) {
int len = nums.length ;
int target = len - k;
int left = 0;
int right = len - 1;
while(true){
int[] pIndex = partition(nums,left,right);
int index1 = pIndex[0];
int index2 = pIndex[1];
if(target < index1){
// 下一轮搜索区间 [left..index1 - 1]
right = index1 - 1;
}else if(target == index1){
return nums[index1];
}else if(target < index2){
left = index1 + 1;
right = index2 - 1;
}else if(target == index2){
return nums[index2];
}else{
// pIndex > target
// 下一轮搜索区间 [index2 + 1..right]
left = index2 + 1;
}
}
}
private int[] partition(int[] nums, int left, int right) {
int randomIndex = left + RANDOM.nextInt(right - left + 1);
swap(nums, randomIndex, left);
// 循环不变量:
// all in [left + 1..lt] < pivot
// all in [lt + 1..i) = pivot
// all in [gt..right] > pivot
int pivot = nums[left];
int lt = left;
int gt = right + 1;
int i = left+1;
while(i < gt){
if (nums[i] < pivot) {
lt++;
swap(nums, i, lt);
i++;
} else if (nums[i] == pivot) {
i++;
} else {
gt--;
swap(nums, i, gt);
}
}
swap(nums,left,lt);
return new int[]{lt,gt-1};
}
}