LeetCode 15. 三数之和 —— 完整面试解题全攻略

3 阅读10分钟

LeetCode 15. 三数之和 —— 完整面试解题全攻略

🎯 难度:中等 | 高频指数:⭐⭐⭐⭐⭐(字节/阿里/腾讯/Google 必考)


【第一部分:题目拆解】

1. 📖 题目回顾

给你一个整数数组 nums,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i ≠ j ≠ k(原文中 i=j 是排版错误,实际为下标互不相同),且 nums[i] + nums[j] + nums[k] == 0。返回所有和为 0 且不重复的三元组。

示例输入输出说明
1[-1, 0, 1, 2, -1, -4][[-1,-1,2], [-1,0,1]]两个不重复三元组
2[0, 1, 1][]无解
3[0, 0, 0][[0,0,0]]三个 0 满足和为零

约束条件(提示)

  • 3 <= nums.length <= 3000
  • -10^5 <= nums[i] <= 10^5

2. 🧠 前置知识清单

知识模块具体内容掌握程度
排序快速排序/归并排序思想⭐ 必须
双指针对撞指针(左右夹逼)⭐ 必须
数组去重跳过相邻重复元素⭐ 必须
时间复杂度分析O(n²) 的推导⭐ 重要
边界处理数组长度、整数溢出(此题不会溢出)⭐ 重要

3. 🏷️ LeetCode 标签与相似题型

  • 标签数组 · 双指针 · 排序
  • 相似题型推荐
    • LeetCode 16. 最接近的三数之和(双指针 + 差值更新)
    • LeetCode 18. 四数之和(外层加一层循环 + 双指针)
    • LeetCode 1. 两数之和(哈希表,双指针变种)

【第二部分:思路推导(图文并茂)】

4. 🧭 核心逻辑步骤

排序 → 固定一个数 → 双指针找另外两个 → 去重 → 移动指针
指针移动图解(以 nums = [-4, -1, -1, 0, 1, 2] 为例)
graph TD
    A[排序后数组] --> B[i=0, 固定 -4]
    B --> C{left=1, right=5}
    C -->|sum = -4-1+2=-3 < 0| D[left++ → 2]
    D -->|sum = -4-1+2=-3 < 0| E[left++ → 3]
    E -->|sum = -4+0+2=-2 < 0| F[left++ → 4]
    F -->|sum = -4+1+2=-1 < 0| G[left++ → 5, left==right 结束]
    
    B2[i=1, 固定 -1] --> C2{left=2, right=5}
    C2 -->|sum = -1-1+2=0| D2[记录 [-1,-1,2]]
    D2 -->|去重后 left=4, right=4| E2[结束]
    
    B3[i=2, 固定 -1, 与 i=1 重复] --> C3[跳过 continue]
    
    B4[i=3, 固定 0] --> C4{left=4, right=5}
    C4 -->|sum = 0+1+2=3 > 0| D4[right-- → 4, left==right 结束]
状态转移表(指针移动逻辑)
条件操作原因
sum === 0记录三元组,左指针右移跳过重复,右指针左移跳过重复已经匹配,两个指针都必须移动
sum < 0left++总和太小,需要增大(数组有序,左指针右移变大)
sum > 0right--总和太大,需要减小(数组有序,右指针左移变小)

5. 💣 暴力解推导

// 暴力解法 O(n³)
var threeSumBrute = function(nums) {
    const result = [];
    const n = nums.length;
    for (let i = 0; i < n - 2; i++) {
        for (let j = i + 1; j < n - 1; j++) {
            for (let k = j + 1; k < n; k++) {
                if (nums[i] + nums[j] + nums[k] === 0) {
                    const triple = [nums[i], nums[j], nums[k]].sort((a,b) => a-b);
                    // 还需要手动去重(非常麻烦)
                    // ...
                }
            }
        }
    }
    return result;
};
痛点分析
⏱️ 时间复杂度 O(n³)n=3000 时,27e9 次循环,不可接受
🧹 去重困难需要额外 Set 或排序比较,增加复杂度
💾 空间浪费存储大量临时三元组

6. ⚡ 最优解优化点

为什么排序 + 双指针能成立?

核心性质:排序后,数组具有单调性。固定一个数 nums[i],问题转化为在 i+1..n-1 中找两数和为 -nums[i]。因为数组有序,左指针向右移动会使和增大,右指针向左移动会使和减小——这就是双指针夹逼的数学依据。

优化成立背后的逻辑

  • 排序将无序的“三数和”问题降维成有序的“两数和”问题。
  • 双指针每次移动都排除一个不可能的组合,减少无效遍历。
  • 去重通过比较相邻元素完成,O(1) 空间。

【第三部分:代码工程与 API 详解】

