专题一:双指针

122 阅读16分钟

常见的双指针有两种形式,一种是对撞指针,一种是快慢指针。

对撞指针:一般用于顺序结构中,也称左右指针。

  • 对撞指针从两端向中间移动。一个指针从最左端开始,另一个从最右端开始,然后逐渐往中间逼近。

  • 对撞指针的终止条件一般是两个指针相遇或者错开(也可以在循环内部找到结果直接跳出循环),也就是:

    • left == right (两个指针指向同一位置)
    • left > right (两个指针错开)

快慢指针:又称为龟兔赛跑算法,其基本思想就是使用两个移动速度不同的指针在数组或链表等序列结构上移动。

这种方法对于处理环形链表或数组非常实用。

其实不单单是环形链表或者数组,如果我们要研究的问题出现循环往复的情况时,均可考虑使用快慢指针的思想。

快慢指针的实现方式有很多,最常见的一种就是:

  • 在一次循环中,每次让慢指针往后移动一位,而快指针往后移动两位,实现一快一慢。

1. 移动零

数组分两块是非常常见的一种题型,主要就是根据一种划分方式,将数组的内容分成左右两部分。这种类型的题,一般就是使用双指针来解决。

1.1 题目链接

283.移动零

1.2 题目描述

给定⼀个数组 nums ,编写⼀个函数将所有 0 移动到数组的末尾,同时保持⾮零元素的相对顺序。

请注意 ,必须在不复制数组的情况下原地对数组进⾏操作。

示例 1:

输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]

示例 2:

输入: nums = [0]
输出: [0]

1.3 解法(快排的思想:数组划分区间 - 数组分两块):

算法思路

在本题中,我们可以⽤⼀个 cur 指针来扫描整个数组,另⼀个 dest 指针⽤来记录⾮零数序列的最后⼀个位置。根据 cur 在扫描的过程中,遇到的不同情况,分类处理,实现数组的划分。在 cur 遍历期间,使 [0, dest] 的元素全部都是⾮零元素, [dest + 1, cur - 1] 的元素全是零。

算法流程

a. 初始化 cur = 0 (⽤来遍历数组), dest = -1 (指向⾮零元素序列的最后⼀个位置。因为刚开始我们不知道最后⼀个⾮零元素在什么位置,因此初始化为 -1 )

b. cur 依次往后遍历每个元素,遍历到的元素会有下⾯两种情况:

  • i. 遇到的元素是 0 , cur 直接 ++ 。因为我们的目标是让 [dest + 1, cur - 1] 内的元素全都是零,因此当 cur 遇到 0 的时候,直接 ++ ,就可以让 0 在 cur - 1的位置上,从⽽在 [dest + 1, cur - 1] 内;

  • ii. 遇到的元素不是 0 , dest++ ,并且交换 cur 位置和 dest 位置的元素,之后让cur++ ,扫描下⼀个元素。

    • 因为 dest 指向的位置是⾮零元素区间的最后⼀个位置,如果扫描到⼀个新的⾮零元素,那么它的位置应该在 dest + 1 的位置上,因此 dest 先⾃增 1 ;

    • dest++ 之后,指向的元素就是 0 元素(因为⾮零元素区间末尾的后⼀个元素就是 0 ),因此可以交换到 cur 所处的位置上,实现 [0, dest] 的元素全部都是⾮零元素, [dest + 1, cur - 1] 的元素全是零。

1.4 C++算法代码:

class Solution {
public:
    void moveZeroes(vector<int>& nums) {
        for(int cur = 0, dest = -1; cur < nums.size(); cur++)
            if(nums[cur])
                swap(nums[++dest],nums[cur]);
    }
};

1.5 算法总结

这个方法是往后学习快排算法的时候,数据划分过程的重要一步。如果将快排算法拆解的话,这一段小代码就是实现快排的核心步骤

2 复写零

2.1 题目链接

1089.复写零

2.2 题目描述

给你⼀个⻓度固定的整数数组 arr ,请你将该数组中出现的每个零都复写⼀遍,并将其余的元素向右平移。

注意:请不要在超过该数组⻓度的位置写⼊元素。请对输⼊的数组就地进行上述修改,不要从函数返回任何东西。

示例 1:

