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 < 0 | left++ | 总和太小,需要增大(数组有序,左指针右移变大) |
sum > 0 | right-- | 总和太大,需要减小(数组有序,右指针左移变小) |
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 | 跳过重复元素 | 总次数 ≤ n | O(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?
答:找到一组解后,left和right都需要收缩,否则会产生重复三元组。例如[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,查找 k | O(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. 🔁 复盘与举一反三
遇到"数组中找若干个元素满足某种条件"的问题,第一反应是什么?
- 能否排序? → 排序后可以利用单调性(双指针/二分)。
- 能否固定一部分? → 降维:三数固定一个变两数,四数固定两个变两数。
- 是否需要去重? → 排序后相邻比较是最简单的去重方式。
- 能否剪枝? → 利用极值提前终止。
通用模板:
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²) 的均摊推导。
🏆 总结口诀:排序固定双指针,去重剪枝防越界,三数和零巧夹逼,面试从容不慌张。