NO.15 三数之和(哈希表解法)——LeetCode热题100面试导向深度解析

3 阅读9分钟

本文讲解哈希表解法。双指针解法是本题的标准答案,空间 O(1) 更优,但哈希表解法展示了"不排序"的另一种思路,可作为面试追问的补充方案。

题目:

给你一个整数数组 nums ,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != ji != kj != 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,供后面的元素配对。

但数组无序会带来去重难题。双指针解法通过排序后跳过相邻重复元素来去重,哈希表则需要额外处理:

  1. 外层去重:相同的 nums[i] 只能作为第一个数处理一次
  2. 内层查找:用 Map 记录已遍历的值,通过 target - nums[j] 是否在 Map 中判断是否存在配对
  3. 全局去重:不同 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:至少要留两个位置给 jk,数组长度小于3时循环不执行。
  • usedFirstSet 集合,记录所有已经作为第一个数处理过的值。
  • 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]:需要找两数之和等于这个值。
  • seenMap,键为数值,值为下标。存储的是已经遍历过的元素。
  • 为什么先 hasset?避免同一个元素被用两次。例如 target = 2,数组 [1, 1],先遍历第一个 1seen 为空,存 1;遍历第二个 1complement = 1seen.has(1)true,找到 [1, 1]。如果先 sethas,第一个 1 存进去后立即查到自己,会错误地找到 [1, 1](但只有一个 1 时不应该找到)。

执行演示(固定 nums[i] = -1target = 1,剩余数组 [0, 1, 2, -1]):

jnums[j]complementseen.has(complement)?操作
101falseseen.set(0, 1)
210true找到一对 (0, 1)
32-1falseseen.set(2, 3)
4-12true找到一对 (-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 = 0nums[0] = -1seen 遍历到 1 时,complement = 0seen.has(0)true,找到 [-1, 0, 1],记录
  • i = 1nums[1] = 0seen 遍历到 1 时,complement = -1seen.has(-1)true,找到 [0, -1, 1],即 [-1, 0, 1],重复

如果只对当前 i 下的两数组合去重(局部 usedPair),i = 1usedPair 是新的空 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

jnums[j]complementseen.has?操作
101falseseen.set(0, 1)
210true三元组 [-1,0,1] 排序后 key=-1,0,1,usedTriplet 没有,记录
32-1falseseen.set(2, 3)
4-12true三元组 [-1,2,-1] 排序后 key=-1,-1,2,usedTriplet 没有,记录
5-45falseseen.set(-4, 5)

i = 1,nums[i] = 0,target = 0

jnums[j]complementseen.has?操作
21-1falseseen.set(1, 2)
32-2falseseen.set(2, 3)
4-11true三元组 [0,1,-1] 排序后 key=-1,0,1,与 i=0 时的 [-1,0,1] 的 key 相同,usedTriplet 已有,跳过
5-44falseseen.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]]


错误回顾

错误写法问题正确写法
hasaddusedFirst 永远为空,外层不产生去重效果has 后必须 add
内层去重只存两数组合(局部 usedPair)不同 i 下的相同三元组无法去重全局 Set 存整个三元组
内层去重不排序(a,b,c)(c,b,a) 被当成两组[a,b,c].sort().join(',')
sethas同一个元素可能被使用两次hasset

复杂度分析

  • 时间复杂度:O(n²)。外层循环O(n),内层遍历O(n),总计O(n²)。哈希表查找和插入均为O(1)。
  • 空间复杂度:O(n)seen 在最坏情况下存储 O(n) 个元素,usedFirstusedTriplet 各 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"变为"更新最小差值"。