输入: arr = [1,0,2,3,0,4,5,0]
输出: [1,0,0,2,3,0,0,4]
解释: 调用函数后,输入的数组将被修改为:[1,0,0,2,3,0,0,4]

示例 2:

输入: arr = [1,2,3]
输出: [1,2,3]
解释: 调用函数后,输入的数组将被修改为:[1,2,3]

2.3 解法(原地复写 - 双指针):

算法思路

如果从前向后进⾏原地复写操作的话,由于 0 的出现会复写两次,导致没有复写的数被覆盖掉。因此我们选择从后往前的复写策略。但是从后向前复写的时候,我们需要找到最后⼀个复写的数,因此我们的⼤体流程分两步:

i. 先找到最后⼀个复写的数;

ii. 然后从后向前进行复写操作。

算法流程

  • a. 初始化两个指针 cur = 0 , dest = 0 ;

  • b. 找到最后⼀个复写的数:

    • i. 当 cur < n 的时候,⼀直执⾏下⾯循环:
      • 判断 cur 位置的元素:
        • 如果是 0 的话, dest 往后移动两位;
        • 否则, dest 往后移动⼀位。
      • 判断 dest 时候已经到结束位置,如果结束就终⽌循环;
      • 如果没有结束, cur++ ,继续判断。
  • c. 判断 dest 是否越界到 n 的位置:

    • i. 如果越界,执⾏下⾯三步:
        1. n - 1 位置的值修改成 0 ;
        1. cur 向移动⼀步;
        1. dest 向前移动两步。
  • d. 从 cur 位置开始往前遍历原数组,依次还原出复写后的结果数组:

    • i. 判断 cur 位置的值:
        1. 如果是 0 : dest 以及 dest - 1 位置修改成 0 , dest -= 2 ;
        1. 如果⾮零: dest 位置修改成 0 , dest -= 1 ;
    • ii. cur-- ,复写下⼀个位置。

2.4 C++算法代码:

class Solution
 {
 public:
    void duplicateZeros(vector<int>& arr) 
    {
        // 1. 先找到最后⼀个数
        int cur = 0, dest = -1 ,n = arr.size();
        while(cur < n)
        {
            if(arr[cur])
                dest++;
            else
                dest += 2;
            if(dest >= n-1)
                break;
            cur++;
        }
        // 2. 处理⼀下边界情况
        if(dest == n)
        {
            arr[n-1] = 0;
            dest -= 2;
            cur--;
        }
        // 3. 从后向前完成复写操作
        while(cur >= 0)
        {
            if(arr[cur])
                arr[dest--] = arr[cur--];
            else
            {
                arr[dest--] = 0;
                arr[dest--] = 0;
                cur--;
            }
        }
    }

};

3 快乐数

3.1 题目链接

202.快乐数

3.2 题目描述

编写⼀个算法来判断⼀个数 n 是不是快乐数。

快乐数 定义为:

  • 对于⼀个正整数,每⼀次将该数替换为它每个位置上的数字的平⽅和。
  • 然后重复这个过程直到这个数变为 1,也可能是⽆限循环但始终变不到 1 。
  • 如果这个过程 结果为 1 ,那么这个数就是快乐数。
  • 如果 n 是 快乐数 就返回 true ;不是,则返回 false。

示例 1:

输入: n = 19
输出: true
解释: 12 + 92 = 82
82 + 22 = 68
62 + 82 = 100
12 + 02 + 02 = 1

示例 2:

输入: n = 2
输出: false

3.3 题目分析:

为了⽅便叙述,将对于⼀个正整数,每⼀次将该数替换为它每个位置上的数字的平⽅和这⼀个操作记为 x 操作;

题⽬告诉我们,当我们不断重复 x 操作的时候,计算⼀定会死循环,死的⽅式有两种:

  • 情况⼀:⼀直在 1 中死循环,即 1 -> 1 -> 1 -> 1......
  • 情况⼆:在历史的数据中死循环,但始终变不到 1

由于上述两种情况只会出现⼀种,因此,只要我们能确定循环是在情况⼀中进⾏,还是在情况⼆中进⾏,就能得到结果。

3.4 解法(快慢指针):

算法思路

根据上述的题⽬分析,我们可以知道,当重复执⾏ x 的时候,数据会陷⼊到⼀个循环之中。⽽快慢指针有⼀个特性,就是在⼀个圆圈中,快指针总是会追上慢指针的,也就是说他们总会相遇在⼀个位置上。如果相遇位置的值是 1 ,那么这个数⼀定是快乐数;如果相遇位置不是 1的话,那么就不是快乐数。

