《前端算法宝典:双指针问题解析与应用》

发布于:2024-05-10 ⋅ 阅读:(25) ⋅ 点赞:(0)

双指针

双指针,指的是在遍历对象的过程中使用两个相同方向(快慢指针)或者相反方向(对撞指针)的指针或者是两个指针构成一个滑动窗口进行扫描,从而达到相应的目的。

双指针方法在某些情况下可以对有序数组的运算做一些优化。

接雨水

题目如图所示

在这里插入图片描述

解题思路

利用双指针从数组两侧向中间遍历,过程中分别计算左指针及其左侧最高高度和右指针及其右侧最高高度。找到对应的最高高度较低的那一列直接更新结果。

code

    window.onload = function () {

        var trap = function () {
            //定义数组长度
            let height = [0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1];
            let n = height.length;
            //定义左指针及其左侧最高,右指针及其右侧最高
            let left = 0;//左指针指向最左边的位置
            let leftMax = 0;
            let right = n - 1;//定义右指针指向数组末尾的位置
            let rightMax = 0;
            let res = 0;//定义变量记录结果

            while (left < right) {
                //找出数组两侧的最大值
                leftMax = Math.max(leftMax, height[left]);
                rightMax = Math.max(rightMax, height[right]);
                // console.log("数组左右两侧的最大值" + leftMax, rightMax)
                // 存水量取决于短板,数组两次最大值中较小的一个用指针遍历
                if (leftMax < rightMax) {
                    res += leftMax - height[left];
                    left++;
                    // console.log("数组左侧最大值较小的时候的res" + res)
                } else {
                    res += rightMax - height[right];
                    right--;
                    // console.log("数组右侧最大值较小的时候的res" + res)
                }
            }
            return res;
        };
        trap();
    }

代码中的console.log是为了更直观的看出数组两侧最大值和res的变化过程。

下面附上控制台的输出截图

在这里插入图片描述

对撞指针

对撞指针是指在数组中,将指向最左侧的索引定义为左指针(left),最右侧的定义为右指针(right),然后从两头向中间进行数组遍历。

对撞数组适用于连续数组和字符串,也就是说当你遇到题目给定连续数组和字符串时,应该第一时间想到用对撞指针解题。

验证回文串

题目如图所示

在这里插入图片描述

解题思路

先都转成大写(不然会出现 a A 判定为不相同)
设置头尾双指针,开启循环
如果指向的元素是不是有效的(不是字母和数字),则跳过
如果指向的元素有效,但不相同,则不是回文,返回false
否则有效,且相同,收缩指针,继续循环
直至指针相遇,循环结束,始终没有返回false,返回true。

code

/**
 * @param {string} s
 * @return {boolean}
 */
var isPalindrome = function (s) {
    // 将字符都转换为大写,避免大小写不同而误判
    s = s.toUpperCase();
    // 设置头尾双指针
    let left = 0;
    let right = s.length - 1;

    while (left < right) {
        // 如果左指针指向元素不是字母或者数字,跳过(左指针右移直接下一个)
        if (!isValid(s[left])) {
            left++;
            continue;
        }
        // 如果右指针指向元素不是字母或者数字,跳过(右指针左移直接下一个)
        if (!isValid(s[right])) {
            right--;
            continue;
        }
        // 如果左右指针指向字母和数字,但不相同,不是回文返回false;
        if (s[left] != s[right]) {
            return false;
        }
        left++;
        right--;
    }
    return true;
};

盛最多水的容器

题目如图所示

在这里插入图片描述

解题思路

定义双指针left,right对应数组的两侧,循环遍历height数组,left和right对应的高度较小的先向内移动,不断计算面积更新最大面积

复杂度:时间复杂度O(n),n是数组height的长度,遍历一次。空间复杂度O(1)

code

/**
 * @param {number[]} height
 * @return {number}
 */
