刷完训练营第一天的数组专题,第二天迎面就是哈希表的三道题目:454. 四数相加 II、15. 三数之和、18. 四数之和。说实话,刚看到题号的时候我并没有太在意,毕竟“两数之和”谁都会写,哈希表查重也谈不上难。真正上手之后才意识到,这三道题放在一起是有讲究的——它们呈现了哈希表从“计数辅助”到“双指针降维”再到“剪枝优化”的完整梯度,尤其是15题和18题的去重细节,几乎能让每个第一次写的人都在测试用例上翻车。这篇就按我实际刷题时的思路顺序,把这三道题从暴力到最优的推导过程、代码实现和踩坑记录完整复盘一遍。
1. 为什么训练营第二天就上三块硬骨头
先聊一个很多刷题新手会困惑的问题:这三道题明明都是“找几个数凑某个和”,为什么归到哈希表专题?其实哈希表在这三道题里扮演的角色完全不同。
454题是哈希表的本职工作场景——统计两个数组合并后的和频次,再用另一个两数之和去匹配,这个思路非常“哈希”。而15题和18题如果硬用哈希表做,你会发现去重逻辑非常痛苦,反而排序后配合双指针更加干净,哈希表在这里只承担“辅助判断”的次要角色。训练营把三道题放在同一天,其实是在传递一个信号:数据结构是工具,不是目的。什么时候用哈希表、什么时候放弃哈希表改双指针,这种判断力比背模板重要得多。
再说难度曲线。454题是标准的“用空间换时间”入门题,理解了分组匹配的复杂度推导后基本可以一次写对。15题是面试高频,尤其是去重的边界条件,很容易在while循环里写错。18题是15题的套娃版本——多套一层循环,但剪枝细节需要单独处理。三道题刷完,你对“哈希表解决配对问题”的整套认知会清晰很多。
我在代码随想录的开篇导读里看到过一句话:训练营每天的题量不算大,但每道题都值得反复咀嚼。第二天的这三道题尤其符合这个判断——表面看都是“相加等于某个数”的套路,实际每道题的思考方式都有本质差异,值得一篇长文展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四数相加 II:HashMap 的计数用法才是重点
先看原题描述:给你四个整数数组 nums1、nums2、nums3、nums4,数组长度都是 n,请你计算有多少个元组 (i, j, k, l) 能满足 nums1[i] + nums2[j] + nums3[k] + nums4[l] == 0。
注意这里有个硬性条件:只需要返回元组数量,不需要返回具体下标组合。这一点非常重要,直接决定了我们能用哈希表做“计数配对”,而不必费劲去重。
2.1 暴力四重循环为什么不行
最直观的做法当然是四层for循环,枚举所有 (i, j, k, l) 组合,判断和是否为零,能 AC 的前提是 n 足够小。但这题的数组长度限制是 n <= 200,四层循环就是 200^4 = 16亿次枚举,显然会超时。
从复杂度上看,四重循环是 O(n^4),这在算法竞赛里是会被直接枪毙的复杂度。哪怕 n = 100,1亿次操作也要跑好几秒,更别说 200 的数据规模。
有些同学可能会想:能不能给四个数组都排序,然后枚举前三个数,最后一个数组用二分查找?这个思路能把复杂度降到 O(n^3 log n),但实现起来要处理重复元素的情况,而且仍然不是最优解。真正漂亮的解法,是两两分组后用哈希表。
2.2 从 O(n^4) 到 O(n^2) 的核心推导
我一直觉得,理解这题最好的切入点是“拆成两个两数之和”。
四个数相加等于0,本质上等价于:
nums1[i] + nums2[j] = -(nums3[k] + nums4[l])
也就是说,我们不需要同时关心四个数组,只需要先用哈希表记录“前两个数组所有和的出现次数”,再遍历“后两个数组所有和”,查表寻找相反数。伪代码思路:
cpp复制// 第一步:用哈希表记录 nums1 + nums2 的所有和及出现次数
unordered_map<int, int> ab_sum;
for (int a : nums1) {
for (int b : nums2) {
ab_sum[a + b]++;
}
}
// 第二步:遍历 nums3 + nums4 的和,查表找相反数
int count = 0;
for (int c : nums3) {
for (int d : nums4) {
int target = -(c + d);
if (ab_sum.find(target) != ab_sum.end()) {
count += ab_sum[target];
}
}
}
为什么这个做法是完全正确的?因为对任意一组满足条件的 (i, j, k, l),一定有 nums1[i] + nums2[j] 存在于哈希表中,且它的值等于 -(nums3[k] + nums4[l])。枚举后两个数组的所有组合时,恰好会命中这个和,并且通过哈希表里记录的“出现次数”把不同下标组合全部计入。
这一步的时间复杂度是 O(n^2),两个双重循环分别负责建表与查表;空间复杂度是 O(n^2),因为最坏情况下 nums1 + nums2 的和可能有 n^2 种不同取值。相比 O(n^4) 的暴力,这是一个数量级层面的改善——n=200 时,运算量从16亿次降到4万次,完全不是一个量级。
2.3 为什么用 unordered_map 而不是 map
刷题时我习惯用 unordered_map 而不是 map,原因是哈希表在平均情况下的查找和插入都是 O(1),而红黑树实现的 map 是 O(log n)。当键值对数量达到 n^2 的量级时,这个常数差异会被明显放大,尤其在 LeetCode 这类对耗时敏感的场景下。
当然 unordered_map 也有代价:键的哈希冲突会带来常数开销,某些极端数据下可能退化,但这类题目不需要杞人忧天。需要注意的是,unordered_map 的 [] 操作符在键不存在时会默认插入一个零值,所以如果你只想查询而不想破坏现有结构,尽量用 find 或 at。
这题还有一个面试延伸点:如果 nums1 和 nums2 的长度不同怎么办?其实思路完全一样,只要四个数组长度相同就能两两分组。如果长度差异特别大,可以把较长的两个数组放在同一组,较短的放在另一组,这样建表循环的次数会少一些,属于一个微优化。
站在个人刷题的角度说,454题是三道题里最“友善”的——去重交给哈希表自动处理,你只需要设计好分组逻辑,基本不会出边界问题。
3. 三数之和:为什么哈希集合在这里反而别扭
15题是三数之和,题目描述很简洁:给你一个整数数组 nums,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j、i != k 且 j != k,同时还满足 nums[i] + nums[j] + nums[k] == 0。注意这里额外要求返回所有和为0且不重复的三元组。
很多同学从454题过渡过来,会本能地沿用“哈希表记录两数和”的思路:两个for循环枚举两个数,第三个用哈希表查。我也这么试过,实测下来发现去重会让代码变得非常丑陋。
3.1 哈希集合做法“能解但难去重”的问题
照搬454的思路,可以这样写:先用两层for循环枚举前两个数,查第三个数 -nums[i]-nums[j] 是否在集合中。但这样会找到重复的三元组,比如数组里的多个相同数字会带来大量相同组合。为了去重,常见的招数是先把数组排序,然后每层循环都跳过重复元素,还要借助集合来避免结果里有重复三元组。
实际上,i 和 j 的去重逻辑互相影响,如果只对 i 去重而对 j 不处理,或者在 i 固定的情况下 j 跳过了本应该参与组合的重复值,结果就会少一种合法三元组或者多出重复三元组。特别是当 nums[i] == nums[i+1] 时,有些模板会让 i 直接跳过,这对于“排列型”搜索是正确的,但对于“组合型”搜索恰恰会漏解。
一个经典的错误写法就是在第一个循环里写 while (i < n && nums[i] == nums[i + 1]) i++;,这会漏掉 nums[i] 与 nums[i+1] 共同出现在一个三元组里的情况。虽然三数之和下如果两个相同元素都等于 nums[i],它们相加再加上第三个数可能为零,但这种组合实际上会被另一个位置枚举到,所以规律非常隐蔽。自己推导的时候很容易绕晕。
所以结论很清晰:哈希表适合“判断存在性”和“统计数量”,但三数之和要求的是枚举所有不重复的组合,这个场景更适合“排序 + 双指针”。
3.2 排序 + 双指针的设计动机
排序 + 双指针的思路是三层循环的降维版。排序让数组有序后,核心问题变成:如何用两个指针代替两层循环?
固定当前元素 nums[i] 之后,问题退化成在 i 右侧的区间 [i+1, n-1] 中寻找两个数之和等于 -nums[i]。因为数组已经有序,可以维护两个指针 left = i+1、right = n-1,通过比较 nums[i] + nums[left] + nums[right] 与 0 的大小关系来移动指针:
- 如果和等于0,记录答案,同时移动 left 和 right 以寻找下一组;
- 如果和大于0,说明 right 指向的值太大,需要 right--;
- 如果和小于0,说明 left 指向的值太小,需要 left++。
这样为什么能将 O(n^3) 降到 O(n^2)?因为每次固定 i,内部的 left 和 right 都最多移动 n 次,总复杂度是 O(n^2)。这个降维的关键是:排序后的单调性保证我们可以通过比较大小决定指针移动方向,而不是像哈希法那样盲目地枚举所有组合。
排序本身的开销是 O(n log n),在 n 较大时完全可接受。
3.3 双指针实现时第一个版本我踩的坑
我的第一版代码是这样写的:
cpp复制vector<vector<int>> threeSum(vector<int>& nums) {
vector<vector<int>> result;
sort(nums.begin(), nums.end());
int n = nums.size();
for (int i = 0; i < n; i++) {
if (nums[i] > 0) break;
if (i > 0 && nums[i] == nums[i-1]) continue;
int left = i + 1;
int right = n - 1;
while (left < right) {
int sum = nums[i] + nums[left] + nums[right];
if (sum < 0) {
left++;
} else if (sum > 0) {
right--;
} else {
result.push_back({nums[i], nums[left], nums[right]});
while (left < right && nums[left] == nums[left+1]) left++;
while (left < right && nums[right] == nums[right-1]) right--;
left++;
right--;
}
}
}
return result;
}
第一版写完我自信满满地提交,结果在包含重复元素的用例上全挂了。问题出在 push_back 之后的那两个 while 去重循环:我先移动 left 跳过重复值,再对 left 做 left++,但实际上多跳了一个位置。比如数组是 [-2, -2, 0, 2, 2],当 i=0 指向 -2 时,left=1 也指向 -2,right=4 指向 2,此时三数之和为 -2 + (-2) + 2 = -2,不等于0。继续移动指针后才会碰到答案,但由于去重位置不对,我漏掉了 -2, 0, 2 这个组合。
调试之后才理清楚,正确逻辑是:找到结果后,先跳过所有与当前 left 相同的值,再跳过所有与当前 right 相同的值,最后左右指针同时收缩到第一个不同的新位置。对应代码应该是:
cpp复制while (left < right && nums[left] == nums[left+1]) left++;
while (left < right && nums[right] == nums[right-1]) right--;
left++;
right--;
这段代码的三个动作顺序缺一不可,少一个都会导致重复答案或漏解。很多LeetCode打卡贴的评论区问“为什么这里要先 while 再 ++”,本质原因就是:如果只 while 不 ++,循环结束时 left 指向的是最后一个重复值而不是新的非重复值,跳过不到位;如果只 ++ 不 while,则仍然可能指向重复值,下一轮会生成重复三元组。
4. 三数之和与四数之和的去重:几乎所有 Bug 都出在这里
如果让我用一句话概括第二天的核心难点,我会说:454题考的是哈希计数,15题和18题考的是“去重”。三道题放在一起刷,去重的感觉会被放大得非常明显。
4.1 为什么固定元素去重要看 nums[i-1] 而不是 nums[i+1]
三数之和里,外层对 i 的去重有两种写法:
- 写法A:if (i > 0 && nums[i] == nums[i-1]) continue;
- 写法B:if (i > 0 && nums[i] == nums[i+1]) continue;
写法B是新手最容易踩的坑。以 nums = [-1, -1, 0, 1] 为例,正确的三元组只有一个:[-1, 0, 1]。如果采用写法B,当 i=0 指向第一个 -1,找到一个答案 [-1, 0, 1] 后,i 移动到1,此时检查 nums[1] == nums[2](-1 == 0 假),不会跳过,正常进入下一轮。但假如数组是 [-1, -1, 2],这里没有合法答案,写法B不会造成漏解。写法B真正致命的地方在于下面这种用例:nums = [-1, -1, 2, -1],如果 i=0 指向 -1,left=1 也指向 -1,right 指向末尾,left < right 时 left 和 right 组成的候选值可能形成 -1 + -1 + 2 = 0。此时 i=0 已被接受,但后续 i=1 时因为写法B误判为与 nums[2] 相等而跳过,可能导致包含 nums[1] 的组合未被完整枚举。
严谨的说法是:写法B会影响 I 固定元素本身是否能与右侧其他相同值组合成答案,所以存在漏解风险。写法A比较的是当前元素和前一个已经处理过的元素,如果相等,说明以当前值为首元素的三元组在上一次循环已经全部枚举完毕,直接跳过不会漏解。这就是“只看后面”和“只看前面”的本质区别。
4.2 left 和 right 的去重放在哪个时机
再说双指针内部的两个去重 while。有些同学会在进入 while (left < right) 之前就先把所有重复值跳过,然后再开始找答案。这种做法有概率漏解,原因是最左侧的重复值可能正是和固定元素 i 组成合法组合的关键值。
我的建议是:先去重,或者找到答案后去重都可以,但前提是保证不漏掉有效组合。以 [-2, 0, 0, 2, 2] 为例,正确结果是 [-2, 0, 2]。如果 right 指针先被跳过到右侧第二个 2,left 从0开始指向0,依然可以搜到 [-2, 0, 2];但如果把 left 跳过到第二个0,那 left=1 或者 left=2 都是0,组合仍能搜到。而真正不安全的做法是提前把等于 nums[i] 的边角值全部剔除,比如固定 i=0 指向 -2 时,如果 left 被要求跳过所有 nums[left] == nums[i] 的元素,就会漏掉 [-2, 0, 2] 里 left=1 位置的0和 right=3 位置的2。因为我们找的是三个不同元素,固定 i 之后,其余两个元素可以与 nums[i] 数值相等,只要下标不同就合法。
因此最佳实践是:外层固定 i 去重采用“是否等于前一个元素”,以确保每轮固定值不同;找到答案后再对 left 和 right 进行去重跳跃,并额外执行一次 left++/right-- 移动到全新的位置。
4.3 18题多了一层循环,去重也跟着加倍
四数之和是三数之和的扩展,需要在最外层再套一个循环。我原以为“照抄三数之和再套一层”就行,结果提交后各种重复三元组出现。根本原因在于:外层每一层循环都需要独立的去重判断,不能只看两层。
第一层 for 遍历 k,需要跳过重复主元:
cpp复制if (k > 0 && nums[k] == nums[k-1]) continue;
第二层 for 遍历 i(从 k+1 开始),同样需要:
cpp复制if (i > k+1 && nums[i] == nums[i-1]) continue;
注意这里的边界条件是 i > k+1 而不是 i > 0。因为内层循环的起始位置是 k+1,而不是 0,使用 i > 0 会在某些情况下跳过本应作为第二个元素的重复值。
双指针部分的去重和15题完全一致:找到一组答案后,先跳过重复的 left,再跳过重复的 right,最后统一收缩。但如果内层循环起始元素与 nums[k] 相同,而它们又处于不同下标时,不可以直接跳过。很多四数之和的题解评论区都在问“为什么第二层不是从0开始而是从k+1开始”,答案很简单:四个下标必须互不相同,第二层只能从第一层右侧选。
5. 四数之和的剪枝:从 AC 到更优
光谈去重还不够,18题还有一个实际刷题中会遇到的性能问题。如果只是无脑四层循环或者照抄15题的双指针,遇到数组特别长时会超时。LeetCode上这题的数据范围是 n <= 200,四个数的组合数大约有 C(200,4) ≈ 6500万种,纯暴力确实算得动但很悬。真正保险的写法必须带上剪枝。
5.1 一层剪枝不能在 target 小于0时简单用 nums[i] > target 判断
三数之和里有一个常见剪枝:排序后,如果 nums[i] > 0,那么后面的元素也都大于0,三个正数相加不可能小于等于0,break 即可。
四数之和里不少人照搬这个思路,写成:
cpp复制if (nums[k] > target) break;
但 target 可能是负数。比如 nums = [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4],target = -8。如果排序后第一个元素是 -5,-5 > -8 为假,不会 break,正确。可如果第一个元素是 -3,-3 > -8 为假,但后面全加起来可能也无法达到 -8,这个剪枝不起作用。
那么正确的剪枝是什么?我参考代码随想录推荐的做法是:在进入循环前先做最小值判断,如果当前最小的四个数之和已经大于 target,说明后续不可能有更小的组合,可以直接 break;如果当前最大的四个数之和小于 target,说明当前主元不够大,需要 continue。
以第一层循环为例:
cpp复制if ((long long)nums[k] + nums[k+1] + nums[k+2] + nums[k+3] > target) break;
if ((long long)nums[k] + nums[n-3] + nums[n-2] + nums[n-1] < target) continue;
这里用 nums[k+1]、nums[k+2]、nums[k+3] 是因为它们是剩余元素中最小的三个,和当前主元组合得到的是“最小四数和”;同理 nums[n-3]、nums[n-2]、nums[n-1] 是剩余元素中最大的三个。第二个条件如果成立,说明把当前最小的主元放在这里,搭档即使都是最大的三个,也没法凑到 target,那当前主元肯定太小,直接换下一个主元即可(continue)。
第二层循环的剪枝类似,但要基于主元 k 固定之后的基础上来判断:
cpp复制if ((long long)nums[k] + nums[i] + nums[i+1] + nums[i+2] > target) break;
if ((long long)nums[k] + nums[i] + nums[n-2] + nums[n-1] < target) continue;
实际刷题中这四个剪枝能显著降低运行时间,尤其在接近超时边界的测试用例上。我提交过一版没有剪枝的代码,能过,但耗时排在末尾;加上剪枝后时间立刻降了一个档次。
5.2 用 long long 防止整型溢出
四数之和给的 nums[i] 范围是 [-10^9, 10^9],target 也是 [-10^9, 10^9]。四个元素相加时,最大可能等于 4 * 10^9,超过了 int 的表示范围(约 2.1 * 10^9)。所以计算和与剪枝比较时,务必把整数转换为 long long。
我有一次在本地测试时全通过,提交后却因为溢出报错,排查好久才发现是 int 相加后溢出。LeetCode 的判题器不会帮你自动提升类型,C++ 里 int 相加溢出是未定义行为,不同编译器结果可能不同。写法和剪枝条件中显式转型:
cpp复制long long sum = (long long)nums[k] + nums[i] + nums[left] + nums[right];
这是一种成本极低但收益极高的习惯。凡是题目给的数据范围可能让中间过程超出 int,就该直接声明 long long。这道题是典型场景,以后刷其他题也可以保留这个习惯。
5.3 循环变量边界的细节:i 从 k+1 开始,left 从 i+1 开始
四数之和的双指针内层结构中,各层起始位置很容易写错。我建议画一张下标约束图:四个下标 k < i < left < right,因此才能保证四个数来自不同位置且组合不重复。每次循环固定前两个,后面两个靠双指针扫描,这就是“三数之和”的降级版场景。
注意第二层循环的终止条件是 i < n - 2,而不是 i < n - 1,因为后面还必须留至少两个位置给 left 和 right。同理,k 的终止条件是 k < n - 3。新手常犯的错误是循环条件写太宽,然后指针越界,或者在剪枝比较时访问 nums[i+2] 超出边界。
6. 三道题一起刷完后的对比与启发
三道题全部跑通之后,我整理了一张脑图式的对比表。这里也放出来,方便你直接参考:
| 题号 | 核心数据结构 | 时间复杂度 | 关键难点 |
|---|---|---|---|
| 454. 四数相加 II | unordered_map | O(n^2) | 两两分组,建表与查表 |
| 15. 三数之和 | 排序 + 双指针 | O(n^2) | 去重逻辑与边界条件 |
| 18. 四数之和 | 排序 + 双指针 + 剪枝 | O(n^3) | 双层固定 + 双指针去重、剪枝判断、溢出 |
这张表透露出的信息值得细品。454 和 15 的复杂度都是 O(n^2),但实现思路完全不同,因为前者需要“数量”而后者需要“具体组合”。15 和 18 题的核心代码结构很相似,但 18 题多一层固定就多一层去重判断,相当于把 15 题的所有细节放大了一倍。
顺便说一下哈希表的取舍心得。训练营里反复强调哈希表适合的三个场景:判断元素是否出现过、统计元素出现次数、映射键值对。454题完美匹配前两个;15题虽然也可以勉强使用哈希表,但去重的额外代价会超过收益;18题则完全没有理由使用哈希表。所以第二天的专题训练更接近一场“数据结构选型”练习,而不是单纯的哈希表模板题。
6.1 每道题建议练到什么程度
对于今天的三道题,我希望达到的标准不是“提交通过一次”,而是能完成以下三件事:
第一,不看题解,能独立写出正确且带注释的完整代码。454题的代码必须控制在20行以内,15题控制在40行以内,18题控制在60行以内。如果超过这个体量,大概率是逻辑冗余或者去重位置写重复了。
第二,能口头解释清楚每个循环变量的上下界。比如为什么要用 i < n-2,为什么 left 从 i+1 开始而不是从0开始,为什么外层固定元素去重要用 nums[i-1] 而不是 nums[i+1]。面试时这些细节往往比代码本身更值得说。
第三,对于三道题各自的“最阴间测试用例”有明确感知。454题是全部为0的数组,建表后所有和的频次集中在0上;15题是包含大量重复元素的数组比如 [-4,-1,-1,0,1,2],验证去重逻辑;18题是类似 [1000000000,1000000000,1000000000,1000000000] 配合 target 为0的情况,验证溢出处理。
6.2 刷题时值得记录的几个心得
写代码的顺序也可以沉淀出一个固定套路:先排序,再固定,再去重,再双指针。排序是一切的基石,不排序的话双指针无法利用单调性;固定是最外层循环,确定主元;去重紧跟在每次固定之后;最后才进入双指针扫描。这个流水线式的思维框架对这三道题都适用,即使以后遇到 k-sum 类题目也能按这个思路扩展。
另外一个小建议是:一定要把 LeetCode 的默认函数签名里的 vector
6.3 关于“代码随想录训练营”这个模式的个人体验
连续两天跟下来,我自己最大的感受是:训练营的价值不在于题目数量,而在于把同一条主线上的题目编排在一起,让人自然地体会“不同情况该用不同数据结构”。如果我只是单独刷 454、15、18 这三道题,很可能止步于“背住一套模板”的层次,而不会去思考为什么 454 适合哈希而 15 适合双指针。
第二天的学习曲线是陡峭的,尤其是从 15 题过渡到 18 题那一层额外循环,我花了接近一个小时调试去重和剪枝才彻底顺过来。但这个过程非常值得,因为之后我对哈希表和双指针的功能边界都有了更清晰的认识。顺着这个方法继续刷下去,预计后面遇到更复杂的计数和区间类题目时,思考和调试成本都会下降不少。