补充知识:如何求⼀个数 n 每个位置上的数字的平⽅和。

  • a. 把数 n 每⼀位的数提取出来:
    • 循环迭代下⾯步骤:
      • i. int t = n % 10 提取个位;
      • ii. n /= 10 ⼲掉个位;
    • 直到 n 的值变为 0 ;
  • b. 提取每⼀位的时候,⽤⼀个变量 tmp 记录这⼀位的平⽅与之前提取位数的平⽅和
    • tmp = tmp + t * t

3.4 C++算法代码:

class Solution {
public:
    int Sum(int n)
    {
        int sum = 0;
        while(n)
        {
            int tmp = n % 10;
            sum += tmp * tmp;
            n /= 10;
        }
        return sum;
    }

    bool isHappy(int n) {
        int slow = n, fast = Sum(n);
        while(slow != fast)
        {
            slow = Sum(slow);
            fast = Sum(Sum(fast));
        }
        return slow == 1;
    }
};

4 盛最多水的容器

4.1 题目链接

11. 盛最多水的容器

4.2 题目描述

给定一个长度为 n 的整数数组 height 。有 n 条垂线,第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。返回容器可以储存的最大水量。

说明: 你不能倾斜容器。

示例 1

image.png

输入: [1,8,6,2,5,4,8,3,7]
输出: 49 
解释: 图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49

示例 2:

输入: height = [1,1]
输出: 1

4.3 解法(对撞指针):

算法思路

设两个指针 left , right 分别指向容器的左右两个端点,此时容器的容积 :

v = (right - left) * min( height[right], height[left])

容器的左边界为 height[left] ,右边界为 height[right] 。

为了⽅便叙述,我们假设左边边界⼩于右边边界

如果此时我们固定⼀个边界,改变另⼀个边界,⽔的容积会有如下变化形式:

  • 容器的宽度⼀定变⼩。
  • 由于左边界较⼩,决定了⽔的⾼度。如果改变左边界,新的⽔⾯⾼度不确定,但是⼀定不会超过右边的柱⼦⾼度,因此容器的容积可能会增⼤。
  • 如果改变右边界,⽆论右边界移动到哪⾥,新的⽔⾯的⾼度⼀定不会超过左边界,也就是不会超过现在的⽔⾯⾼度,但是由于容器的宽度减⼩,因此容器的容积⼀定会变⼩的。

由此可⻅,左边界和其余边界的组合情况都可以舍去。所以我们可以 left++ 跳过这个边界,继续去判断下⼀个左右边界。

当我们不断重复上述过程,每次都可以舍去大量不必要的枚举过程,直到 left 与 right 相遇。期间产⽣的所有的容积⾥⾯的最⼤值,就是最终答案。

4.4 C++算法代码:

class Solution {
public:
    int maxArea(vector<int>& height) {
        int left = 0, right = height.size() - 1, ret = 0;
        while(left < right)
        {
            int v = min(height[left], height[right]) * (right - left);
            ret = max(v,ret);
            if(height[left] < height[right]) left++;
            else right--;
        }
        return ret;
    }
};

5 有效三角形的个数

5.1 题目链接

611. 有效三角形的个数

5.2 题目描述

给定一个包含非负整数的数组 nums ,返回其中可以组成三角形三条边的三元组个数。

示例 1:

输入: nums = [2,2,3,4]
输出: 3
解释: 有效的组合是: 
2,3,4 (使用第一个 2)
2,3,4 (使用第二个 2)
2,2,3

示例 2:

输入: nums = [4,2,3,4]
输出: 4

5.3 解法(排序 + 双指针):

算法思路

先将数组排序。

根据解法⼀中的优化思想,我们可以固定⼀个最⻓边,然后在⽐这条边⼩的有序数组中找 出⼀个⼆元组,使这个⼆元组之和⼤于这个最⻓边。由于数组是有序的,我们可以利用对撞指针来优化。