var maxArea = function (height) {
    let maxArea = 0; // 用于存储最大面积
    let left = 0; // 左边界指针
    let right = height.length - 1; // 右边界指针

    // 当左指针小于右指针时循环
    while (left < right) {
        const minHeight = Math.min(height[left], height[right]); // 计算左右指针所指高度的最小值
        const width = right - left; // 计算当前宽度
        const area = minHeight * width; // 计算当前面积

        maxArea = Math.max(maxArea, area); // 更新最大面积

        // 移动指针,让高度较小的一侧向内移动,以寻找可能更大的面积
        if (height[left] < height[right]) {
            left++;
        } else {
            right--;
        }
    }

    return maxArea; // 返回最大面积
};

两数之和 II - 输入有序数组

题目如图所示

在这里插入图片描述

解题思路

定义左右指针位于数组两侧,左指针指向数组起始位,右指针指向末尾位。
计算当前左右指针对应值的和(两数之和)是否大于或者小于还是等于目标值。

因为数组是有序递增的,所以若两数之和大于目标值,应该右指针左移减小两数之和使其逼近目标值;

若两数之和小于目标值则需要减小和来逼近目标值,左指针右移,当两数之和等于目标值的时候,返回左右指针的索引,切记本题题目索引值是从1开始的,所以需要
返回的索引值加一。

在这里插入图片描述

code

/**
 * @param {number[]} numbers
 * @param {number} target
 * @return {number[]}
 */
var twoSum = function (numbers, target) {
    // 定义变量n获取数组的长度
    let n = numbers.length;
    // 定义左右指针
    let left = 0;
    let right = n - 1;

    while (left < right) {
        //比较当前数组两端最大值和最小值之和与目标值的大小
        //若当前数组两侧之和大于目标值右指针左移
        if (numbers[left] + numbers[right] > target) {
            right--;
            //若当前数组两侧之和小于目标值左指针右移
        } else if (numbers[left] + numbers[right] < target) {
            left++;
            // 若等于目标值则返回左右指针的索引(注意题目的索引是从1开始的,而代码是从0开始的,所以返回的索引值需要加一)
        } else {
            return [left + 1, right + 1]
        }
    }
    return [left + 1, right + 1];
};

三数之和

题目如图所示

在这里插入图片描述

解题思路

标签:数组遍历
首先对数组进行排序,排序后固定一个数 nums[i]nums[i]nums[i],再使用左右指针指向 nums[i]后面的两端,数字分别为 nums[L] 和 nums[R],计算三个数的和 sum 判断是否满足为 0,满足则添加进结果集
如果 nums[i]大于 0,则三数之和必然无法等于 0,结束循环
如果nums[i] == nums[i−1]nums,则说明该数字重复,会导致结果重复,所以应该跳过
当 sum == 0 时,nums[L] == nums[L+1]则会导致结果重复,应该跳过,L++
当 sum == 0 时,nums[R] == nums[R−1] 则会导致结果重复,应该跳过,R−−
时间复杂度:O(n^2) n 为数组长度

code

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
//  碰撞指针解题
var threeSum = function (nums) {
    let n = nums.length;//定义n为数组长度
    let ans = [] //记录结果的数组
    // 首先对数组进行排序
    nums.sort((a, b) => a - b);
    // 遍历数组
    for (let i = 0; i < n; i++) {
        // 若nums[i]大于零,那么三数之和一定大于零
        if (nums[i] > 0) break;
        // 如果当前数字与上一个数字一样,直接跳过
        // (注意数组越界问题)
        if (i > 0 && nums[i] == nums[i - 1]) continue;

        let left = i + 1;
        let right = n - 1;
        while (left < right) {
            //计算三数之和
            let s = nums[i] + nums[left] + nums[right]
            if (s == 0) {
               ans.push([nums[i],nums[left],nums[right]]);

                //指针当前指向和刚才的一样就直接跳过
                // 左指针所指当前数字和前一个数字一样,直接跳过去重
                while (left < right && nums[left] == nums[left + 1]) {
                    left++;
                }
                while (left < right && nums[right] == nums[right - 1]) {
                    right--;
                }
                left++;
                right--;
            } else if (s < 0) {
                left++;
            } else if (s > 0) {
                right--;
            }
        }
    }
    return ans
};

