🚀 算法探险:三数之和——排序与双指针的完美舞步 💃

46 阅读5分钟

嘿,各位算法探险家们!今天咱们要聊聊一个在 LeetCode 上出镜率极高、同时也是面试官们最爱的经典问题——三数之和 (Three Sum)

这个问题看似简单:给你一个整数数组 nums,找出所有不重复的三元组 [a, b, c],使得它们的和为零,即 a+b+c=0a + b + c = 0

如果你上来就想用三层循环(暴力解法),那可要小心了,时间复杂度直接飙到 O(n3)O(n^3),对于稍微大一点的输入,你的程序可能就要原地“爆炸”了💥。

但是别慌!聪明的程序员总有优雅的解决方案。我们将采用一种结合了排序双指针的技巧,将时间复杂度优化到 O(n2)O(n^2)


💡 从“两数之和”到“三数之和”

在解决三数之和之前,咱们先快速回顾一下它的老弟——两数之和 (Two Sum)

  1. 两数之和(暴力) : 双层循环,O(n2)O(n^2)
  2. 两数之和(哈希表) : 遍历一次数组,用 O(1)O(1) 的时间去查找 targetcurrenttarget - current 是否存在于哈希表中,将时间复杂度优化到**O(n)O(n)**。

然而,三数之和的约束条件是“不重复的三元组”,如果直接套用哈希表解法,处理去重会变得非常麻烦。所以,咱们需要一种更结构化的方法。


🛠️ 三数之和:排序 + 双指针 O(n2)O(n^2) 方案详解

我们的核心思路是:降维打击!

a+b+c=0a + b + c = 0 拆解为:固定一个数 aa,然后寻找两个数 bbcc,使得 b+c=ab + c = -a

1. 预处理:排序(Sort)

这是整个解法的基石,也是优化复杂度的关键一步。

JavaScript

nums.sort((a, b) => a - b);

为什么一定要排序?

  1. 方便跳过重复元素(去重) :如果数组是有序的,相同的元素会相邻。我们只需要检查当前元素是否与其前一个元素相同,就可以轻松跳过重复项。
  2. 方便使用双指针:排序后,数组具备单调性。当我们知道当前三数之和是偏大还是偏小时,可以确定性地移动指针来缩小查找范围。

🔑 关于 JavaScript 的 sort 方法:

很多初学者在这里会遇到一个“坑”。JS 内置的 sort() 默认是按字符串字典顺序排序的,例如 [1, 10, 2] 会被排成 [1, 10, 2](因为它把 '10' 看作 '1' 后面跟了个 '0')。

为了实现数字升序排序,我们必须传入一个比较函数:

  • nums.sort((a, b) => a - b):

    • ab<0a - b < 0 时,表示 a<ba < bsort 认为 aa 应该在 bb 前面,不交换,结果是升序(从小到大)。

排序本身需要 O(nlogn)O(n \log n) 的时间,但这只执行一次,所以不会影响我们主体 O(n2)O(n^2) 的复杂度。

2. 主体:固定 + 双指针

数组排序完成后,我们开始主体循环。

JavaScript

for (let i = 0; i < nums.length - 2; i++) {
    // ... 固定 i 的逻辑 ...
}

A. 固定第一个数 ii 并去重

我们用一个外层循环 ii固定第一个数 aa。循环条件是 i<nums.length2i < nums.length - 2,因为我们至少需要三个数,所以 ii 至少要留出两个位置给 leftright 指针。

JavaScript

// **关键点 1:跳过重复的 i**
// i > 0 确保 i 不是第一个元素
// nums[i] === nums[i - 1] 确保当前元素和上一个固定元素相同
if (i > 0 && nums[i] === nums[i - 1]) continue;

B. 初始化双指针

对于固定的 nums[i]nums[i],我们的目标是在剩余的子数组中找到 bbcc 使得 b+c=nums[i]b + c = -nums[i]

  • 左指针 left: 初始化为 i+1i + 1
  • 右指针 right: 初始化为数组的末尾 nums.length1nums.length - 1

JavaScript

