双指针
对于双指针类型的题目,他通常可以被应用于: 快慢指针、滑动窗口、排序、相向指针 等类型的题目中,它不仅可以在一个序列中维持某种性质,也在两个序列使用。
在这些题目中,我们在实现算法时通常要考虑以下几点:
- 保证循环不变量,维持一段区间保持某种性质
- 大部分题目中,其中一个指针相对于另一个指针具有单调性
使用双指针还有一个明显的特征 : 我们使用朴素(暴力)的算法时,需要两层循环来实现,时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 2 ) O(n^2) </math>O(n2),此时就可以使用双指针,将两层循环中的第二层循环抵消,时间复杂度降低到 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n ) O(n) </math>O(n)
看一道基础题体会一下:
LeetCode-27.移除元素
给你一个数组 nums
和一个值 val
,你需要[原地] 移除所有数值等于 val
的元素,并返回移除后数组的新长度。不要使用额外的数组空间,你必须仅使用 O(1)
额外空间并 [原地]修改输入数组。
示例:给出数组[3,2,2,3]
以及要移除的元素3,我们要做的就是把不是3的数值全部放入数组的前面,后面的数值无所谓。
text
输入: nums = [3,2,2,3], val = 3
输出: 2, nums = [2,2]
暴力解法如何?代码如下:
js
var removeElement = function (nums, val) {
let size = nums.length;
for (let i = 0; i < size; i++) {
if (nums[i] === val) {
for (let j = i + 1; j < size; j++) {
nums[j - 1] = nums[j]
}
size--;
i--;
}
}
return size;
}
我们可以看到两层循环,并且i,j就相当于两个指针,此时我们考虑以下双指针是否可行:我们定义指针i(快),j(慢)
,然后需要维护一段区间[0,j)
中没有目标值(循环不变量),i每向前一步会遇到两种情况:
① 不是要移除元素--将其放到j+1
所指空间,然后j++
② 是要移除元素--继续前进
发现了什么? i在递增的过程中,j只会向前不会回退,换句话讲,j相对于i是单调的。 那么双指针可解:
js
var removeElement = function (nums, val) {
let j = 0;
for (let i = 0; i < nums.length; i++) {
if (nums[i] !== val) {
nums[j] = nums[i];
j++;
}
}
return j;
};
分析一下上述代码是否遵循了循环不变量:我们的不变量就是[0,j)
中没有目标值
- 初始状态:区间为
[0,0)
,此时区间无元素,故更不存在要删除的数 - 每次循环后:
- 如果不是要删除的值,将该值放入j,此时
[0,j)
无要移除元素,j++
,指向下一次遇到不是要移除元素时要放置的地方 - 如果是要删除的值,
i++
,j
没有变,因此保证了于上一次维护的区间一样,没问题
- 如果不是要删除的值,将该值放入j,此时
- 最终
[0,j)
内无要移除元素,那么长度就是j了
如果此时维护的区间是[0,j]
呢? 看代码:
js
var removeElement = function (nums, val) {
let j = 0;
//保证初始时[0,0]即首位元素不是要移除元素
if (nums[0] === val) {
for (let i = 0; i < nums.length; i++) {
if (nums[i] !== val) {
nums[0] = nums[i];
nums[i] = val;
break;
}
}
}
if (nums[0] === val) return 0;
for (let i = 1; i < nums.length; i++) {
if (nums[i] !== val) {
nums[++j] = nums[i];
}
}
return j + 1;
};
再次分析循环不变量:
- 初始化: 区间为
[0,0]
,那么第一个元素是否是目标元素我们并没有判断,因此这里需要进行初始化操作,初始化后可能出现首位还是要移除元素,说明整个数组中只有要移除元素,因此直接返回零,否则[0,0]
这个区间被我们维护好了 - 每次循环后:由于初始化时已经确定了首位,因此
i
应该从1开始遍历- 如果是要移除元素,
i++
,j
不变,区间不变 - 如果不是要移除元素,此时我们应该将其放到
j+1
的地方,然后让j
前移,这样区间不会变化
- 如果是要移除元素,
- 最后区间
[0,j]
中有j+1
个元素
ok,到这里已经有点感觉了,开始上几道题看看吧:
LeetCode-27.删除有序数组中的重复项
给你一个 非严格递增排列 的数组 nums
,请你 [原地] 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。元素的 相对顺序 应该保持 一致 。然后返回 nums
中唯一元素的个数。
示例:
text
输入: nums = [0,0,1,1,1,2,2,3,3,4]
输出: 5, nums = [0,1,2,3,4]
首先我们确定当一个快指针向前时,遇到了重复元素,慢指针需要回退? 不需要,因为遇到重复的就继续,不是重复的就放入区间,慢指针前移即可,满足了单调性
其次确定循环不变量:[0,j)
内无重复元素
- 初始化:
j
为零时,表示[0,0)
内无元素,由于题目是去重,实际上j可以从1开始,即[0,1)
为初始值 - 每次循环:
- 当
i
遇到一个新的元素时(值与j-1
不相同),直接放到j
处,j
前移,此时区间仍保持 - 当
i
遇到的值与j-1
相同时(重复值),j
不动,区间不变
- 当
- 结束时,长度为j
着手写代码:
js
var removeDuplicates = function (nums) {
let j = 1;
for (let i = 0; i < nums.length; i++) {
if (nums[i] !== nums[j - 1]) {
nums[j] = nums[i];
j++;
}
}
return j;
};
捋清楚后,直接秒杀🤩,继续。
LeetCode-283.移动零
给定一个数组 nums
,编写一个函数将所有 0
移动到数组的末尾,同时保持非零元素的相对顺序。
示例 :
text
输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]
这不就是移除元素0
嘛😉,希望大家自己分析一下循环不变量,这里直接上代码了:
js
function swap(nums, i, j) {
const temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
var moveZeroes = function (nums) {
let j = 0;
for (let i = 0; i < nums.length; i++) {
if (nums[i] !== 0) {
swap(nums, i, j);
j++;
}
}
};
注:由于这里不仅仅是删除0
,而是把0
移动到后面,因此我们遇到非零元素交换。
LeetCode-977.有序数组的平方
给你一个按 非递减顺序 排序的整数数组 nums
,返回 每个数字的平方 组成的新数组,要求也按 非递减顺序 排序。
示例 1:
text
输入: nums = [-4,-1,0,3,10]
输出: [0,1,9,16,100]
这道题与上面有所不同了,实际上并不是快慢指针,而是相向指针的题目,但是为了巩固之前谈到的性质,故而引出。
我们先来分析一下题目,一个非递减顺序的数组,并且数组中包含负数,最终需要一个该数组平方后的递增序列,那么他的最大值一定是左端点或右端点的平方,此时我们只要每次比较最左和最右就能确定当前的最大值了。
还具有我们之前说的单调性吗? 我的理解是仍然具有,因为我们需要每次循环时找到最大值,最大值又一定在两端,因此两个指针分别为头尾指针,无论当前这次循环那个指针动了,另一个指针一定要么不动,要么向内缩进,仍然单调。
再来分析一下循环不变量:
- 初始化:我们需要返回一个新数组,新增一个数组
res
,我们的循环不变量是什么呢?每次循环都从原数组中取出当前最大值放入res
,每次循环,总循环次数减一。 - 每次循环:比较得出最大值,最大值处的指针内缩,循环结束,等待下一次循环。
- 结束时,两指针相遇,将最后的元素平方推入
res
顶部。(这个res可以当作栈看)
js
var sortedSquares = function (nums) {
let i = 0, j = nums.length - 1;
const res = [];
let pos = nums.length - 1;
while (i <= j) {
//左边平方大于右边平方,将其
if (nums[i] * nums[i] >= nums[j] * nums[j]) {
res[pos] = (nums[i] * nums[i]);
i++;
} else {
res[pos] = (nums[j] * nums[j]);
j--;
}
pos--;
}
return res;
};
这道题中我们完全可以使用unshift()
来模拟栈操作,但是这样时间复杂的就上来了,因为unshift()
底层实际就是将目标数组的元素全部后移一位再添加到首部。好在JS中的数组是一个动态数组(长度动态扩展),因此我们可以设定pos
,由于res
的长度一定等于nums
,那么pos
就可以从nums.length-1
开始,每次循环前移一位即可,我们的while
循环时间复杂度严格的等于了n
,你甚至不用怕他越界👻。
本次双指针类题目大多是快慢指针,下次重点关注滑动窗口