前端算法思想 -- 双指针

536 阅读11分钟

双指针

一般情况下,遍历数组(或者字符串)操作,都是采用单指针从前往后或者从后往前依次访问数组(或者字符串)中的元素。

而对于以下情况,只采用单指针处理,则会徒增时间复杂度和空间复杂度 双指针类型相关题目

image.png

1.1.双指针概述

一般情况下,对于数组或者字符串的遍历(线形结构)都是使用单指针从左向右依次遍历每个元素,这种情况下时间复杂度是O(n)。但是,对于以下情况如果使用单指针会增加时间复杂度和空间复杂度。
1.在数组或者字符串中不是找一个数字而是找两个或者两个以上的数字
例如:找到三数之和或者两数之和为0的组成。 单指针处理,则需要嵌套循环,一个循环遍历找一个数,另一个循环遍历找另一个数。 双指针处理,需要先进行排序预处理,然后只需要采用头尾指针,执行一次遍历,知道头尾指针相重合,时间复杂度为O(n),但是整体时间复杂度主要取决于排序算法,通常为 O(nlogn)。

2.对于数组或者字符串的反转
如果是单指针的话,需要在遍历的时候,将每个元素unshift进入数组中,遍历完之后缓存的数组就是结果,原来遍历的数组不需要了,这就增加了额外的O(n)的内存,空间复杂度为O(n)。
如果是双指针的话,那么对于数组的反转,可以通过头尾指针,加上一个变量缓存达到反转的效果,空间复杂度为O(1)。

总的来说,什么时候我们需要考虑双指针?主要包含两类问题:
1.循环嵌套转为单循环问题,优化时间复杂度
2.通过指针记录状态,优化空间复杂度

2.相关题目

2.1 两数之和

(1)输入为无序数组

题目: image.png 解答:

var twoSum = function(nums, target) {
    let head = 0;
    let last = nums.length - 1;
    let result = [];
    let arr = nums
    for (let i = 0; i < last; i++) {
        let cur = nums[i];
        let other = target - cur;
        if (nums.indexOf(other) !== -1 && nums.lastIndexOf(other) !== i) {
            result = [i, nums.lastIndexOf(other)];
            break;
        }
    }
    return result;
 }
(2)输入为有序数组 -- 双指针母题

示例 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
var twoSum = function(numbers, target) {
  let start = 0
  let end = numbers.length - 1
  // 因为是有序数组,所以可以根据指针所指的两数之和与target的大小,作为移动首尾指针的指导:大了end向前移动,小了start向后移动
  while (start < end) {
    const sum =  numbers[start] + numbers[end]
    if (sum === target) { 
        return [start+1, end+1]
    }
    if (sum < target) {
      start = start + 1
    }
    if (sum > target) {
      end = end - 1
    }
  }
};

2.2 三数之和

题目: image.png 下面是基本思想的代码(没有加上额外的主要条件)

分析:关于几个数字之和有一个共同的要求:即同一个元素不能在答案中重复出现。什么意思?
比如【1,1,1】如果采用排列的方式(即不考虑元素,只在乎元素的位置)那么答案为[1,1],[1,1],[1,1]。
这里的三个元素分别是按照index为0,1,2考虑的index对应为[0,1],[0,2],[1,2],这个是排列时候只考虑元素的index位置因素,并不考虑元素本身的数学值。
如果考虑数学值因素的话,那么就需要结果去重,答案为[1,1]。啥意思?就是说,index=0的时候已经找到了一个答案,对应的index为[0,2],因为尾指针开始是从index=2向前的,所以先找到[0,2]。
那么我们怎么在遍历的时候过滤呢?可以发现另外两个答案是[0,1],[1,2]。我们先来看[0,1],此时头指针不变,尾指针向前移动过程中,发现了当前尾指针平移之后的元素值arr[index = 1] === arr[index = 2],这意味着当头指针指向的元素是同一个arr【0】= 1,尾指针值为index和index-1所对应的值也是一样的,都是1,那么就意味着这两个答案是相同元素[arr[0], arr[2]], [arr[0], arr[1]],因此这个时候就需要去重。即,尾指针向前遍历的过程中,如果前一个和后一个相同时候,需要跳过,继续向前。
反之,对于头指针也是一样[1,2],也是[0,2]在尾指针不变的时候,头指针前后相同,导致结果重复。 那么,如果找到了对应的两个目标元素,为什么要同时head++,last--呢,因为假如head不变,last--,那么如果last--对应的元素值和原来的last相同是不是要跳过,如果不同,那么因为排序数组,前面肯定都没有了,因为target值是一定的,一个变量不变(假设为-2),另一个变量从2,变为1,那也不用继续last往前了。所以这个时候head不动,last动,如果last前面有和last相同值,那就是答案重复,如果没有(因为排序),那说明前面都不可能找到了。也没有继续的必要了。反之亦然,head动,last不动也是没有继续的必要。所以必须两个同时动。这个就是几数之和,最大的注意点!!!
解答:

var threeSum = function(nums) {
    var arr = nums.sort((a, b) => { return a-b })
    console.log(arr)
    var result= []
    for(let index = 0; index <  arr.length; index++) {
        let value = arr[index];
        let left_index = index + 1;
        let right_index = arr.length - 1;
        while (left_index < right_index) {
            var left_value = arr[left_index];
            var right_value = arr[right_index];
            let sum = value + left_value + right_value;
            if (sum === 0) {
                result.push([value, left_value, right_value]);
                left_index++;
                right_index--
            } 
            if (sum > 0) { right_index--; }
            if (sum < 0) { left_index++; }
        }
    }
    return result;
};

加上对应的限制条件后,因为题目中要求不包括重复的三元组,意思是如果是【-4,-1,-1,0,1】的话,当前值是-1,那么剩下的两个是-1,1,然后指针走到第二个-1就不用遍历了。因为这个数组中-1有两个,那么我拿出一个其中一个-1,剩下的元素组合和我拿另一个-1的元素组合拿肯定是相同的。所以这个主指针相同的时候,就可以跳过。如果遍历的话,那么就会有重复,比如主指针指向-4,那么左右遍历的时候左指针指向第一个和第二个-1的结果都给0,导致结果重复,如下:

image.png 以下代码中标注了哪些新增条件:

var threeSum = function(nums) {
    var arr = nums.sort((a, b) => { return a-b })
    console.log(arr)
    var result= []
    for(let index = 0; index <  arr.length; index++) {
        let value = arr[index];
        if (index > 0 && value === arr[index - 1]) { continue; } // 1.当前指针和前一个值相同,跳过这一次遍历
        let left_index = index + 1;
        let right_index = arr.length - 1;
        if (value > 0) { break; } // 2.当前指针值大于0,跳过这一次遍历
        while (left_index < right_index) {
            var left_value = arr[left_index];
            var right_value = arr[right_index];
            // 3.1 当前左指针index -1 不是当前index的时候 && 如果左指针对应的值等于前一次值,跳过
            if (left_index > index + 1 && left_value === arr[left_index - 1]) {
                left_index++;
                continue;
            }
            // 3.2 当前右指针index + 1 不是arr.length的时候 && 如果右指针对应的值等于后一次值,跳过
            if (right_index < arr.length - 1 && right_value === arr[right_index + 1]) { 
                right_index--;
                continue;
            }
            let sum = value + left_value + right_value;
            if (sum === 0) {
                result.push([value, left_value, right_value]);
                left_index++;
                right_index--
            } 
            if (sum > 0) { right_index--; }
            if (sum < 0) { left_index++; }
        }
    }
    return result;

2.3 最接近的三数之和

image.png

var threeSumClosest = function(nums, target) {
    var arr = nums.sort((a, b) => { return a-b; })
    var length = nums.length
    var result = nums[0] + nums[1] + nums[length - 1]
    for (let index = 0; index < nums.length - 2; index++) {
        let start = index + 1;
        let end = length - 1;
        while (start < end) {
            let sum = nums[index] + nums[start] + nums[end];
            if (Math.abs(target - sum) < Math.abs(target - result)) {
                result = sum;
            }
            if (sum > target) {
                end = end - 1;
            }
            if (sum < target) {
                start = start + 1;
            }
            if (sum === target) {
                result = sum;
                return sum;
            }
        }
    }
    return result;
};

三数之和模版,一个for循环用来主index遍历,一个while循环来控制两个指针。 什么时候指针不再移动? 每一次移动计算和,怎么控制左右指针的加减,不管怎么样,如果不满足要求指针一定会加或者减,否则就是退出,绝对不可能出现第四种情况,不加不减不退出!

2.4 长度最小的子数组

image.png 分析:求的是最小的子数组,关键字:子数组
那么子数组一定是在数组中的子集,即连续。那么就可以划分为以各个index开头的数组的集合,然后汇总为整个数组的结合。那么我们现在各个以index开头数组集合中找到最小的,然后对比,再对比各个集合最小的,最后得到整个数组中最小的。就是化大为小的思想。
方法1:暴力解法:这种解法对于数据量小的是可以实现的,但是对于大数据量的时候会时间会很长,可以看出,暴力是两层for循环,时间复杂度是O(nlogn)
注意:这里和三数之和或者n数之和的区别 三数之和的特点是三个数不是连续的,可以是分散的,所以可以通过预处理排序,然后再此基础上,for主循环是主index指针,剩下的两个指针分别是从首尾出发,然后根据当前总和判定需要移动头指针还是尾指针。
而连续子数组之和,因为是连续的所以index位置不能变,所以在第一层for循环时候,剩下的是通过另一个指针index2 = index1 + 1,一直遍历到结尾。通过slice计算中间之和。一旦超过target就立即break内部循环,跳到外部for循环,使主指针加1.

var minSubArrayLen = function(target, nums) {
    let result = [];
    let min = 0;
    for (let start = 0; start < nums.length; start++) {
        let sum = nums[start];
        if (sum >= target) { return 1; }
        for (let end = start + 1; end < nums.length; end++) {
            sum = sum + nums[end];
            if (sum >= target) {
                if (min === 0) {
                    min = end - start + 1;
                } else if (end - start + 1 < min) {
                    min = end - start + 1;
                }
                break;
            }
        }
    }
    return min;
};

2.5 颜色分类

image.png

var sortColors = function(nums) {
    let stash = '';
    for (let index1 = 0; index1 < nums.length; index1++) {
        for (let index2 = index1 + 1; index2 < nums.length; index2++) {
            if (nums[index1] > nums[index2]) {
                stash = nums[index1];
                nums[index1] = nums[index2];
                nums[index2] = stash;
            }
        }
    }
    return nums
};

分析:这个题目思路是通过找出数组中最小的元素,放在第一位,进而达到排序的目的。 主要分为两步骤: 1.找出数组中最小的元素:一个数组中怎么通过一次遍历找出最小值, 2.排序:主指针遍历一次

2.6 反转字符串

image.png

var reverseString = function(s) {
    let start = 0;
    let last = s.length - 1;
    let stack;
    while (last > start) {
        stack = s[start];
        s[start] = s[last];
        s[last] = stack;
        start++
        last--
    }
    return s
};

2.7 盛最多水的容器

image.png 这个题目可以看成是滑动窗口也可以看成是双指针问题,显然如图所示,我们最后的解一定是在其中的两个柱子形成的容器中,所以这个问题就转化为,我们应该怎么从初始化的两根边缘柱子向中间寻找到最优解。 容器最大有两个因素:
高度越大越好(取决于短的那个),宽度越大越好
所以我们当我们从首尾移动柱子的时候,我们应该如何移动柱子?也就是说我们应该移动左边还是右边,还是两个都移动。
答案:移动短的那一根。为什么?因为面积最后是看最短的那个柱子的高度,如果我们移动长的根柱子,移动后的结果有三种:移动到的那根柱子大于,等于,小于不动的柱子(短的不动)。 大于情况:距离变短了,高不变,还是短的为高 等于:距离变短了,高不变,一样高 小于:距离变短了,高短了,更小 这里是有一点贪心的思想在其中的,等到走完之后就可以找到最大值了。

var maxArea = function(height) {
    let start = 0;
    let end = height.length - 1;
    let result = 0;
    while (start < end) {
        let res = mian(start, end)
        result = Math.max(result, res);
        if (height[start] < height[end]) {
            start = start + 1
        } else {
            end = end - 1;
        }
    }
    function mian(start, end) {
        let height2 = Math.min(height[start], height[end]);
        let width = end - start;
        return height2*width
    }
    return result;
};

2.8 长按键入

总结

双指针本身没有难度,但是有麻烦点需要理清楚:
1.start指针和end指针在将近靠近的时候是我们需要注意的点:当start和end的距离只差一个单位时候,那么进入下一次循环后start+1,end-1,就变成了start+1 === end-1,那么此时双指针实现了指向同一元素。这个时候就标志着“双指针整个运动”的结束。
双指针母题 可以把两数之和作为双指针的母体

参考文献:mp.weixin.qq.com/s/SXj8tkGj1…