let left = i + 1;
let right = nums.length - 1;

C. 移动双指针

while (left < right) 的循环中,我们计算当前的和 sum

JavaScript

while (left < right) {
    const sum = nums[i] + nums[left] + nums[right];

    if (sum === 0) {
        // **找到解**
        res.push([nums[i], nums[left], nums[right]]);

        // **继续找(双指针收缩)**
        left++;
        right--;

        // **关键点 2:跳过重复的 leftright**
        // left < right 检查循环是否结束
        // nums[left] === nums[left - 1] 检查 left 是否和上一个元素重复
        while (left < right && nums[left] === nums[left - 1]) left++;
        while (left < right && nums[right] === nums[right + 1]) right--;

    } else if (sum < 0) {
        // **和太小了 (sum < 0)**:
        // 需要一个更大的数,因为数组已排序,所以**左指针 right++**
        left++;
    } else { // sum > 0
        // **和太大了 (sum > 0)**:
        // 需要一个更小的数,所以**右指针 left--**
        right--;
    }
}

总结时间复杂度:

  • 排序O(nlogn)O(n \log n)
  • 外层循环 iiO(n)O(n)
  • 内层双指针 leftleftrightrightO(n)O(n)
  • 总时间复杂度O(nlogn)+O(n2)O(n2)O(n \log n) + O(n^2) \approx O(n^2)

完整代码示例(技术交流平台版)

JavaScript

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
function threeSum(nums) {
    // O(n log n):排序是优化解法的基石
    // 使用 (a, b) => a - b 确保数字升序排序
    nums.sort((a, b) => a - b); 

    const res = [];
    
    // O(n):外层循环 i,固定第一个数 a
    for(let i = 0; i < nums.length - 2; i++) {
        
        // **关键点 1:对 i 进行去重**
        // 确保本次固定的 nums[i] 与上一次不同
        if(i > 0 && nums[i] === nums[i - 1]) continue;
        
        // i > 0 且 nums[i] > 0,因为数组已排序,后续的数肯定大于零,和不可能等于 0,可以提前退出
        if (nums[i] > 0) break;

        // O(n):双指针,用于在剩余数组中找 b 和 c
        let left = i + 1;
        let right = nums.length - 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--;

                // **关键点 2:对 leftright 进行去重**
                // 找到一个解后,要跳过所有和当前 nums[left] 重复的元素
                while(left < right && nums[left] === nums[left - 1]) left++;
                // 找到一个解后,要跳过所有和当前 nums[right] 重复的元素
                while(left < right && nums[right] === nums[right + 1]) right--;
                
            } else if(sum < 0) {
                // 和太小了,需要增大和 -> left 右移
                left++;
            } else { // sum > 0
                // 和太大了,需要减小和 -> right 左移
                right--;
            }
        }
    }
    
    return res;
}

// 示例运行
const testNums = [-1, 0, 1, 2, -1, -4];
console.log(`原数组: ${testNums}`);
console.log(`结果: ${threeSum(testNums)}`); // 结果: [ [ -1, -1, 2 ], [ -1, 0, 1 ] ]

const sortedTestNums = [2, 1, 6, 3, 4, 5];
// 验证 sort
sortedTestNums.sort((a, b) => a - b);
console.log(`验证排序结果: ${sortedTestNums}`); // 结果: [ 1, 2, 3, 4, 5, 6 ]

总结陈词:算法之美 💖

三数之和这个题目,完美地展示了算法设计中的降维结构化思维。从 O(n3)O(n^3) 的暴力解到 O(n2)O(n^2) 的优雅解,我们只多做了一步排序

  1. 排序:为后续的去重和双指针操作奠定基础。
  2. 固定 ii :将三数之和问题降维成两数之和问题。
  3. 双指针:利用排序后的数组特性,以 O(n)O(n) 的时间复杂度完成查找和去重。

掌握了这套“排序 + 固定 + 双指针”的组合拳,你不仅能轻松拿下三数之和,还能解决四数之和、K 数之和等一系列类似问题!

算法的世界充满乐趣,让我们在下一个挑战中再见!👋