设最⻓边枚举到 i 位置,区间 [left, right] 是 i 位置左边的区间(也就是⽐它⼩的区

间):

  • 如果 nums[left] + nums[right] > nums[i] :
    • 说明 [left, right - 1] 区间上的所有元素均可以与 nums[right] 构成⽐nums[i] ⼤的⼆元组
    • 满⾜条件的有 right - left 种
    • 此时 right 位置的元素的所有情况相当于全部考虑完毕, right-- ,进⼊下⼀轮判断
  • 如果 nums[left] + nums[right] <= nums[i] :
    • 说明 left 位置的元素是不可能与 [left + 1, right] 位置上的元素构成满⾜条件的⼆元组
    • left 位置的元素可以舍去, left++ 进入下轮循环

5.4 C++算法代码:

class Solution {
public:
    int triangleNumber(vector<int>& nums) {
        // 1. 排序
        sort(nums.begin(), nums.end());

        // 2. 利用双指针解决问题
        int ret = 0, n = nums.size();
        for(int i = n - 1; i >= 2; i--) //先固定最大的数
        {
            // 利⽤双指针快速统计符合要求的三元组的个数
            int left = 0, right = i - 1;
            while(left < right)
            {
                if(nums[left] + nums[right] > nums[i])
                {
                    ret += right - left;
                    right--;
                }
                else
                {
                    left++;
                }
            }
        }
        return ret;
    }
};

6 查找总价格为目标值的两个商品

6.1 题目链接

LCR 179. 查找总价格为目标值的两个商品

6.2 题目描述

购物车内的商品价格按照升序记录于数组 price。请在购物车中找到两个商品的价格总和刚好是 target。若存在多种情况,返回任一结果即可。

示例 1:

输入: price = [3, 9, 12, 15], target = 18
输出: [3,15] 或者 [15,3]

示例 2:

输入: price = [8, 21, 27, 34, 52, 66], target = 61
输出: [27,34] 或者 [34,27]

6.3 解法

算法思路

注意到本题是升序的数组,因此可以⽤对撞指针优化时间复杂度。

算法流程:(附带算法分析,为什么可以使⽤对撞指针):

  • a. 初始化 left , right 分别指向数组的左右两端(这⾥不是我们理解的指针,⽽是数组的下标)
  • b. 当 left < right 的时候,⼀直循环
    • i. 当 nums[left] + nums[right] == target 时,说明找到结果,记录结果,并且返回;
    • ii. 当 nums[left] + nums[right] < target 时:
      • • 对于 nums[left] ⽽⾔,此时 nums[right] 相当于是 nums[left] 能碰到的⼤值(别忘了,这⾥是升序数组哈~)。如果此时不符合要求,说明在这个数组⾥⾯,没有别的数符合 nums[left] 的要求了(最⼤的数都满足不了你,你已经没救了)。因此,我们可以⼤胆舍去这个数,让 left++ ,去⽐较下⼀组数据;
      • • 那对于 nums[right] ⽽⾔,由于此时两数之和是⼩于⽬标值的, nums[right]还可以选择⽐ nums[left] ⼤的值继续努⼒达到⽬标值,因此 right 指针我们按兵不动;
    • iii. 当 nums[left] + nums[right] > target 时,同理我们可以舍去nums[right] (最⼩的数都满⾜不了你,你也没救了)。让 right-- ,继续⽐较下⼀组数据,⽽ left 指针不变(因为他还是可以去匹配⽐ nums[right] 更⼩的数的)。

6.4 C++算法代码:

class Solution {
public:
    vector<int> twoSum(vector<int>& price, int target) {
        int left = 0, right =price.size()-1;
        while(left < right)
        {
            int sum = price[left] + price[right];
            if(sum > target) right--;
            else if(sum < target) left++;
            else return {price[left],price[right]};
        }
        // 照顾编译器
        return {-1, -1};
    }
};

7 三数之和

7.1 题目链接

15. 三数之和

7.2 题目描述

给你一个整数数组 nums ,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != ji != k 且 j != k ,同时还满足 nums[i] + nums[j] + nums[k] == 0 。请你返回所有和为 0 且不重复的三元组。

注意: 答案中不可以包含重复的三元组。

示例 1:

输入: nums = [-1,0,1,2,-1,-4]
输出: [[-1,-1,2],[-1,0,1]]
解释:
nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0 。
nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0 。
nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 。
不同的三元组是 [-1,0,1][-1,-1,2] 。
注意,输出的顺序和三元组的顺序并不重要。

示例 2:

输入: nums = [0,1,1]
输出: []
解释: 唯一可能的三元组和不为 0 。

示例 3:

输入: nums = [0,0,0]
输出: [[0,0,0]]
解释: 唯一可能的三元组和为 0

7.3 解法(排序+双指针):

算法思路

本题与两数之和类似,是⾮常经典的⾯试题。

  • 与两数之和稍微不同的是,题⽬中要求找到所有不重复的三元组。那我们可以利⽤在两数之和那⾥⽤的双指针思想,来对我们的暴⼒枚举做优化:
    • i. 先排序;
    • ii. 然后固定⼀个数 a :
    • iii. 在这个数后⾯的区间内,使⽤双指针算法快速找到两个数之和等于 -a 即可。
  • 但是要注意的是,这道题⾥⾯需要有去重操作~
    • i. 找到⼀个结果之后, left 和 right 指针要跳过重复的元素;
    • ii. 当使用完⼀次双指针算法之后,固定的 a 也要跳过重复的元素

7.4 C++算法代码:

class Solution {
public:
    vector<vector<int>> threeSum(vector<int>& nums) {
        // 1. 排序
        sort(nums.begin(),nums.end());
        // 2. 利⽤双指针解决问题
        vector<vector<int>> ret ;
        int n = nums.size();
        for(int i = 0; i < n; )
        {
            if(nums[i] > 0) break;  // ⼩优化
            int left = i + 1, right = n - 1 ,target = - nums[i];
            while(left < right)
            {
                int sum = nums[left] + nums[right];
                if(sum > target) right--;
                else if (sum < target) left++;
                else 
                {
                    ret.push_back({nums[i], nums[left], nums[right]});
                    left++,right--;
                    // 去重操作 left 和 right
                    while(left < right && nums[left] == nums[left - 1]) left++;
                    while(left < right && nums[right] == nums[right + 1]) right--;
                }
            }
            // 去重i
            i++;
            while(i < n && nums[i] == nums[i - 1] ) i++;
        }
        return ret;
    }
};

8 四数之和

8.1 题目链接

18. 四数之和

8.2 题目描述

给你一个由 n 个整数组成的数组 nums ,和一个目标值 target 。请你找出并返回满足下述全部条件且不重复的四元组 [nums[a], nums[b], nums[c], nums[d]] (若两个四元组元素一一对应,则认为两个四元组重复):

  • 0 <= a, b, c, d < n
  • abc 和 d 互不相同
  • nums[a] + nums[b] + nums[c] + nums[d] == target

你可以按 任意顺序 返回答案 。

示例 1:

输入: nums = [1,0,-1,0,-2,2], target = 0
输出: [[-2,-1,1,2],[-2,0,0,2],[-1,0,0,1]]

示例 2:

输入: nums = [2,2,2,2,2], target = 8
输出: [[2,2,2,2]]

8.3 解法(排序 + 双指针)

算法思路

  • a. 依次固定⼀个数 a ;
  • b. 在这个数 a 的后⾯区间上,利⽤三数之和找到三个数,使这三个数的和等于 target - a 即可。

8.4 C++算法代码:

class Solution {
public:
    vector<vector<int>> fourSum(vector<int>& nums, int target) {
        vector<vector<int>> ret;

        // 1. 排序
        sort(nums.begin(),nums.end());
        // 2. 利⽤双指针解决问题
        int n = nums.size();
        for(int i = 0; i < n; )// 固定数 a
        {
            // 利⽤ 三数之和
            for(int j =  i + 1; j < n; )// 固定数 b
            {
                // 双指针
                int left = j + 1, right = n - 1;
                long long aim = (long long)target - nums[i] - nums[j];
                while(left < right)
                {
                    int sum = nums[left] + nums[right];
                    if(sum > aim) right--;
                    else if(sum < aim) left++;
                    else
                    {
                        ret.push_back({nums[i], nums[j], nums[left++], nums[right--]});
                        // 去重一
                        while(left < right && nums[left] == nums[left - 1]) left++;
                        while(left < right && nums[right] == nums[right + 1]) right--;
                    }
                }
                // 去重二
                j++;
                while(j < n && nums[j] == nums[j - 1]) j++;
            }
            // 去重三
            i++;
            while(i < n && nums[i] == nums[i - 1]) i++;
        }
        return ret;
    }
};