数组 之 双指针法

201 阅读12分钟

双指针法

前文,创建了一个数组容器 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,这样也是通过的。

颜色分类

leetcode.cn/problems/so…

给定一个包含红色、白色和蓝色、共 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++;
            }
        }
    
    }
    
}