双指针

0 阅读10分钟

283. 移动零

这道题目是 “移动零” 。它在面试中主要考察对 “原地(In-place)修改”“双指针” 技巧的掌握。

题目要求把所有的 0 挪到最后,同时不能破坏非零数字原本的先后顺序,最关键的是:不能开辟新数组


🏠 生活案例:排队剔除空位

想象一排座次,里面坐着一些人(非零数字)和一些空椅子(数字 0)。老师要求把所有人都往前挪,把空椅子都留到最后。

你的代码逻辑(快慢指针法):

  1. 慢指针 (slow) :像是一个“管理员”,他站在队列的最前方,负责盯着“下一个有用的人应该坐在哪”。
  2. 快指针 (fast) :像是一个“侦查员”,他跑在前面,专门寻找那些“不是空椅子的人”。

动作描述

  • 当侦查员 fast 发现一个人时,就赶紧叫他坐到管理员 slow 指着的位置上。
  • 如果此时管理员和侦查员不在同一个位置,说明中间有“空椅子”,于是两人交换位置:人坐到前面,空椅子被换到后面。

💻 代码实现与生活化注释

JavaScript

/**
 * @param {number[]} nums
 * @return {void} Do not return anything, modify nums in-place instead.
 */
var moveZeroes = function(nums) {
    // 1. 管理员 slow:记录下一个非零元素该去的位置
    let slow = 0;

    // 2. 侦查员 fast:负责遍历整个数组寻找非零元素
    for(let fast = 0; fast < nums.length; fast++){
        
        /**
         * 3. 发现目标!
         * 只要侦查员发现当前的数不是 0
         */
        if(nums[fast] !== 0){
            
            /**
             * 4. 只有当快慢指针不在同一位置时,才交换
             * (如果一开始全是正数,fast 和 slow 会一直重合,没必要自己跟自己换)
             */
            if(fast !== slow){
                // 经典的交换动作:把非零数换到前面,把 0 换到后面
                let temp = nums[fast];
                nums[fast] = nums[slow];
                nums[slow] = temp;
            }

            /**
             * 5. 管理员挪动位置:
             * 因为刚才那个坑位已经坐了一个“非零人”,管理员要指下一个坑位了。
             */
            slow++;
        }
    }
    
    // 因为是原地修改,不需要返回新数组
    return nums;
};

🔍 深度拆解:为什么这样做很“妙”?

1. 相对顺序不变

因为 fast 是从左往右按顺序找人的,找到一个换一个,所以 1, 3, 12 的先后顺序被完美保留了下来。

2. 空间复杂度 O(1)O(1)

我们没有用 new Array(),只是用了两个变量(指针)和一点点交换用的临时变量,极大地节省了内存。

3. 时间复杂度 O(n)O(n)

不管数组多长,fast 只需要走一遍,效率非常高。

💡 进阶思考

如果你直接把非零的数填到前面,最后再手动补零,也是一种解法。但你代码里的这种“交换”写法更进一步:它在移动非零数的同时,自动就把 0 甩到了后面,不需要第二次循环去补零,操作次数更少!

11. 盛最多水的容器

🏠 生活案例:搬家抬水槽

想象你和朋友在搬一个巨大的简易水槽(由两块挡板组成)。

  • 目标:两块挡板之间的面积越大,能装的水就越多。
  • 物理规律:水槽能装多少水,取决于矮的那块挡板(短板效应)。

你的策略(双指针法):

  1. 你站在最左边 (left),朋友站在最右边 (right)。这是初始状态,虽然宽度最大,但挡板可能很矮。

  2. 关键决定:接下来谁往中间挪?

    • 如果挪动高的那块板,宽度变窄了,而高度最高也只能是刚才那块矮板的高度,水量只会变少
    • 如果挪动矮的那块板,虽然宽度变窄了,但我们有机会遇到一块更高的板,从而弥补宽度损失,让水量变多。
  3. 结论:谁矮谁就往中间挪,直到两人碰头。


💻 代码实现与生活化注释

JavaScript

/**
 * @param {number[]} height
 * @return {number}
 */
