本文讲解哈希表解法。双指针解法是本题的标准答案,空间 O(1) 更优,但哈希表解法展示了"不排序"的另一种思路,可作为面试追问的补充方案。
题目:
给你一个整数数组 nums ,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j、i != k 且 j != k ,同时还满足 nums[i] + nums[j] + nums[k] == 0 。请你返回所有和为 0 且不重复的三元组。
注意:答案中不可以包含重复的三元组。
示例 1:
输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
示例 2:
输入:nums = [0,1,1]
输出:[]
示例 3:
输入:nums = [0,0,0]
输出:[[0,0,0]]
一、读题
三数之和的暴力解法是三层循环,时间复杂度 O(n³)。优化的核心思路是"降维":固定一个数,将三数之和转化为两数之和问题。
哈希表解法与双指针解法的核心框架相同,区别仅在于"如何查找两数之和":
- 双指针:先排序,利用有序性用左右指针夹逼查找,时间复杂度 O(n),空间 O(1)
- 哈希表:不排序,遍历时用 Map 记录已出现的值,通过
target - nums[j]是否在 Map 中判断是否存在配对,时间复杂度 O(n),空间 O(n)
固定第一个数 nums[i] → 在剩余数组中找两数之和 = -nums[i]
↓
双指针查找(需排序) 或 哈希表查找(不需排序)
哈希表解法不需要排序,直接遍历剩余数组。遍历到 nums[j] 时,检查 target - nums[j] 是否已经在 Map 中出现过——如果出现过,说明当前值和之前某个值正好凑成 target,记录三元组;如果没出现过,将当前值存入 Map,供后面的元素配对。
但数组无序会带来去重难题。双指针解法通过排序后跳过相邻重复元素来去重,哈希表则需要额外处理:
- 外层去重:相同的
nums[i]只能作为第一个数处理一次 - 内层查找:用 Map 记录已遍历的值,通过
target - nums[j]是否在 Map 中判断是否存在配对 - 全局去重:不同
i可能产生相同的三元组(如[-1,0,1]和[0,-1,1]),需要用全局 Set 存储整个三元组的排序字符串作为唯一标识
完整代码
var threeSum = function(nums) {
const result = [];
const n = nums.length;
if (n < 3) return result;
// 外层去重:记录已经作为第一个数处理过的值
const usedFirst = new Set();
// 全局去重:存储整个三元组的唯一标识
const usedTriplet = new Set();
for (let i = 0; i < n - 2; i++) {
// 外层去重:跳过相同的 nums[i]
// 错误写法:if (usedFirst.has(nums[i])) continue; 但忘了 add
if (usedFirst.has(nums[i])) continue;
usedFirst.add(nums[i]);
const target = -nums[i];
const seen = new Map(); // 存储遍历过的值 => 下标
for (let j = i + 1; j < n; j++) {
const complement = target - nums[j];
// 查找 complement 是否已经出现过
if (seen.has(complement)) {
// 对整个三元组排序生成唯一键
const triplet = [nums[i], complement, nums[j]].sort((a, b) => a - b);
const key = triplet.join(',');
// 全局去重:同一组三元组只记录一次
if (!usedTriplet.has(key)) {
result.push([nums[i], complement, nums[j]]);
usedTriplet.add(key);
}
}
// 将当前值存入 Map,供后续元素查找
seen.set(nums[j], j);
}
}
return result;
};
分步拆解
1. 外层循环与去重(usedFirst)
const usedFirst = new Set();
for (let i = 0; i < n - 2; i++) {
if (usedFirst.has(nums[i])) continue;
usedFirst.add(nums[i]);
// ...
}
- 循环终止条件
i < n - 2:至少要留两个位置给j和k,数组长度小于3时循环不执行。 usedFirst是Set集合,记录所有已经作为第一个数处理过的值。if (usedFirst.has(nums[i])) continue:如果当前值已经作为nums[i]处理过,直接跳过,避免外层重复。- 为什么不用
i > 0 && nums[i] === nums[i-1]?因为数组未排序,相同的值不一定相邻,用Set更可靠。
错误写法:
// ❌ 只判断 has,忘记 add,导致死循环
if (usedFirst.has(nums[i])) continue;
// 缺少 usedFirst.add(nums[i]);
2. 内层哈希表查找(seen)
const target = -nums[i];
const seen = new Map();
for (let j = i + 1; j < n; j++) {
const complement = target - nums[j];
if (seen.has(complement)) {
// 找到一对 (nums[j], complement)
}
seen.set(nums[j], j);
}
target = -nums[i]:需要找两数之和等于这个值。seen是Map,键为数值,值为下标。存储的是已经遍历过的元素。- 为什么先
has再set?避免同一个元素被用两次。例如target = 2,数组[1, 1],先遍历第一个1:seen为空,存1;遍历第二个1:complement = 1,seen.has(1)为true,找到[1, 1]。如果先set再has,第一个1存进去后立即查到自己,会错误地找到[1, 1](但只有一个1时不应该找到)。
执行演示(固定 nums[i] = -1,target = 1,剩余数组 [0, 1, 2, -1]):
| j | nums[j] | complement | seen.has(complement)? | 操作 |
|---|---|---|---|---|
| 1 | 0 | 1 | false | seen.set(0, 1) |
| 2 | 1 | 0 | true | 找到一对 (0, 1) |
| 3 | 2 | -1 | false | seen.set(2, 3) |
| 4 | -1 | 2 | true | 找到一对 (-1, 2) |
3. 全局去重(usedTriplet)
const usedTriplet = new Set();
if (seen.has(complement)) {
// 对整个三元组排序生成唯一键
const triplet = [nums[i], complement, nums[j]].sort((a, b) => a - b);
const key = triplet.join(',');
if (!usedTriplet.has(key)) {
result.push([nums[i], complement, nums[j]]);
usedTriplet.add(key);
}
}
usedTriplet是全局 Set,存储所有已经记录过的三元组。[nums[i], complement, nums[j]].sort((a, b) => a - b).join(','):将三个数字排序后用逗号拼接成字符串。例如[-1, 0, 1]排序后是[-1, 0, 1],拼接成"-1,0,1"。[0, -1, 1]排序后也是[-1, 0, 1],拼接成"-1,0,1"。两者映射到同一个 key,实现去重。
为什么必须用全局 Set?
假设数组 [-1, 0, 1],哈希表遍历:
i = 0,nums[0] = -1:seen遍历到1时,complement = 0,seen.has(0)为true,找到[-1, 0, 1],记录i = 1,nums[1] = 0:seen遍历到1时,complement = -1,seen.has(-1)为true,找到[0, -1, 1],即[-1, 0, 1],重复
如果只对当前 i 下的两数组合去重(局部 usedPair),i = 1 时 usedPair 是新的空 Set,无法识别 [0, -1, 1] 已经记录过。只有全局 Set 能挡住跨 i 的重复。
错误写法:
// ❌ 只对两数组合去重(局部 usedPair),跨 i 重复无法处理
const usedPair = new Set(); // 定义在 for 循环内部
// 不同 i 下,相同的三元组会被重复记录
// ❌ 不排序,无法识别 (a,b,c) 和 (c,b,a) 是同一组
const key = `${nums[i]},${complement},${nums[j]}`;
方法补充
Set
const set = new Set();
set.add(value); // 添加元素,返回 Set 本身
set.has(value); // 判断是否存在,返回 boolean
set.delete(value); // 删除元素,返回 boolean
set.size; // 获取元素个数
Set 中元素是唯一的,重复 add 同一个值不会增加新元素。
Map
const map = new Map();
map.set(key, value); // 存入键值对,返回 Map 本身
map.has(key); // 判断是否存在某个键,返回 boolean
map.get(key); // 获取键对应的值,不存在返回 undefined
map.delete(key); // 删除键值对,返回 boolean
map.size; // 获取键值对个数
Map 的键可以是任意类型(包括数字、字符串、对象),本题用数字作为键。
sort() 与 join()
// sort() 默认按字符串排序,对数字会出错
[1, 10, 2].sort(); // [1, 10, 2] ❌ 按字符串排序
[1, 10, 2].sort((a, b) => a - b); // [1, 2, 10] ✅ 按数字升序
const arr = [2, -1, 1];
arr.sort((a, b) => a - b); // [-1, 1, 2]
arr.join(','); // "-1,1,2"
sort() 将数组元素按升序排列,join() 用指定分隔符将数组元素拼接成字符串。两者配合生成唯一的组合标识。
continue
作用: 跳过当前循环的剩余代码,直接进入下一次迭代。
for (let i = 0; i < 5; i++) {
if (i === 2) continue;
console.log(i); // 输出 0, 1, 3, 4(跳过了 2)
}
和 break 的区别:
break:终止整个循环continue:只跳过当前这一次,循环继续
在本题中的应用:
if (usedFirst.has(nums[i])) continue;
当前 nums[i] 的值已经处理过了,没必要再找一遍,直接跳过本次循环,进入下一次 i。
执行演示
输入 nums = [-1, 0, 1, 2, -1, -4]
i = 0,nums[i] = -1,target = 1,usedFirst 存入 -1
| j | nums[j] | complement | seen.has? | 操作 |
|---|---|---|---|---|
| 1 | 0 | 1 | false | seen.set(0, 1) |
| 2 | 1 | 0 | true | 三元组 [-1,0,1] 排序后 key=-1,0,1,usedTriplet 没有,记录 |
| 3 | 2 | -1 | false | seen.set(2, 3) |
| 4 | -1 | 2 | true | 三元组 [-1,2,-1] 排序后 key=-1,-1,2,usedTriplet 没有,记录 |
| 5 | -4 | 5 | false | seen.set(-4, 5) |
i = 1,nums[i] = 0,target = 0
| j | nums[j] | complement | seen.has? | 操作 |
|---|---|---|---|---|
| 2 | 1 | -1 | false | seen.set(1, 2) |
| 3 | 2 | -2 | false | seen.set(2, 3) |
| 4 | -1 | 1 | true | 三元组 [0,1,-1] 排序后 key=-1,0,1,与 i=0 时的 [-1,0,1] 的 key 相同,usedTriplet 已有,跳过 |
| 5 | -4 | 4 | false | seen.set(-4, 5) |
i = 2,nums[i] = 1,usedFirst.has(1)?false,继续
后续不会找到更多。
i = 3,nums[i] = 2,usedFirst.has(2)?false
虽然 nums[3] = 2 > 0,但因为数组未排序,后面可能还有负数(如 -4),所以无法剪枝。
i = 4,nums[i] = -1,usedFirst.has(-1)?true → continue 跳过
返回 [[-1, 0, 1], [-1, -1, 2]]
错误回顾
| 错误写法 | 问题 | 正确写法 |
|---|---|---|
只 has 不 add | usedFirst 永远为空,外层不产生去重效果 | has 后必须 add |
| 内层去重只存两数组合(局部 usedPair) | 不同 i 下的相同三元组无法去重 | 全局 Set 存整个三元组 |
| 内层去重不排序 | (a,b,c) 和 (c,b,a) 被当成两组 | [a,b,c].sort().join(',') |
先 set 再 has | 同一个元素可能被使用两次 | 先 has 再 set |
复杂度分析
- 时间复杂度:O(n²)。外层循环O(n),内层遍历O(n),总计O(n²)。哈希表查找和插入均为O(1)。
- 空间复杂度:O(n)。
seen在最坏情况下存储 O(n) 个元素,usedFirst和usedTriplet各 O(n)。
面试追问
Q:哈希表解法为什么不需要排序?
A:因为查找两数之和依赖的是 Map 的 O(1) 查找,不需要数组有序。但代价是空间复杂度从 O(1) 升到 O(n),且去重逻辑更复杂。
Q:为什么用全局 Set 而不是局部 Set?
A:因为不同 i 下可能产生相同的三元组(例如 [-1,0,1] 和 [0,-1,1])。局部 Set 只能去重当前 i 下的重复,无法处理跨 i 的重复。全局 Set 能确保整个结果集中没有重复三元组。
Q:两种解法面试时选哪个?
A:双指针是本题的标准解法,空间 O(1) 更优,推荐首选。哈希表解法展示了你对"两数之和"问题的延伸理解,可以作为追问"能不能不排序"时的补充方案。两种都掌握最佳。
相似题型
- 两数之和(LeetCode 1):哈希表经典题,是本题的子问题。
- 四数之和(LeetCode 18):可以用哈希表套两层循环,也可以用双指针。
- 最接近的三数之和(LeetCode 16):双指针框架,判断条件从"等于target"变为"更新最小差值"。