滑动窗口

滑动窗口实际是两个指针之间形成的区域,下面是滑动窗口的两个指针的移动方式

  1. 初始时,左右指针left,right都指向第0个元素,窗口为[left,right),注意这里是左闭右开,因此初始窗口[0,0)区间没有元素,符合我们的初始定义
  2. 开始循环遍历整个数组元素,判断当前right指针是否超过整个数组的长度,是退出循环,否则执行第3步
  3. 然后right指针开始向右移动一个长度,并更新窗口内的区间数据
  4. 当窗口区间的数据满足我们的要求时,右指针right就保持不变,左指针left开始移动,直到移动到一个不再满足要求的区间时,left不再移动位置
  5. 执行第2步

无重复字符的最长子串

题目如图

在这里插入图片描述

解题思路

在字符串s中定义两个指针,左指针指向数组首位索引,右指针指向数组第二索引

用一个临时数组temp保存[left,right)的字符(包含左指针不包含右指针);

若temp集合中包含s中right所指元素,左指针右移,不包含右指针右移

定义变量max用来存储最长子串。

右指针索引-左指针索引大于max的时候,将右指针索引-左指针索引赋给max

在while循环外返回max;

code

var lengthOfLongestSubstring = function (s) {
    if (s.length <= 1) {
        return s.length;
    }
    let left = 0;
    let right = 1;
    let max = 0;
    let temp;
    //    指针右移过程
    while (right < s.length) {
        //temp保存s中[left,right)的元素
        temp = s.slice(left, right);
        //若temp集合中包含s中right所指元素,左指针右移
        if (temp.indexOf(s.charAt(right)) > -1) {
            left++;
            continue;//跳出当前循环中之后的代码,进入下一次循环
        } else {
            right++;
        }
        if (right - left > max) {
            max = right - left;
        }

    }
    return max;
};

找到字符串中所有字母异位词

题目如图所示
在这里插入图片描述

解题思路

首先定义两个空对象 need 和 window,分别用来存储字符串 t 中每个字符的出现次数以及当前窗口中每个字符的出现次数。

遍历字符串 t,统计每个字符出现的次数,并保存在 need 对象中。

使用两个指针 left 和 right 分别表示窗口的左、右边界。另外定义了 valid 表示窗口中与 t 中字符匹配的数量,res 用来存储匹配成功的起始索引。

通过一个 while 循环,不断右移 right 指针,将新字符加入窗口。如果新字符在 need 中,则更新 window 中相应字符的出现次数,并检查是否与 need 中对应字符的出现次数匹配。

另外,在判断窗口大小超过 t 的长度时,通过一个内层的 while 循环,不断左移 left 指针,并更新窗口中字符的出现次数。同时,检查当前窗口中是否所有字符的出现次数与 need 中相同,如果是则说明找到一个符合条件的子串,将起始索引添加到结果数组 res 中。

最后返回结果数组 res,即为找到的所有符合条件的子串的起始索引数组。

这个算法的时间复杂度是 O(n),其中 n 为字符串 s 的长度。

code

/**
 * @param {string} s
 * @param {string} p
 * @return {number[]}
 */
var findAnagrams = function (s, t) {
    // 需要的
    let need = {};
    // 窗口中的字符
    let window = {};
    for (let a of t) {
        // 统计需要的字符
        need[a] = (need[a] || 0) + 1;
    }
    // 左右指针
    let left = 0,
        right = 0;
    let valid = 0;
    let res = [];
    while (right < s.length) {
        // 即将移入窗口的字符
        let c = s[right];
        // 右移窗口
        right++;
        if (need[c]) {
            // 当前字符在需要的字符中,则更新当前窗口统计
            window[c] = (window[c] || 0) + 1;
            if (window[c] == need[c]) {
                // 当前窗口和需要的字符匹配时,验证数量增加1
                valid++;
            }
        }
        while (right - left >= t.length) {
            if (valid == Object.keys(need).length) {
                res.push(left);
            }
            let d = s[left];
            left++;
            if (need[d]) {
                if (window[d] == need[d]) {
                    valid--;
                }
                window[d]--;
            }
        }
    }
    return res;
};