var maxArea = function(height) {
    // 1. 初始化:我站开头,朋友站末尾
    let left = 0;
    let right = height.length - 1;
    let maxsquare = 0; // 记录历史上出现过的最大盛水量

    // 2. 只要我们还没碰头,就继续尝试
    while(left < right){
        // 计算当前的宽度(底边)
        let currentwidth = right - left;

        /**
         * 3. 核心物理规则:木桶效应
         * 水的高度取决于左右两块挡板中较短的那一块
         */
        let currentheight = Math.min(height[right], height[left]);

        // 计算当前盛水量:底 * 高
        let currentquare = currentheight * currentwidth;

        // 更新最高纪录
        maxsquare = Math.max(currentquare, maxsquare);

        /**
         * 4. 贪心策略:谁矮谁往中间挪
         * 我们保留高的,抛弃矮的,试图在后面找到更高的挡板
         */
        if(height[left] < height[right]){
            left++; // 左边矮,左边向右挪
        } else {
            right--; // 右边矮(或一样高),右边向左挪
        }
    }

    return maxsquare;
};

🔍 深度拆解:为什么不需要尝试所有组合?

如果用暴力法,需要对比 n2n^2 次组合。而双指针法只需要 O(n)O(n) 次,因为它排除了无效的搜索方向

  • 当我们确定 height[left]height[right] 矮时,以 left 为左边界的所有其他组合(比如 leftright-1leftright-2 等)的盛水量一定都比当前这个组合少。
  • 因为宽度在不断减小,而高度受限于 left 这块“短板”,再怎么换右边的挡板,高度也不会超过 height[left]
  • 所以,我们可以理直气壮地“扔掉” left 这块板,让它往中间移动,去寻找转机。

总结

这就是算法中的贪心思想结合对撞指针。它告诉我们:在资源(宽度)不断缩减的情况下,必须通过提升核心竞争力(高度)才有可能获得更大的收益。

15. 三数之和

15. 三数之和

🏠 生活案例:三人凑单免单

想象你在逛街,商家有个活动: “三人组团,消费总额刚好为 0 元(假设有抵扣券负值)即可免单”

  • 规则:不能找重复的组合(比如“小明、小红、小刚”和“小红、小刚、小明”是一回事)。

  • 你的策略

    1. 先排队:让所有人在路边按身高(数值)从矮到高排好队。这样如果你现在的总和太小,你就往右找“高个子”;太大就往左找“矮个子”。
    2. 定一个,找两个:固定一个人 A,然后在 A 右边的所有人里,用“两头堵”的方法找 B 和 C。

💻 代码实现与生活化注释

JavaScript

var threeSum = function(nums) {
    let len = nums.length;
    let res = [];
    if(len < 3) return res;

    // 1. 排队:从小到大排序,这是双指针的前提
    nums.sort((a, b) => a - b);

    for(let i = 0; i < len; i++){
        // 优化:如果第一个数就大于0,后面全是正数,不可能凑成0了
        if(nums[i] > 0) break;

        // 2. 去重:如果这个人和上一个人一样,直接跳过(避免重复组合)
        if(i > 0 && nums[i] == nums[i-1]) continue;

        let L = i + 1; // 左指针(剩下人里最矮的)
        let R = len - 1; // 右指针(剩下人里最高的)

        while(L < R){
            let total = nums[i] + nums[L] + nums[R];

            if(total == 0){
                // 3. 恭喜凑单成功!
                res.push([nums[i], nums[L], nums[R]]);

                // 继续去重:如果旁边的人长得一样,直接跳过
                while(L < R && nums[L] == nums[L+1]) L++;
                while(L < R && nums[R] == nums[R-1]) R--;

                // 缩小范围继续找
                L++;
                R--;
            } else if(total < 0){
                // 钱不够?左边的矮个子往右挪,换个高点的
                L++;
            } else {
                // 钱多了?右边的高个子往左挪,换个矮点的
                R--;
            }
        }
    }
    return res;
};

📝 综合复习表

为了帮你把这几张图的代码串起来,我总结了这个对比表:

题目核心算法生活比喻关键点
53. 最大子数组和动态规划 (Kadane)心情存钱罐如果之前的累积是负数,果断“断舍离”从零开始。
56. 合并区间排序 + 贪心会议室预定必须先排序!然后看当前开始时间是否撞了上一个的结束时间。
15. 三数之和排序 + 双指针三人凑单免单定一个,移两个。通过排序和跳过相同值来去重

💡 学习小贴士

  1. 为什么总要排序?

    在 56 题(合并区间)和 15 题(三数之和)里,排序是为了变无序为有序,让我们能预测“下一步该往哪走”,从而把复杂度从 O(n2)O(n^2)O(n3)O(n^3) 降下来。

  2. 为什么三数之和不用哈希表(Map)?

    虽然可以用,但双指针在处理**“不去重”**的要求时,比哈希表更直观且省内存。

42. 接雨水

看来你已经挑战到了 LeetCode 热题中的“大 Boss”之一:接雨水。这道题之所以被标为“困难”,是因为它需要你不仅能理解高度的变化,还要能洞察水的深度是由谁决定的。


🏠 生活案例:屋顶的凹槽

想象你在一个不平整的屋顶上排了一排柱子,现在下了一场大雨。

  • 核心物理原理:任何一个位置能接多少水,取决于它左边最高的柱子和右边最高的柱子中较短的那一个。这就像木桶原理,最短的那块板决定了水位。
  • 计算公式:该位置的水量 = min(左边最高,右边最高)当前柱子高度\min(\text{左边最高}, \text{右边最高}) - \text{当前柱子高度}

你的代码逻辑(双指针法):

你和朋友分别从屋顶的最左端 (left) 和最右端 (right) 往中间走。

  1. 你们每走一步,都记下目前为止看到的最高柱子(leftMaxrightMax)。
  2. 如果你这边的最高柱子比朋友那边的矮,那你这边的水位就由你这边的 leftMax 说了算,你就算出你脚下能接多少水,然后往前走一步。
  3. 反之,如果你朋友那边的柱子更矮,就处理他那边。

💻 代码实现与生活化注释

JavaScript

/**
 * @param {number[]} height
 * @return {number}
 */
var trap = function(height) {
    let left = 0;                  // 左指针:从头开始
    let right = height.length - 1; // 右指针:从尾开始
    let leftMax = 0;               // 左侧目前见过的最高海拔
    let rightMax = 0;              // 右侧目前见过的最高海拔
    let total = 0;                 // 总接水量

    while (left < right) {
        // 1. 更新两侧的“最高挡板”记录
        leftMax = Math.max(leftMax, height[left]);
        rightMax = Math.max(rightMax, height[right]);

        /**
         * 2. 关键决策:哪边矮,就处理哪边
         * 因为水位是由“短板”决定的。如果你左边的墙 (leftMax) 比右边的墙 (rightMax) 矮,
         * 那么中间不管有什么更高的墙,左边这个位置的水位最高也只能到 leftMax。
         */
        if (leftMax < rightMax) {
            // 当前位置能接的水 = 左侧最高墙 - 当前地板高度
            total = total + leftMax - height[left];
            left++; // 处理完,左边向中间靠拢
        } else {
            /**
             * 反之,右边的墙更矮,水位由右边决定
             */
            total = total + rightMax - height[right];
            right--; // 处理完,右边向中间靠拢
        }
    }

    return total;
};

🔍 深度拆解:为什么双指针可行?

很多人的第一反应是:算某个位置的水量,不是得知道所有左边和右边最高的墙吗?

双指针的巧妙之处在于:

  • leftMax < rightMax 时,我们其实并不需要知道右边真正最高的海拔是多少。我们只需要知道右边已经存在一个比 leftMax 更高的屏障(即 rightMax),这就足够保证左边这个位置的水不会从右边流走。
  • 于是,该位置的水位就完全由 leftMax 锁定了。

总结

这道题是“盛最多水的容器”的进阶版:

  • 11. 盛最多水的容器:只看两端,算一个大坑。
  • 42. 接雨水:看每一个位置,算所有小坑的总和。

这种从两侧向中间收缩的思路,完美避开了 O(n2)O(n^2) 的重复计算,只用一次遍历(O(n)O(n) 时间)和几个变量(O(1)O(1) 空间)就解决了困难题。