双指针法
前文,创建了一个数组容器 IntArray,内含对整型数组的增删改查:
public class IntArray {
private int[] data;
private int size;
关于数组在指定 index 位置删除元素,是将 下标集合为 [index+1,size) 的元素前移一位。
⨳ E remove(int index) 从数组中删除index位置的元素, 返回删除的元素
力扣有一道移除元素的题目 leetcode.cn/problems/re…
给你一个数组
nums和一个值val,你需要[原地] 移除所有数值等于val的元素,并返回移除后数组的新长度。不要使用额外的数组空间,你必须仅使用
O(1)额外空间并 [原地 ]修改输入数组。元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。
最简单的做法就是两层 for循环进行暴力破解:
for(int index=0;index<size;index++){
if(nums[index] ==val){
remove(index); // 使用 IntArray 的 remove 方法的逻辑,即将\[index+1,size) 的元素前移一位
index--;
}
}
两层 for 循环时间复杂度是O(n^2),今天介绍一个讨巧的方法:双指针法。
双指针法,顾名思义就是用两个指针遍历数组,有快慢指针和前后指针两种实现方式:
⨳ 快慢指针,两个指针都从头向尾遍历数组,只不过快指针是为了检索,慢指针用于记录
⨳ 头尾指针,头指针从从头向尾遍历数组,尾指针从尾向头遍历数组,头尾指针既可以检索又可以记录
头尾指针
先介绍头尾指针的用法,如果简化这道力扣题,让数组只删除一个元素 val,又不想搬移元素,该怎么做?
最先想到的方法,就是从头到尾遍历,找到指定的元素 val,将其与数组尾部的元素交换:
for(int index=0;index<size;index++){
if(nums[index] == val){
nums[index] = nums[size];
size--;
break;
}
}
头尾指针的大致思路,就是如此:
⨳ 头指针从头向尾遍历数组,如果遇到目标元素 val 就将其与尾部不等于 val 的元素进行交换,从而保证头指针左边的元素都不是目标元素;
⨳ 尾指针从尾向头遍历数组,如果遇到非目标元素 val,就将其与头部等于 val 的元素进行交换,从而保证尾指针右边的元素不是目标元素
大致思路有了,头尾指针怎么定呢,从哪里开始呢?头尾指针什么时候停止遍历呢?
假如 头指针指向数组的头元素,即索引为 0 的元素,尾指针指向数组的尾元素,即索引为 size-1 的元素,那么:
⨳ 头指针循环遍历前,[0,head_index ) 区间的元素都不是目标元素 val,执行一次循环体后,head_index 要加一,也要保证[0,head_index) 目标元素 val;
为什么区间是前闭后开呢?
▪ 后开的原因是,head_index 指向元素不在区间内,每次循环体执行只处理 head_index 指向的元素即可;
▪ 前闭的原因是,我们定的头指针最开始指向 0,如果前闭后闭 [0,head_index],会导致循环遍历前,[0,head_index)区间会有一个元素,这会导致索引为0的元素会被遗漏掉。
⨳ 尾指针循环遍历前,(tail_index,size-1] 区间的元素都是目标元素 val,执行一次循环体后,tail_index 要减一,也要保证 (tail_index,size-1] 是目标元素 val;
为什么区间是前开后闭呢?
▪ 前开的原因是,tail_index 指向元素不在区间内,每次循环体执行只处理 tail_index 指向的元素即可;
▪ 后闭的原因是,我们定的尾指针最开始指向size-1,和头指针前闭后开一样,循环遍历前要保证区间中没有元素,每次循环体执行完,都需要在区间中添加一个符合条件的元素。
⨳ 啥时候结束循环呢? 当 head_index = tail_index 时,可以吗?
当 head_index = tail_index = index 时,可以保证 [0,index) 区间的元素都不是目标元素,(index,size-1] 区间的元素都是目标元素,但 index 位置的元素漏掉了,所以只有当 head_index > tail_index 时,才可以停止遍历。
根据以上的分析,可以写代码了:
class Solution {
public int removeElement(int[] nums, int val) {
int head_index = 0; // 指向头元素
int tail_index = nums.length-1; // 指向尾元素
while(head_index <= tail_index){
// 头指针向尾部遍历,寻找不是目标值的元素
while(head_index <= tail_index){
if(nums[head_index]!=val){
head_index++;
continue;
}
break;
}
// 尾指针向头部遍历,寻找是目标值的元素
while(head_index <= tail_index){
if(nums[tail_index]==val){
tail_index--;
continue;
}
break;
}
// 交换元素
if(head_index<=tail_index){
int tail_val = nums[tail_index];
nums[tail_index] = nums[head_index];
nums[head_index] = tail_val;
head_index ++;
tail_index --;
}
}
return head_index;
}
}
上述代码还有优化的空间,比如:
⨳ 头尾指针移动的时候,可以将目标值的判断写到 while 条件里;
⨳ 理论上,我们仅仅需要保证 [0,head_index) 区间中的元素都不是目标值,至于 [tail_index,size-1) 中的元素是不是目标元素,不强求,因为这部分会舍弃掉,所以没必要进行交换。
class Solution {
public int removeElement(int[] nums, int val) {
int head_index = 0; // 指向头元素
int tail_index = nums.length-1; // 指向尾元素
while(head_index <= tail_index){
// 头指针向尾部遍历,寻找不是目标值的元素
while(head_index <= tail_index && nums[head_index]!=val)
head_index++;
// 尾指针向头部遍历,寻找是目标值的元素
while(head_index <= tail_index && nums[tail_index]==val)
tail_index--;
// 将右边不等于val的元素覆盖左边等于val的元素
if(head_index<=tail_index)
nums[head_index++] = nums[tail_index--];
}
return head_index;
}
}
虽然上述代码看起来是两层循环,但实际上使用头尾指针遍历数组,数组中的元素都只被访问了一遍,头指针从头到尾,尾指针从尾到头,直到双方碰头,所以时间复杂度是 O(n)。
使用头尾指针虽然效率比较高,但这会破坏原有元素的顺序,这点是需要注意的。
下面介绍一下快慢指针。
快慢指针
移除元素题目规定不要使用额外的数组空间,如果可以使用额外空间呢?
我直接新建一个数组,然后从头到尾遍历原数组,将非目标值的元素直接一个个添加进新数组,这样时间复杂的也是 O(n),顺序也不会发生改变。
那是不是可以将数组看作两部分呢?其实无论是头尾指针还是快慢指针都是隐形地将数组分成两部分:
⨳ 左部分为新数组,新数组起始长度为 0,随着循环体执行,不断从右部分拿出非目标值放入新数组;
⨳ 右部分为原数组,原数组最初长度为 size,新数组每次加一个元素多对应原数组减少一个元素;
如果说,头尾指针既可以检索又可以记录,那快慢指针对检索和记录的功能区分就很明显了:
⨳ 快指针:从从头向尾遍历数组,检索非目标值 val 的元素
⨳ 慢指针:跟在快指针后面,记录快指针检索到的非目标值 val
0 --(非目标值)--- 慢指针 --(目标值)-- 快指针 --(未检索部分)--size
快慢指针起止点怎么定,什么时候停止遍历呢?假设都从0开始:
⨳ 快指针先走,每次循环的目的就是使得 [slow_index,fast_index) 中的元素全是目标值(这点非必要,因为这部分会被舍弃,或被新数组覆盖);
⨳ 慢指针后走,记录快指针找到的非目标值,使得 [0,slow_index) 中的元素全是非目标值,slow_index 就是新数组的长度;
⨳ 当 fast_index == size 时,表示数组全部都检索到了,遍历停止。
class Solution {
public int removeElement(int[] nums, int val) {
int fast_index = 0; // 快指针
int slow_index = 0; // 慢指针
for(; fast_index<nums.length; fast_index++){
// 没有检索到非目标值,继续检索
if(nums[fast_index]==val)
continue;
// 找到目标值了,交给慢指针进行记录
nums[slow_index] = nums[fast_index];
slow_index++;
}
return slow_index;
}
}
快慢指针的时间复杂度也是 O(n),只是为了保证数组元素的顺序,数据移动的会比头尾指针多一点。
删除有序数组中的重复项
再看一道力扣题,leetcode.cn/problems/re…
给你一个 非严格递增排列 的数组 nums ,请你 [原地] 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。元素的 相对顺序 应该保持 一致 。然后返回 nums 中唯一元素的个数。
这道题看似是 移除元素 的升级版:
⨳ 相对顺序 保持 一致,说明不能使用头尾指针;
⨳ 目标值不再是一个固定值,而是重复项,这有点难搞,但仔细分析一个题目,给出的数组是 递增排列 的,也就是说是不是重复项,比较一下邻近的元素即可;
使用快慢指针很快就写出来了:
class Solution {
public int removeDuplicates(int[] nums) {
int fast_index = 1;
int slow_index = 1;
for(;fast_index<nums.length;fast_index++){
// 是重复项
if(nums[fast_index]==nums[slow_index-1])
continue;
// 不是重复项
nums[slow_index++] = nums[fast_index];
//slow_index++;
}
return slow_index;
}
}
注意,快慢指针最开始不是从 0 开始,而是从 1 开始,也就是说 [0,1) 中的元素不需要删除。
判断是不是重复项,是拿 fast_index 指向的元素与 [0,slow_index) 区间最右边的元素进行比较的。
删除有序数组中的重复项 II
再看一道力扣题:leetcode.cn/problems/re…
给你一个有序数组
nums,请你 [原地] 删除重复出现的元素,使得出现次数超过两次的元素只出现两次 ,返回删除后数组的新长度。不要使用额外的数组空间,你必须在 [原地] 修改输入数组** 并在使用 O(1) 额外空间的条件下完成。
这道题与删除有序数组中的重复项 唯一区别就是目标元素的判定,没啥好说的,直接上代码:
class Solution {
public int removeDuplicates(int[] nums) {
int fast_index = 2;
int slow_index = 2;
for(;fast_index<nums.length;fast_index++){
// 是重复项
if(nums[fast_index]==nums[slow_index-1] && nums[fast_index]==nums[slow_index-2] )
continue;
// 不是重复项
nums[slow_index++] = nums[fast_index];
}
return slow_index;
}
}
其实只用一个条件就能判断是不是重复项,大家想想是不是这回事:
nums[fast_index]==nums[slow_index-2]
移动零
再看一道力扣题:leetcode.cn/problems/mo…
给定一个数组
nums,编写一个函数将所有0移动到数组的末尾,同时保持非零元素的相对顺序。请注意 ,必须在不复制数组的情况下原地对数组进行操作。
这也是移除元素 的变体:
⨳ 将所有 0 移动到数组的末尾,相当于移除元素的目标值是 0
⨳ 保持非零元素的相对顺序,可以使用快慢指针。
闲话少说,直接写就完了:
class Solution {
public void moveZeroes(int[] nums) {
int fast_index = 0;
int slow_index = 0;
for(;fast_index<nums.length;fast_index++){
// 是目标值,继续检索非目标值
if(nums[fast_index]==0)
continue;
// 交换元素
int un_zero = nums[fast_index];
nums[fast_index] = nums[slow_index];
nums[slow_index] = un_zero;
slow_index++;
}
}
}
这道题说的是保留 0 ,所以使用交换元素使得 [slow_index,fast_index) 中的元素全是目标值。
其实不用交换也可以,直接使用 nums[slow_index++] = nums[fast_index]; 进行覆盖,得到新数组长度后,最后再补0,这样也是通过的。
颜色分类
给定一个包含红色、白色和蓝色、共
n**个元素的数组nums,[原地]对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。我们使用整数
0、1和2分别表示红色、白色和蓝色。
这道题可以看做是快慢指针,不过有头尾各有两个慢指针:
⨳ 快指针,从头到尾遍历数组,检索元素,如发现 ==0 的值将其插入到慢指针指向的位置;如发现 ==2 的值将其插入到尾部慢指针指向的位置;
⨳ 头部慢指针:每当有元素插入到头部慢指针指向的位置,头部慢指针加一,从而保证头部慢指针左侧的元素([0,head_slow_index))都是 ==0 的值。
⨳ 尾部慢指针,每当有元素插入到尾部慢指针指向的位置,尾部慢指针加一,从而保证尾部慢指针右侧的元素((tail_slow_index,size-1])都是 ==2 的值。
⨳ 当快指针和尾部慢指针相遇,意味着所有元素都已经归位,[head_slow_index,tail_slow_index] 中的元素都是 ==1 的:
class Solution {
public void sortColors(int[] nums) {
int fast_index = 0; // 快指针
int head_slow_index = 0; // 头部慢指针
int tail_slow_index = nums.length-1;
while(fast_index<=tail_slow_index){ // 快指针检索
// 如果快指针检索到等于 0 ,将其插入到头部慢指针指向的位置
if(nums[fast_index]==0){
int tmp = nums[head_slow_index];
nums[head_slow_index] = nums[fast_index];
nums[fast_index] = tmp;
head_slow_index++;
fast_index++;
}
// 如果快指针检索到等于 2,将其插入到尾部慢指针指向的位置
else if(nums[fast_index]==2){
int tmp = nums[tail_slow_index];
nums[tail_slow_index] = nums[fast_index];
nums[fast_index] = tmp;
tail_slow_index--;
// 因为交换后 fast_index 位置的元素没有被处理过,所以 fast_index 不用 ++
}
// 如果 nums[fast_index]==1
else{
fast_index++;
}
}
}
}