从暴力到双指针:我用三数之和搞懂了大厂面试最爱的「去重+双指针」套路

467 阅读7分钟

刷LeetCode永远绕不开的题——三数之和(一生之敌)。说它经典,是因为几乎所有大厂面试都会考;说它「坑多」,是因为暴力解法会超时,双指针看似简单但去重逻辑能让人挠破头。

今天就用我刷这道题的真实经历,从暴力解法的痛点讲到双指针的核心思路,重点拆解让无数人崩溃的「去重逻辑」。看完这篇,你不仅能彻底搞懂三数之和,还能举一反三解决四数之和、五数之和等变种题。

题目描述:找三个数,和为0且不重复

题目是这样的:给定一个整数数组 nums,判断是否存在三个元素 a, b, c ,使得 a + b + c = 0 ?要求找出所有不重复的三元组。

举个例子,输入 nums = [-1,0,1,2,-1,-4],正确输出应该是 [[-1,-1,2],[-1,0,1]]。注意,像 [-1,0,1][0,-1,1] 这种元素相同但顺序不同的组合,算重复,只能保留一个。

暴力解法:能过样例,但过不了面试

刚学算法时,我第一反应是暴力枚举:三层循环直接套,简单暴力又美妙。代码大概长这样:

function threeSum(nums) {
    const res = [];
    const n = nums.length;
    for (let i = 0; i < n; i++) {
        for (let j = i + 1; j < n; j++) {
            for (let k = j + 1; k < n; k++) {
                if (nums[i] + nums[j] + nums[k] === 0) {
                    res.push([nums[i], nums[j], nums[k]]);
                }
            }
        }
    }
    // 去重:这里还要对res去重,复杂度爆炸
    return unique(res); 
}

但这种方法有两个致命问题:

  1. 时间复杂度太高:三层循环的时间复杂度是 (O(n^3)),当数组长度是1000时,计算次数是十亿级,直接超时;
  2. 去重困难:即使找到所有和为0的三元组,还要对结果去重(比如 [-1,0,1][0,-1,1] 算重复)。用 JSON.stringify 转字符串再去重?这会额外增加 (O(k)) 的时间复杂度(k是结果数量),面试肯定不会给过。

双指针解法:排序+双指针,时间复杂度降为 (O(n^2))

ps:其实我看两遍了再写还是记不住,就只能换方法了,就去b站看up视频我看的是代码随想录,讲解十分清晰,上完一遍就手感火热啊,正好看到四数之和,一家人一起解决了。

暴力解法行不通,就得想优化。这时候双指针法登场了。它的核心思路是:先排序,再固定一个数,用双指针找另外两个数

第一步:排序——双指针的前提

为什么要先排序?因为排序后可以:

  • 利用有序数组的特性,通过指针移动快速缩小范围;
  • 方便后续去重(重复元素会相邻,容易跳过)。

比如原数组是 [-1,0,1,2,-1,-4],排序后变成 [-4,-1,-1,0,1,2]。这时候,相同的元素(比如两个 -1)会挨在一起,为后续去重打下基础。

第二步:固定一个数,双指针找另外两个数

排序后,我们固定第一个数 nums[i],然后用左指针 left 指向 i+1,右指针 right 指向数组末尾。三个数的和 sum = nums[i] + nums[left] + nums[right]

  • 如果 sum < 0:说明需要更大的数,左指针右移(left++);
  • 如果 sum > 0:说明需要更小的数,右指针左移(right--);
  • 如果 sum = 0:找到一个有效三元组,记录结果。

第三步:去重——这才是真正的难点

找到三元组后,如何避免重复?关键是跳过相同的元素。具体分三种情况:

1. 固定数 nums[i] 重复

比如排序后的数组是 [-4,-1,-1,0,1,2],当 i=1nums[i]=-1)时,和 i=2nums[i]=-1)时的情况是一样的。这时候需要跳过重复的 nums[i]

判断条件:如果 i > 0nums[i] === nums[i-1],说明当前 nums[i] 和前一个数重复,直接跳过。

2. 左指针 nums[left] 重复

假设已经找到 i=0nums[i]=-4),left=1nums[left]=-1),right=5nums[right]=2),此时和为 -4 + (-1) + 2 = -3,不满足条件。左指针右移到 left=2nums[left]=-1),这时候 nums[left] 和前一个 left 位置的数重复,需要跳过。

