彻底吃透“三数之和”:从 JS 排序陷阱到双指针去重细节

0 阅读5分钟

前言

在前端算法面试中,LeetCode 第15题“三数之和”(3Sum)是一道极具区分度的题目。它不像“两数之和”那样可以简单地用哈希表解决,也不像困难题那样需要复杂的动态规划状态转移。

这道题的核心考察点在于:能否将 

O(n3)O(n3)

 的暴力解法优化至 

O(n2)O(n2)

,以及在 JavaScript 语言环境下,对数组排序机制的理解和对边界情况(特别是去重逻辑)的严谨处理。本文将剥离所有冗余表述,直击解题核心。

一、 JavaScript 的排序陷阱

在着手解题前,必须先解决一个语言层面的前置问题。本题的高效解法依赖于有序数组。然而,JavaScript 的 Array.prototype.sort() 方法在处理数字时,存在一个非直观的默认行为。

1. 默认行为的隐患

如果不传递比较函数,sort() 方法会将数组元素转换为字符串,并按照 UTF-16 码元序列(Unicode 码点)进行排序。

JavaScript

const nums = [2, 1, 10, 3];
nums.sort();
console.log(nums); 
// 输出: [1, 10, 2, 3]

在字符串比较中,"10" 的第一个字符 "1" 小于 "2",因此 "10" 排在 "2" 之前。这显然不符合数值排序的需求。

2. 正确的数值排序

为了实现数值升序,必须传入一个比较函数 (a, b) => a - b。

JavaScript

nums.sort((a, b) => a - b);
// 输出: [1, 2, 3, 10]

原理

  • 当 a - b < 0 时,a 小于 b,排序算法将 a 置于 b 之前。
  • 当 a - b > 0 时,a 大于 b,排序算法将 a 置于 b 之后。

这是后续双指针操作的基石。

二、 算法演进:从暴力到双指针

1. 复杂度分析

  • 暴力法:三层 for 循环遍历 i, j, k。时间复杂度为 

    O(n3)O(n3)
    

    。在数据量达到 

    103103
    

     级别时,运算次数达到 

    109109
    

    ,必然超时。

  • 双指针法:先排序,再遍历。固定一个数,用双指针寻找另外两个数。排序耗时 

    O(nlog⁡n)O(nlogn)
    

    ,双指针遍历耗时 

    O(n2)O(n2)
    

    。整体复杂度降低至 

    O(n2)O(n2)
    

2. 核心逻辑图解

算法流程如下:

  1. 排序:将数组升序排列。

  2. 固定锚点:遍历数组,索引为 i。

  3. 双指针扫描

    • 左指针 left = i + 1。
    • 右指针 right = length - 1。
  4. 求和判断:计算 sum = nums[i] + nums[left] + nums[right]。

    • 若 sum === 0:记录结果,左右指针同时收缩。
    • 若 sum < 0:和偏小,left 右移(变大)。
    • 若 sum > 0:和偏大,right 左移(变小)。

image.png

image.png

三、 完整代码实现

以下是基于上述逻辑的 JavaScript 标准题解,代码注重可读性与性能的平衡。

JavaScript

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var threeSum = function(nums) {
    const res = [];
    const len = nums.length;
    
    // 1. 排序:这是双指针生效的前提
    nums.sort((a, b) => a - b);
    
    // 2. 遍历,固定第一个数 nums[i]
    for (let i = 0; i < len - 2; i++) {
        
        // 剪枝优化:若当前最小的数已大于0,后续之和必无法等于0
        if (nums[i] > 0) break;
        
        // 外层去重:跳过重复的基准数
        if (i > 0 && nums[i] === nums[i - 1]) continue;
        
        let left = i + 1;
        let right = len - 1;
        
        while (left < right) {
            const sum = nums[i] + nums[left] + nums[right];
            
            if (sum === 0) {
                // 收集结果
                res.push([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--;
                }
                
            } else if (sum < 0) {
                // 和太小,左指针右移
                left++;
            } else {
                // 和太大,右指针左移
                right--;
            }
        }
    }
    return res;
};

四、 深度解析:去重与剪枝策略

这道题的难点不在于“找”,而在于“不重”。如果不进行精确的去重,结果集中会出现重复的三元组(如 [-1, 0, 1] 出现两次),导致提交失败。

1. 整体剪枝 (Pruning)

if (nums[i] > 0) break;
由于数组已排序,nums[i] 是三数中最小的。如果最小值都大于 0,那么 nums[i] + nums[left] + nums[right] 必然大于 0。此判断可提前终止循环,节省计算资源。

2. 外层循环去重

if (i > 0 && nums[i] === nums[i - 1]) continue;
这是针对第一个数(基准数)的去重。

  • 场景:数组为 [-1, -1, 0, 1]。
  • 逻辑:当 i=0 时,固定第一个 -1,找到了 [-1, 0, 1]。当 i=1 时,如果是同一个值 -1,如果不跳过,会再次找到 [-1, 0, 1]。
  • 关键点:必须是 nums[i] === nums[i-1],表示当前值和上一个已处理过的值相同,因此跳过当前值。

3. 内层双指针去重

在 sum === 0 找到一组解后,不能简单地停止,也不能只移动一步。

codeJavaScript

while (left < right && nums[left] === nums[left - 1]) left++;
while (left < right && nums[right] === nums[right + 1]) right--;
  • 逻辑:当记录了解 [-1, 0, 1] 后,假设数组片段为 ... 0, 0, 1, 1 ...。
  • left 必须跳过所有重复的 0,直到指向下一个不同的数。
  • 注意比较对象:这里是 nums[left] === nums[left - 1],因为在判断前代码已经执行了 left++,所以是拿当前位置与刚才被处理过的位置比较。

image.png

总结

“三数之和”是双指针算法的教科书级应用。解决此题的关键在于建立有序性的思维模型:

  1. 有序是双指针移动判定的基础(利用 JS sort 修正排序)。
  2. 有序是去重逻辑的基础(相同的元素必然相邻)。

掌握了这种“排序 + 双指针 + 严谨去重”的模式,对于解决“四数之和”或“最接近的三数之和”等变种题目,将不再有任何障碍。