7. 🔍 API 详细解析

方法/操作说明时间复杂度空间复杂度
nums.sort((a,b) => a-b)V8 引擎 TimSort(插入+归并)O(n log n)O(n)(栈空间)
for 循环固定第一个数O(n) 外层O(1)
while(left < right)双指针扫描O(n) 内层(整体均摊)O(1)
result.push([...])存储三元组O(1) 均摊O(1)
去重 while跳过重复元素总次数 ≤ nO(1)

8. 📦 完整代码实现

版本一:极简风格(适合面试手写)
var threeSum = function(nums) {
    const res = [];
    nums.sort((a, b) => a - b);
    for (let i = 0; i < nums.length - 2; i++) {
        if (nums[i] > 0) break;
        if (i > 0 && nums[i] === nums[i - 1]) continue;
        let l = i + 1, r = nums.length - 1;
        while (l < r) {
            const s = nums[i] + nums[l] + nums[r];
            if (s === 0) {
                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 (s < 0) l++;
            else r--;
        }
    }
    return res;
};
版本二:可读性增强(带注释 + 变量命名清晰)
/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var threeSum = function(nums) {
    const triplets = [];
    // 1. 排序是关键
    nums.sort((a, b) => a - b);
    const n = nums.length;

    // 2. 固定第一个元素
    for (let first = 0; first < n - 2; first++) {
        // 剪枝:最小元素都大于 0,后面不可能有解
        if (nums[first] > 0) break;
        // 去重:跳过重复的第一个元素
        if (first > 0 && nums[first] === nums[first - 1]) continue;

        let left = first + 1;
        let right = n - 1;

        // 3. 双指针查找另外两个数
        while (left < right) {
            const sum = nums[first] + nums[left] + nums[right];

            if (sum === 0) {
                triplets.push([nums[first], 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 triplets;
};

9. 📊 复杂度分析

维度复杂度解释
时间复杂度O(n²)排序 O(n log n) + 外层 n 次 × 内层双指针 O(n) 均摊 → O(n²)
空间复杂度O(log n) ~ O(n)排序栈空间 O(log n)~O(n)(取决于引擎),结果数组不算额外空间

时间换空间:本题没有用额外哈希表存储,双指针直接在原数组上操作,节省了 O(n) 的空间,但把两数之和的 O(n) 哈希查找变成了 O(n) 双指针扫描——这是典型的用时间换空间(但从 O(n³) 优化到 O(n²),总体是双赢)。


【第四部分:面试实战避坑】

10. 🎯 得分重点与难点

重点权重说明
排序 + 双指针框架⭐⭐⭐⭐⭐必须脱口而出
去重逻辑(两处)⭐⭐⭐⭐⭐最容易遗漏
剪枝优化(nums[i]>0⭐⭐⭐⭐体现思考深度
边界条件(length-2⭐⭐⭐防止越界

难点详解

  • 为什么去重要放在记录结果之后,而不是之前?
    答:因为[-1,-1,2]这种三元组中,第一个-1和第二个-1是不同下标的合法元素,不能在外层continue时把它跳掉。外层的continue只跳过相同值的第一个元素,保留第一次出现的机会。
  • 内层两个 while 去重为什么要同时处理 left 和 right?
    答:找到一组解后,leftright 都需要收缩,否则会产生重复三元组。例如 [0,0,0,0],找到第一个后若不跳过,会生成多个 [0,0,0]

11. ⚠️ 易错点与边界错误

❌ 错误写法✅ 正确写法原因
for (let i = 0; i < nums.length; i++)for (let i = 0; i < nums.length - 2; i++)需要留出至少两个位置给 left/right
if (nums[i] === nums[i-1]) continue;if (i > 0 && nums[i] === nums[i-1]) continue;i=0 时 nums[-1] 为 undefined
只去重 left,不去重 right同时去重 left 和 right会漏掉右侧重复导致的冗余
while (nums[left] === nums[left+1]) left++;while (left < right && nums[left] === nums[left+1]) left++;缺少边界判断,可能越界
找到解后只 left++right--同时 left++right--否则死循环(如 [0,0,0]

12. 🧪 测试用例设计

// 测试套件(可用 Jest/Mocha 运行)
const testCases = [
    // 正常用例
    { input: [-1,0,1,2,-1,-4], expected: [[-1,-1,2], [-1,0,1]] },
    // 边界:全零
    { input: [0,0,0], expected: [[0,0,0]] },
    // 边界:无解
    { input: [0,1,1], expected: [] },
    // 边界:两个零(不足三个元素)
    { input: [0,0], expected: [] },
    // 边界:含重复大量元素
    { input: [-2,0,0,2,2], expected: [[-2,0,2]] },
    // 边界:全部正数
    { input: [1,2,3,4], expected: [] },
    // 边界:全部负数
    { input: [-4,-3,-2,-1], expected: [] },
    // 边界:大规模(性能验证)
    { input: Array.from({length: 3000}, (_,i) => i - 1500), expected: 'length>0' }, // 只验证不超时
];

// 简单断言函数(对比排序后的结果)
function assertTripletsEqual(a, b) {
    const sortedA = a.map(t => [...t].sort((x,y)=>x-y)).sort((x,y)=>x[0]-y[0]);
    const sortedB = b.map(t => [...t].sort((x,y)=>x-y)).sort((x,y)=>x[0]-y[0]);
    return JSON.stringify(sortedA) === JSON.stringify(sortedB);
}

// 执行测试
testCases.forEach(({input, expected}, idx) => {
    const result = threeSum(input);
    if (typeof expected === 'string') {
        console.log(`Case ${idx}: 通过(规模验证)`);
    } else {
        const ok = assertTripletsEqual(result, expected);
        console.log(`Case ${idx}: ${ok ? '✅ 通过' : '❌ 失败'}`, result);
    }
});

【第五部分:深度优化与思考】

13. 🚀 优化思路(多种方案对比)

方案思路时间复杂度空间适用场景
双指针(本题)排序 + 对撞指针O(n²)O(1)通用最优
哈希表法固定 i,用 Set 存储 j,查找 kO(n²)O(n)需要快速查询
递归回溯(DFS)组合枚举O(n³)O(n)n 很小(<50)
剪枝优化提前判断最大最小和O(n²) 但常数小O(1)数据分布极端

常数级优化代码(加入提前剪枝):

var threeSum = function(nums) {
    const res = [];
    nums.sort((a,b) => a-b);
    const n = nums.length;
    for (let i = 0; i < n - 2; i++) {
        if (nums[i] > 0) break;
        if (i > 0 && nums[i] === nums[i-1]) continue;
        // 剪枝:最小的两个数 + nums[i] 都大于 0,跳出
        if (nums[i] + nums[i+1] + nums[i+2] > 0) break;
        // 剪枝:最大的两个数 + nums[i] 都小于 0,跳过当前 i
        if (nums[i] + nums[n-2] + nums[n-1] < 0) continue;
        let l = i+1, r = n-1;
        while (l < r) {
            const sum = nums[i] + nums[l] + nums[r];
            if (sum === 0) {
                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 (sum < 0) l++;
            else r--;
        }
    }
    return res;
};

14. 🔁 复盘与举一反三

遇到"数组中找若干个元素满足某种条件"的问题,第一反应是什么?

  1. 能否排序? → 排序后可以利用单调性(双指针/二分)。
  2. 能否固定一部分? → 降维:三数固定一个变两数,四数固定两个变两数。
  3. 是否需要去重? → 排序后相邻比较是最简单的去重方式。
  4. 能否剪枝? → 利用极值提前终止。

通用模板

function kSum(nums, target, k) {
    nums.sort((a,b) => a-b);
    return kSumRecursive(nums, target, k, 0);
}
// 递归 + 双指针处理 k=2 的情况

举一反三

  • 四数之和:外层两层循环 + 双指针,O(n³)
  • 三数之和与 target 任意值:只需改判断条件
  • 三数之和返回最接近值:不需要去重,只需维护差值

15. 🎤 延伸思考与面试技巧

追问 1:如果数据量增大到 n = 10^5 怎么办?

回答:O(n²) 无法承受,需要考虑其他约束。如果数据范围有限(如 -10^3 ~ 10^3),可以用计数排序 + 枚举值域,复杂度 O(V²),V 为值域大小。也可以用分治 + FFT(不现实)。一般面试官会接受 O(n²) 是此题的上限。

追问 2:如果数据倾斜(大量重复元素)怎么办?

回答:我们的去重逻辑已经在重复元素时跳过大量循环,效率已经很高。最坏情况(所有元素相同),外层和内层去重会使得实际循环次数极少。

追问 3:如果只能遍历一次(流式数据)怎么办?

回答:无法用双指针(需要随机访问),只能使用哈希表存储已出现的两数之和,但去重会变得极其复杂。可以用 Map 记录 (sum, [i,j]),但空间和时间都是 O(n²),且去重需要额外结构。

💡 面试技巧
  • 先说暴力解,再优化,体现思考过程。
  • 手写代码时,先写注释框架(排序→固定→双指针→去重),再填充细节。
  • 主动提出测试用例(尤其是全零、重复元素),展示严谨性。
  • 复杂度分析要准确,能说出 O(n²) 的均摊推导。

🏆 总结口诀:排序固定双指针,去重剪枝防越界,三数和零巧夹逼,面试从容不慌张。