判断条件:当找到和为0的三元组后,需要循环判断 nums[left] === nums[left+1],如果是,左指针右移,直到遇到不同的数。

3. 右指针 nums[right] 重复

同样,找到和为0的三元组后,如果 nums[right] 和前一个 right 位置的数重复(比如 nums[right]=1nums[right-1]=1),需要跳过。

判断条件:循环判断 nums[right] === nums[right-1],如果是,右指针左移,直到遇到不同的数。

代码逐行解析:看我如何把思路写成代码

结合上面的思路,来看我写的三数之和代码:

function threeSum(nums) {
    nums.sort((a, b) => a - b); // 先排序
    const result = [];
    const n = nums.length;
    
    for (let i = 0; i < n - 2; i++) { // 固定第一个数 nums[i]
        // 跳过重复的 nums[i](i>0时才判断)
        if (i > 0 && nums[i] === nums[i - 1]) {
            continue;
        }
        
        let left = i + 1; // 左指针初始化为 i+1
        let right = n - 1; // 右指针初始化为数组末尾
        
        while (left < right) { // 左右指针相遇时停止
            const sum = nums[i] + nums[left] + nums[right];
            
            if (sum === 0) { // 找到有效三元组
                result.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 (sum < 0) { // 和太小,左指针右移
                left++;
            } else { // 和太大,右指针左移
                right--;
            }
        }
    }
    
    return result;
}

屏幕录制 2025-05-15 165538_20250515_165905.gif

关键步骤说明:

  1. 排序nums.sort((a, b) => a - b) 将数组升序排列,是双指针的基础;
  2. 固定第一个数:外层循环 i 遍历数组,固定 nums[i] 为第一个数;
  3. 跳过 nums[i] 重复if (i > 0 && nums[i] === nums[i - 1]) continue 避免重复的三元组;
  4. 双指针移动:通过 leftright 的移动,在 i 固定的情况下,找到所有可能的 nums[left]nums[right]
  5. 跳过 leftright 重复:找到和为0的三元组后,跳过相邻的重复元素,避免结果重复。

四数之和:三数之和的「套娃版」

理解了三数之和,四数之和(LeetCode 18)就简单了。思路是:固定前两个数,用双指针找后两个数,同样需要处理重复问题。

看我写的四数之和代码:

var fourSum = function(nums, target) {
    nums.sort((a, b) => a - b); // 先排序
    const res = [];
    const n = nums.length;
    
    for (let i = 0; i < n - 3; i++) { // 固定第一个数 nums[i]
        // 跳过 nums[i] 重复
        if (i > 0 && nums[i] === nums[i - 1]) continue;
        
        for (let j = i + 1; j < n - 2; j++) { // 固定第二个数 nums[j]
            // 跳过 nums[j] 重复
            if (j > i + 1 && nums[j] === nums[j - 1]) continue;
            
            let left = j + 1; // 左指针
            let right = n - 1; // 右指针
            
            while (left < right) {
                const sum = nums[i] + nums[j] + nums[left] + nums[right];
                
                if (sum === target) { // 找到有效四元组
                    res.push([nums[i], nums[j], nums[left], nums[right]]);
                    
                    // 跳过 left 重复
                    while (left < right && nums[left] === nums[left + 1]) left++;
                    // 跳过 right 重复
                    while (left < right && nums[right] === nums[right - 1]) right--;
                    
                    left++;
                    right--;
                } else if (sum < target) { // 和太小,左指针右移
                    left++;
                } else { // 和太大,右指针左移
                    right--;
                }
            }
        }
    }
    
    return res;
};

四数之和与三数之和的区别:

  • 多了一层循环固定第二个数 nums[j]
  • 去重时需要同时处理 nums[i]nums[j] 的重复;
  • 目标和从0变成了任意 target,但逻辑完全一致。

总结:双指针+去重的核心逻辑

三数之和这道题,表面考的是算法,实际考的是「如何用排序和双指针降低时间复杂度」「如何通过跳过重复元素避免结果重复」。掌握这两个核心,不仅能解决三数之和、四数之和,还能解决类似的N数之和问题。

最后,给刚开始刷算法的同学一个建议:遇到类似问题,先想暴力解法,再想如何优化。双指针不是凭空想出来的,而是在分析暴力解法的痛点(时间复杂度高、重复元素难处理)后,结合排序的特性推导出来的。

下次面试遇到三数之和,记得把这篇的思路讲给面试官听——从暴力到双指针的优化过程,比直接写代码更能体现你的算法思维。