快慢指针

快慢指针方法会使用两个在数组(或序列/链表)中以不同速度移动的指针。该方法在处理循环链表或数组时非常有用。

应用场景:

  1. 处理链表或数组中的循环的问题
  2. 找链表中点或需要知道特定元素的位置

何时应该优先选择快慢指针,而不是上面提到的普通双指针方法?

有些情况不适合使用二指针方法,比如在不能反向移动的单链接链表中。使用快速和慢速模式的一个案例是当你想要确定一个链表是否为回文(palindrome)时。

移动零问题

题目如图

在这里插入图片描述

快慢指针解题思路

右指针自左向右遍历数组,右指针当前所指元素不为0的时候,交换左右指针对应元素,左指针右移,之后右指针继续遍历。
直到右指针遍历到数组的最后一位且右指针对应数字不为0
在这里插入图片描述

code

var moveZeroes = function (nums) {
    let len = nums.length;
    let left = 0;
    let right = 0;
    while (right < len) {
        //右指针所指的元素不为0时
        if (nums[right] !== 0) {
            // 交换左右指针的值
            let temp = nums[right];
            nums[right] = nums[left];
            nums[left] = temp;
            // 左指针右移
            left++;
        }
        // 右指针右移
        right++;
    }
};

移动零的其他解题方法

技巧一行code

var moveZeroes = function(nums) {
    nums.sort((a,b) => b? 0: -1)
};

如果 b 是真值,保持 a 和 b 的顺序不变;如果 b 是假值,将 a 排在 b 的前面。

这段代码实际上将数组中的元素按照它们在数组中出现的顺序排列,但将所有零值元素放在非零值元素的前面。

单个指针解题思路

1、先获取数组长度
2、然后循环遍历数组
3、当数组哪个位置是0 ,就删除这个这个位置的元素,然后在末尾补 0
从而就达到了 把所有0移动到数组末尾的要求且保持的非零元素的相对顺序
注意:当为0的元素删除后,下一个元素就会前进一位占据该位置,所以要从该位置在进行判断
当移动到末尾的元素,就不用再一次进行遍历了,所以遍历的长度要减去1位

code

var moveZeroes = function(nums) {
        //获取数组长度
        let len = nums.length;
        //循环遍历数组
        for(let i =0;i<len;i++){
            //当数组哪个位置是0 ,就删除这个这个位置的元素,然后在末尾补 0
            //从而就达到了 把所有0移动到数组末尾的要求且保持的非零元素的相对顺序
            if(nums[i]==0){
                nums.splice(i,1)
                nums.push(0)
            //当为0的元素删除后,下一个元素就会前进一位占据该位置,所以要从该位置在进行判断
                i--
            //当移动到末尾的元素,就不用再一次进行遍历了,所以遍历的长度要减去1位
                len--
            }
        }
        return nums
}

总结

    //循环遍历数组
    for(let i =0;i<len;i++){
        //当数组哪个位置是0 ,就删除这个这个位置的元素,然后在末尾补 0
        //从而就达到了 把所有0移动到数组末尾的要求且保持的非零元素的相对顺序
        if(nums[i]==0){
            nums.splice(i,1)
            nums.push(0)
        //当为0的元素删除后,下一个元素就会前进一位占据该位置,所以要从该位置在进行判断
            i--
        //当移动到末尾的元素,就不用再一次进行遍历了,所以遍历的长度要减去1位
            len--
        }
    }
    return nums

}

# 总结
算法的解决方法一定不唯一,所以上述的所有双指针算法可以为一些问题提供很好的解决思路,但是解法并不唯一,以上可供学习参考。