先汇报一下进度:今天是代码随想录算法训练营的第六天,刷题任务是 LeetCode 上的 454. 四数相加 II、383. 赎金信、15. 三数之和、18. 四数之和。如果你正在跟这个系列,大概率也能感觉到,前几道题主要是在“哈希表”里打转,到 15 题和 18 题突然出现了一个很经典的套路——排序加双指针。四道题放一起刷,反而比单独刷更爽,因为它们刚好把两个看起来不太相关的题型串成了一条线:什么时候用哈希表,什么时候该转向双指针,刷完基本就能建立起判断依据。
先说这套题最适合谁。如果你已经掌握数组、链表、哈希表的基础用法,需要提升“做题手感”,这个组合非常合适;如果你是二刷复盘,重点可以去抠三数之和和四数之和的去重细节,这两题是面试高频中的高频,也是很多人的失分重灾区。文章把四道题的完整思路、最终代码、常见 Bug 都整理了一遍,属于可以直接拿去对照复习的那种。
1. 从整天的题目编排看解题思维切换
1.1 前两道题和后两道题本质上是两个分支
不少刷题的人看到“454. 四数相加 II”时会有一个错觉,以为这题跟“18. 四数之和”是同一类问题,只是数组数量不同。实际上它们的解法差别非常大,甚至可以说代表了两条不同的分支。
454 题的形式是“四个独立数组,每个数组取一个数,让总和等于 0”,最后统计满足条件的组合数量。注意关键词:统计数量。这意味着它不需要你罗列出每种具体的组合,只要是合法匹配就算一次。这种场景最合适的数据结构就是哈希表,因为不需要去重,不需要排序,我们可以放心地做“分组计数”:先算出前两个数组所有和的出现次数,再在遍历后两个数组时查出“还差多少”。
383 赎金信则更简单,它要判断的是 magazine 字符串里的字符能不能拼出 ransomNote,本质上是一个“计数账本”问题。因为字符串只有 26 个小写字母,数组就够用了,连哈希表都可以不用。这里体现出一个很重要的原则:能用数组当哈希表,就不要偷懒上 unordered_map,占用更小、性能更高。
15 三数之和开始变了。它从一个数组里选三个数,要求返回所有不重复的三元组。一旦出现“必须给出具体组合且不能重复”,哈希表方案的痛点就暴露出来了:找两个数很快,但要去重却需要大量额外的排序或集合操作,代码很容易失控。这时候标准解法是排序加双指针,利用有序性让指针在移动过程中自然跳过重复值,把去重这件事做得非常优雅。
18 四数之和就是 15 题的升级版,在排序加双指针的基础上多固定一层循环,然后把 target 从 0 换成一个任意整数。这个变化看起来小,实际写起来处处是坑,尤其是剪枝条件和 int 溢出问题,后面会详细说。
1.2 一种可迁移的分析顺序
这四道题刷下来,我最大的体会是:拿到“找几个数满足某种条件”的问题,先别急着套模板,可以按下面三个问题问自己:
第一,题目是“判断存在性”“计数”还是“枚举所有方案”?如果只统计数量,优先思考能不能用哈希表把两两结果存起来;如果要求枚举具体组合,大概率需要考虑去重,而“排序 + 双指针”通常是正解。
第二,数据范围是大还是小?在 454 题中,四个数组长度最多 200,两两组合产生 40000 个和,这个规模用哈希表完全可行。如果数组长度变成 50000,O(n^2) 的空间开销会撑不住,那就要换思路。在小数据范围下,最容易想到且不容易写错的方案往往是最好的。
第三,输出结果需不需要唯一性?三数之和和四数之和都需要避免得到重复的组合,因此在逻辑上必须引入“跳过重复值”的机制。而 454 题每个组合都来自固定的四个数组下标,天然不会重复,所以完全不用关心去重问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 哈希表实战:454. 四数相加 II 与 383. 赎金信
2.1 454 四数相加 II:两两分组再合并
这道题的朴素做法是四层循环,复杂度 O(n^4),在 n=200 时大约是 16 亿次操作,不可能通过。既然四层循环不现实,一个常见的优化思路就是“分组”。把 A、B 看成一组,把 C、D 看成另一组。两个数组内各取一个数相加,一共有 n^2 种情况,这个量级是 40000,完全能在时间和空间上接受。
流程如下:先用两层循环遍历 A 和 B,用 unordered_map 记录所有 a + b 的和出现的次数。然后再用两层循环遍历 C 和 D,查找 0 - (c + d) 是否出现在 map 中。如果出现了,就把对应的次数累加到答案中。
有些朋友刷到这里时会走进一个误区:想在遍历 C、D 的过程中继续往 map 里添加 A、B 的和,结果发现漏解。原因很简单,哈希表里如果还没存完所有 A、B 组合,后面的查询就可能查不到本来应该命中的数据。正确流程是先完整统计 A、B,再开始查询 C、D。
核心代码我给一个可直接运行的版本:
cpp复制int fourSumCount(vector<int>& A, vector<int>& B, vector<int>& C, vector<int>& D) {
unordered_map<int, int> countAB;
for (int a : A) {
for (int b : B) {
countAB[a + b]++;
}
}
int result = 0;
for (int c : C) {
for (int d : D) {
int target = 0 - c - d;
if (countAB.find(target) != countAB.end()) {
result += countAB[target];
}
}
}
return result;
}
这里还有一个容易被忽略的点:为什么统计次数用的是 unordered_map 而不是 map?map 底层是红黑树,插入和查询都是 O(logn),而 unordered_map 底层是哈希表,平均 O(1)。在 n 最多 200 的情况下,map 和 unordered_map 的性能差异未必能明显感知,但从复杂度角度讲,unordered_map 才是更匹配这个题的工具。
另外,result 用 int 是否安全?LeetCode 本题限定四个数组长度都不超过 200,极端情况下全部都是 0,A、B 的和 0 会出现 40000 次,C、D 的每个查询也都会命中 40000 个答案,所以 result 最大接近 1.6e9,勉强不会超过 int 上限。如果后面遇到长度更大的变种题,建议直接用 long long 计数,这也是代码随想录里常强调的“别在数据边界上赌”。
2.2 383 赎金信:数组哈希的最简单模型
赎金信这道题可以这样理解:ransomNote 是最终要拼出来的一串字符,magazine 是手头的字符仓库,每个字符只能用一次。我们要判断仓库里的字符够不够用。
一个新手最容易想到的做法是遍历 ransomNote,每次从 magazine 里查找一个字符并去掉它。在字符集很小的时候,这种做法虽然可能通过,但效率不高,而且还要处理字符串删除、计数等问题,代码容易写脏。更稳妥的方式是统计 magazine 中每个字母出现的次数,再遍历 ransomNote 时逐个扣减,一旦出现某个字母的次数为负数,就说明不够用。
因为题目限定只有小写字母,直接用长度为 26 的 int 数组即可,不需要引入 unordered_map。这种“数组哈希”其实就是把字符 c 转成下标 c - 'a'。
cpp复制bool canConstruct(string ransomNote, string magazine) {
if (ransomNote.size() > magazine.size()) {
return false;
}
int record[26] = {0};
for (char c : magazine) {
record[c - 'a']++;
}
for (char c : ransomNote) {
record[c - 'a']--;
if (record[c - 'a'] < 0) {
return false;
}
}
return true;
}
这里加一个提前判断:如果 ransomNote 比 magazine 还长,直接返回 false,也算是一个小剪枝。第一次写这道题时,我试过把两个字符串顺序颠倒了,也就是先去减 ransomNote 再加 magazine,逻辑上完全反了,跑测试样例时能蒙混过关,一提交就报错。后来养成习惯:先统计“资源方”,再遍历“消耗方”,顺序就不会乱。
2.3 哈希表题的选型标准
两题刷完可以顺带总结一下哈希表容器的选择。如果 key 的类型是整数且范围有限,比如 ASCII 码 0 到 127,直接用 vector 或数组做桶;如果 key 是任意字符串、对象或整数范围很大,才考虑 unordered_map;如果需要 key 有序输出,或者需要依赖顺序的遍历,才使用 map。
在 383 题中使用数组还有一个隐含好处:空间固定,不会动态扩容,也不产生哈希计算的开销。LeetCode 上确实能看到有人用 unordered_map 解这道题,代码也不长,但我建议把“数组哈希优先”的思路内化,因为很多类似题目稍加变形,比如字符集变成 256 个 ASCII 字符,仍然是数组处理更稳。
如果题目字符集扩大到 Unicode,或者输入是英文单词而不是单个字符,那么数组就不再合适,unordered_map 才是通用解。判断依据就是一句话:key 空间是否足够小且连续。
3. 双指针经典:15. 三数之和
3.1 为什么三数之和不用三重循环
三数之和看起来很直白:三重循环枚举三个数,判和为 0,然后去重。如果 n 是 3000,三重循环是 270 亿次,跑都跑不完。但三重循环最大的问题反而不是时间,而是去重。就算你用三重循环找到了所有组合,还需要一个 set 来过滤重复三元组,每个三元组还得排序才能比较,复杂度直接爆炸。
所以经典解法是“排序 + 双指针”。为什么排序有帮助?因为排序后,一个数组内部的大小关系固定了。我们要找的是三个数,可以先固定第一个数 i,那么剩下两个数就可以用两个指针从左右两端往中间扫描。指针移动的时候,根据当前和与 0 的关系决定是 left++ 还是 right--,整个过程只需要 O(n) 时间。外层再套一层 i 的循环,总复杂度是 O(n^2)。
用双指针要有序,而排序本身是 O(nlogn)。整体时间复杂度是 O(n^2),空间复杂度如果只看额外空间就是 O(1)。这种复杂度对于 n 在几千以内的题已经足够好。
3.2 双指针实现的完整过程
先把核心流程拆成几步。第一步,对数组排序。第二步,从下标 0 开始遍历,把当前值 nums[i] 作为三元组的第一个数。第三步,令 left = i + 1,right = n - 1。如果三个数的和大于 0,右指针左移;如果小于 0,左指针右移;如果等于 0,记录结果。第四步,找到一组答案后,left 和 right 都要继续移动,否则会出现无限循环,因为此时 sum 已经等于目标值了。
完整代码可以这样写:
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) {
right--;
} else if (sum < 0) {
left++;
} 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;
}
注意看去重的两个 while。找到一组答案之后,先把和当前 left 指向值相同的下一个位置跳过,再把和当前 right 指向值相同的下一个位置跳过,最后才 left++、right--。这是为了保证不会再产生重复的三元组,同时又不会漏掉 left 和 right 交叉前的最后一组合法答案。
3.3 去重逻辑是这个题的核心
三数之和至少有一半的难点在去重。第一次写这题时,十有八九会在“重复三元组”上栽跟头。
最经典的错误是把外层去重写成 if (nums[i] == nums[i + 1]) continue。从表面看,这确实跳过了相邻相等值,但实际会漏解。举个例子,数组是 [-1, -1, 2],唯一满足条件的三元组是 [-1, -1, 2],如果 i=0 时因为 nums[0] == nums[1] 直接跳过,那这个答案就永远找不到了。因为这里本该由第一个 -1 作为起点,去匹配后面第二个 -1。所以正确写法是判断当前值是否和上一个已经处理过的值相等,也就是 i > 0 && nums[i] == nums[i - 1]。
这两个条件看起来只是下标差一位,实际语义完全不同。nums[i] == nums[i - 1] 表示“我这种值在上一次循环已经处理过了,跳过重复处理”;nums[i] == nums[i + 1] 表示“我的下一个值跟我一样,我不处理”,这会把很多必要的方案拦在门外。
在双指针内部找到答案后的去重也要注意顺序。网上很多版本是先把 left 移动到最后一个与当前值相同的位置,比如 while (left < right && nums[left] == nums[left + 1]) left++;,然后再 left++。如果你反过来写,比如先 left++ 再判断是否和前一个相等,那就需要调整判断条件。这不是对错问题,而是写的时候容易绕晕,建议每次刷题都固定用一种写法,我习惯找到答案后直接跳过重复值再各走一步,逻辑更容易理顺。
3.4 剪枝条件不是越多越好
三数之和里常见的一个剪枝是:排序后,如果 nums[i] > 0,直接 break。因为最小的数已经大于 0,往后所有数都比它大,三个数的和不可能等于 0。
那能不能把 > 写成 >=?不能。想一想数组全为 0 的情况,nums[i] == 0 时,仍然有 [0, 0, 0] 这个可行解。所以这里必须严格大于 0 才 break。
还有另一个常见问题是 i 的循环范围。这里没有写成 i < n - 2,因为循环内还有 break / continue 逻辑,而且双指针在 left < right 时会自动保证至少还有两个数,所以即使写成 i < n 也不会有越界访问。但为了语义更精确,我会写 i < n - 2,提醒自己 i 后面必须留出位置给 left 和 right,这样代码自我注释效果更好。
4. 双指针进阶:18. 四数之和
4.1 多一层循环,但复杂度上了一档
四数之和是完全可以在三数之和的基础上推导的。三数之和是固定一个数,然后在剩余区间里用双指针找两个数;四数之和是固定两个数,然后在剩余区间里用双指针找两个数。差别就是内部多套了一层循环,所以时间复杂度从 O(n^2) 变成 O(n^3)。
如果数组长度到 1000,O(n^3) 会非常吃力;但如果题目明确 n 只有 200 或 300,就可以放心用这个方法。在面试中,这类题更看重你对边界情况的把控,而不是堆砌各种聪明优化。
这里要特别提醒一下:四数之和不等于把四个数组拆开做哈希表的四数相加 II。两个题名字接近但场景完全不同。网上讨论经常有人把两题混为一谈,结果看代码越看越懵。四数之和是同一个数组里挑四个下标不同的元素,要求组合不重复;一旦要求“具体的组合不重复”,排序加双指针就是比哈希表更顺手的方案。
4.2 固定两个数:去重时别放过第二层循环
下面是通用写法。第一层循环固定 nums[i],第二层循环固定 nums[j],left 和 right 在剩余区间里移动。
cpp复制vector<vector<int>> fourSum(vector<int>& nums, int target) {
vector<vector<int>> result;
sort(nums.begin(), nums.end());
int n = nums.size();
for (int i = 0; i < n; i++) {
if (nums[i] > target && nums[i] >= 0) {
break;
}
if (i > 0 && nums[i] == nums[i - 1]) {
continue;
}
for (int j = i + 1; j < n; j++) {
long long twoSum = (long long)nums[i] + nums[j];
if (twoSum > target && twoSum >= 0) {
break;
}
if (j > i + 1 && nums[j] == nums[j - 1]) {
continue;
}
int left = j + 1;
int right = n - 1;
while (left < right) {
long long sum = (long long)nums[i] + nums[j] + nums[left] + nums[right];
if (sum > target) {
right--;
} else if (sum < target) {
left++;
} else {
result.push_back({nums[i], nums[j], 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;
}
第二层循环的去重条件很多人会写错。它不能是 if (j > 0 && nums[j] == nums[j - 1]),因为当 nums[j] 等于 nums[i] 时,第一层循环已经把 i 固定为某个值,j 可以取它后面与 nums[i] 相等的值。比如数组 [-1, -1, 0, 1, 2],当 i=0 时 nums[i]=-1,j=1 时 nums[j] 也是 -1,这是合法的,下标不同。所以 j 的去重条件是 j > i + 1,也就是只跳过“第二层循环中已经处理过的值”,而不是“下标 0 时处理的 i 位置值”。
很多刷题答案在这个条件上有意无意写错,测试样例少时还能过,一旦出现大量重复元素就会漏掉答案。我在本地测试时养成了一个习惯:先用重复元素非常多的数组去跑,比如 [-1,-1,-1,0,1,1],能通过这个用例基本说明去重逻辑没什么大问题。
4.3 target 不再是 0,剪枝条件要格外小心
三数之和里,排序后 nums[i] > 0 就可以 break。四数之和的问题在于 target 可以是任意整数,包括负数。
如果 target = -5,nums[i] = 1,你会看到 nums[i] > target 成立,但千万不能直接 break。因为排序后的第一个数是 1,后面可能全是负数吗?不会,排序后第一个数为 1 说明后续都大于等于 1,和不可能降到 -5。但第一个数是 1,后面也可能是很大的负数?不可能,数组已经排序,第一个数 1 代表后面都大于等于 1。所以这种情况下确实可以剪枝。但关键问题是:当 target 是负数时,nums[i] > target 太容易满足了,比如 nums[i] = -3,target = -5,-3 > -5 成立,此时能不能 break?不能。因为后面可能有 -2、0,四数和未必达不到 -5。所以必须加上 nums[i] >= 0 这样的限制。
代码随想录里给出的剪枝条件我实测有效:if (nums[i] > target && nums[i] >= 0) break;。第二层循环同理,要同时判断两层和与 target 的关系以及是否是非负数。这套条件不是那么容易理解,我建议直接把这两种写法都跑一遍,一种加限制,一种不加限制,观察边界用例就能体会出差别。
4.4 溢出:很多人阴沟翻船的地方
四数之和的中间计算一定要用 long long。LeetCode 的测试数据里存在接近 int 边界的大数,四个数相加的中间值一旦超过 int 范围,就会溢出成负数或奇怪的值,结果自然是错的。
有人可能会说,nums[i] + nums[j] 作为 int 时看起来没超,但 nums[i] + nums[j] + nums[left] + nums[right] 可能超。所以在 sum 计算处,我习惯先把第一个数转成 long long,这样后面相加都会自动提升为 long long。这段代码不能省,以前我看到很多人在讨论区问“为什么我思路完全正确,提交却 WA”,一看代码,全是 int 相加溢出。
5. 刷题中的高频问题与排查方法整理
5.1 常见 Bug 速查表
这几道题我前前后后刷过很多遍,总结了一些特别高频的错误,做成一张表,方便复习时直接对号入座。
| 题目 | 典型错误 | 根本原因 | 对应解法 |
|---|---|---|---|
| 454 四数相加 II | 边遍历边统计 AB 结果,后两个数组查询时漏解 | AB 哈希表还没构建完整 | 先完整统计 AB,再遍历 CD 查询 |
| 454 四数相加 II | 结果变量用 int 但题目数据范围更大 | 极端组合数可能超过 int | 用 long long 计数;或确认数据范围后再用 int |
| 383 赎金信 | 先减 ransomNote 再统计 magazine | 弄反了资源方和消耗方 | 先统计 magazine,再遍历 ransomNote 扣减 |
| 15 三数之和 | 外层去重写成 nums[i] == nums[i+1] |
误把下一个相同值当成重复处理 | 改为 nums[i] == nums[i-1],跳过已处理过的重复起点 |
| 15 三数之和 | 找到答案后没有 left++ 和 right-- | 指针不动,死循环 | 必须跳过重复值后同时移动左右指针 |
| 18 四数之和 | 第二层循环去重写成 j > 0 |
没有考虑 i 固定后 j 可以等于 nums[i] | 改为 j > i + 1 |
| 18 四数之和 | target 为负数时剪枝条件过激进 | 只判断 nums[i] > target 就 break |
同时判断 nums[i] >= 0 |
| 18 四数之和 | int 溢出 | 多个 int 相加超过范围 | 相加时转 long long |
5.2 我排查问题的实操顺序
如果写完代码提交报错,不要直接去翻题解,先自己在本地做三件事。
第一,构造一个重复值特别多的数组。比如三数之和用 [0,0,0,0],四数之和用 [-2,-1,-1,-1,0,1,2]。重复值多的用例最容易暴露去重逻辑问题。如果输出结果中出现重复三元组,或者缺失某个组合,基本都是去重条件写错。
第二,构造所有数都相同的极端数组。三数之和里全 0 数组验证会不会死循环。如果代码在 sum == 0 后少了 left++、right--,这个用例会直接卡死;如果去重 while 写错,则可能输出空数组或超时。
第三,用暴力解法作为参照对比。写一个 O(n^4) 的暴力版本,再用随机生成的数组去比较结果集合是否完全相同。注意比较前要把两个版本的结果都排序,因为集合内部的元素顺序可能不同。暴力解法虽然慢,但正确性容易保证,一旦发现差异,可以打印出当前 i、j、left、right 的值,定位问题会快很多。
5.3 从四道题延伸出来的通用思考路径
今天的题目虽然多,但刷完之后,可以整理出一条处理“子数组求和”类问题的通用路径。
如果题目要求的是“有几个组合满足条件”,并且输入是多个独立数组或集合,优先用哈希表做分组计数,比如 454 题就是把四组拆成两组两组;如果题目要求“枚举所有具体组合”,并且输入是一个数组里选下标不同的元素,那大概率要先排序再用双指针,比如三数之和、四数之和。
去重逻辑里有一个可以复用的原则:重复的元素只让最左侧的那个参与结果生成。外层循环中,判断当前元素是否和前一个元素相同,如果相同就 continue;双指针找到一组结果后,持续跳过与当前 left、right 相同的值,再各收缩一步。几乎所有双指针求组合的题都适用这个原则,包括之后的四数之和变种、最接近的三数之和等。
如果将来遇到五数之和、六数之和,固定的层数变多了,靠多层 for 循环会越来越不现实。更通用的做法是把 nSum 写成递归模板,先固定一个数,再对剩余数组调用 (n-1)Sum,并在函数入口处统一处理剪枝和去重。三数之和和四数之和是理解这个模板最好的两只“小白鼠”。
6. 关于第六天训练,我的一些个人体会
这几个题刷完,我对“为什么代码随想录要把它们放在同一天”有了更实际的理解。前两题逼你熟悉哈希表的计数能力,后两题逼你脱离“遇到查找就无脑哈希表”的惯性思维。你只有先把哈希表用到顺手,才更容易理解为什么三数之和里哈希表不是最优解。
我个人的小经验是:四道题不要分开孤立地复习,而要把 454 和 18 放在一起对比,把 383 和 15 放在一起对比。对比的重点不是记代码,而是搞清楚数据形式的差别。454 是几个独立的数组,不需要考虑相同下标;18 是同一个数组里选多个下标,天然要处理重复值。明白了这一点,很多错解就有了提前预防的可能。
另一个很实在的建议是,刷这种经典题,一定要做错题记录。不要只是 AC 就划走,至少把第一次写错的地方标出来。我整理这篇文章时回看自己的记录,发现 15 题的外层去重条件、18 题的 j 层去重条件、四数之和的 int 溢出,这三类问题我每次重刷都会遇到。它们才是这组题目真正的难度所在。如果你能把这些坑提前避开,这天的训练就算没白刷。
