哈希表与双指针专题复盘:LeetCode454/383/15/18全解析
2026/9/10 6:34:23 网站建设 项目流程

刷算法题最爽的一刻,就是把一个看着很唬人的题目,拆成几个熟悉的小套路。今天是代码随想录算法训练营的第七天,安排的四道题LeetCode454四数相加II、383赎金信、15三数之和、18四数之和,恰好是哈希表和双指针两个专题的“综合检阅”。如果你正在跟着训练营打卡,或者准备面试想集中突破一下这类题型,这篇复盘把我的完整思考过程、代码版本和踩过的坑都整理出来了,可以直接当参考。

这四道题放在一天是很有讲究的:454和383是哈希表的经典应用,15和18则是排序+双指针的重头戏。很多人在15题的三数之和去重上卡很久,我也一样。这篇文章会把去重逻辑掰开揉碎讲清楚,也会把四数之和里容易被忽略的剪枝和溢出问题说明白,结尾再整理一份高频错误速查表,希望能帮你少走点弯路。

1. 训练营第七天:四道题到底在练什么

1.1 两两分组:哈希表与双指针各占一半

一天四道题看起来很密集,但如果你把它们按解法分组,思路马上就清楚了:454四数相加II和383赎金信属于哈希表专题;15三数之和和18四数之和属于排序+双指针专题。

题号题目核心解法时间复杂度空间复杂度
454四数相加II分组 + 哈希表O(n^2)O(n^2)
383赎金信数组模拟哈希O(n + m)O(1)
15三数之和排序 + 双指针O(n^2)O(1)(不计结果集)
18四数之和排序 + 双指针 + 剪枝O(n^3)O(1)(不计结果集)

注意一个很有意思的点:454题名是“四数相加”,18题名是“四数之和”,听起来很像,但解法思路完全是两个方向。454不要求去重、只要求计数,可以大胆用哈希表;18要求返回不重复的四元组,就必须排序后配合双指针。这个差异是今天很重要的一个认知点。

1.2 四道题放在一起学,重点看这层递进关系

从难度上观察,383最简单,适合热身;454考察对哈希表分组的理解,是哈希表用法的重中之重;15的三数之和是双指针的经典题目,面试出现频率极高;18则是在15的基础上套了一层循环,是双指针技巧的延伸应用。

这四道题也暗合了代码随想录训练营前几天的学习路径:先用哈希表解决“是否存在”“出现几次”这类问题,再去处理“不能重复”这类需要去重的问题。训练营把这几道题放在同一天,目的就是让你对比两种思路的适用场景:什么时候用哈希表更高效,什么时候必须排序+双指针。理解了这条分界线,后面遇到同类题就不容易选错方案。

2. LeetCode454 四数相加II:两两分组后,哈希表才是主角

2.1 题面拆解:为什么这道题不需要去重

题目给四个长度相同的整数数组 nums1、nums2、nums3、nums4,要你统计有多少个四元组 (i, j, k, l) 满足:nums1[i] + nums2[j] + nums3[k] + nums4[l] == 0。

注意这里说的是下标组合,而不是元素值组合。也就是说,即使两个数组里的数字值相同,只要下标不同,就算不同的答案。这就解释了为什么这道题不需要去重,只需要计数。

还有一个隐含条件:四个数组长度相同,都是 n。暴力解法就是四层循环枚举所有下标组合,时间复杂度 O(n^4)。如果 n 为 200,200 的四次方是 16 亿,直接超时。所以问题的核心就是:如何避免 O(n^4)。

2.2 从O(n^4)到O(n^2):分组思路是怎么来的

这个思路其实很朴素:把四个数分成两组。先算出 nums1 和 nums2 的所有两两之和,用一个哈希表记录每个和值出现的次数;再遍历 nums3 和 nums4 的所有两两之和,如果哈希表里存在 0 - (c + d),那就说明找到了匹配的组合,把对应的次数累加到结果里。

为什么这样可行?因为等式可以改写为 nums1[i] + nums2[j] = - (nums3[k] + nums4[l])。左边和右边都可以提前计算,哈希表负责快速查找。

我常用一个生活化类比来理解:四个班要凑两队搞联谊,先统计 A 班和 B 班各自在哪个时间段有空,把“空闲交集”记录在表里;再统计 C 班和 D 班的时间,如果某个时间点在表里出现过,就说明四个班这个时间都能凑上,直接累加计数。

这个方案的巧妙之处在于空间换时间的思路非常直接,两两组合的数量是 n^2,哈希表的查找平均 O(1),所以总复杂度就是 O(n^2)。对很多算法题来说,把四层循环拆成两个两层循环,是最朴素的降维手段。

2.3 代码实现与三个容易忽略的细节

class Solution { public: int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) { unordered_map<int, int> umap; for (int a : nums1) { for (int b : nums2) { umap[a + b]++; } } int count = 0; for (int c : nums3) { for (int d : nums4) { int target = 0 - (c + d); auto it = umap.find(target); if (it != umap.end()) { count += it->second; } } } return count; } };

这里有几个细节容易出问题。第一,遍历 nums1 和 nums2 时,umap[a + b]++这一句如果写成umap[a + b]之后忘记累加,统计频率就会错误。第二,第二次循环里一定是用find去查,直接用umap[target]会在 target 不存在时插入一个 0,这样会污染哈希表,如果第二次循环和第一次循环用的是同一个表,会造成后续误判。第三,count 要累加的是it->second也就是频率值,而不是简单地加 1。

我一开始自己写的时候,最后一步写的if (umap.find(target) != umap.end()) count++;,结果答案一直偏小,后来才意识到,同一个 target 可能对应多个 A+B 的组合,应该累加频率而不是加一。

3. LeetCode383 赎金信:用数组模拟哈希,20分钟拿下

3.1 题意翻译:这道题和242题就差一句话

赎金信的题面背景稍微有点绕,but核心判断很简单:给定两个字符串 ransomNote 和 magazine,判断 ransomNote 能不能由 magazine 里面的字符拼出来,magazine 中每个字符只能用一次。

这和训练营前面做过的242有效字母异位词非常像。242那道题要求两个字符串的字符种类和数量完全一致;383这道题则放宽了条件,只需要 magazine 的字符能覆盖 ransomNote 即可,magazine 里可以有多余的字符。所以383本质上是242的“覆盖版本”。

题面还特意提醒,两个字符串都只包含小写字母。这个限制条件非常关键,它意味着我们不需要用 unordered_map,直接用固定大小的数组就能解决问题。

3.2 用数组还是unordered_map:性能实测感受

对于小写字母场景,我用 int[26] 和 unordered_map 都实现过,实际效果差别很明显。unordered_map 虽然写起来更通用,但是每次插入、查找都需要计算哈希值,遇到字符串较长的时候还会有扩容、内存分配的额外开销。

数组模拟哈希则简单直接:下标 0 到 25 对应 a 到 z,值就是该字符的出现次数。26 的长度是常量,空间上几乎可以忽略不计,时间上就是一次数组访问,效率极高。

怎么选择?如果在面试里遇到“只包含小写字母”这种明确限制,优先用数组。如果字符集不确定或者很大,再用 unordered_map。这个选择也是面试官考察代码细节的一部分,能讲清楚背后的缘由,会显得你对基础知识点更扎实。

3.3 完整代码与复杂度说明

class Solution { public: bool canConstruct(string ransomNote, string magazine) { if (magazine.size() < ransomNote.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; } };

一个被很多人忽略的优化:可以先判断杂志长度是否小于赎金信长度,如果小于,直接返回 false。这行代码虽然不影响正确性,但在极端情况下能省不少时间。

遍历顺序建议是先统计 magazine,再遍历 ransomNote 判断够不够。如果反过来,先统计 ransomNote,再遍历 magazine 去扣减,逻辑会绕一些,也容易出现负数判定的困惑。按“库存够不够供货”的思路来写,最直观。

时间复杂度 O(n + m),空间复杂度 O(1)。因为数组长度固定为 26,不随输入规模变化。

4. LeetCode15 三数之和:双指针经典,去重是全场重点

4.1 为什么推荐排序+双指针,而不是哈希

题目要求:给定整数数组 nums,返回所有和为 0 且不重复的三元组。注意“不重复”这三个字是本题的灵魂。

最朴素的做法是三层循环,但 O(n^3) 基本不可行。有人会想:可不可以像454那样用哈希表?确实可以做,但你会很快发现去重非常痛苦。因为题目要求返回的是不重复的三元组,也就是说顺序不同的相同三元组只能算一个。用哈希表存二元组,再用 set 去重,虽然也能过,但代码里要处理的细节比双指针版本多得多,面试时也容易被追问得漏洞百出。

这就是这道题为什么推荐排序+双指针方案。排序以后,相同的数字会挤在一起,去重变得非常自然;同时通过双指针收缩区间,可以一次性跳过大量无效组合。

4.2 双指针移动逻辑与三处去重的正确姿势

具体流程是:先对数组排序,然后固定第一个数 i,用 left 指向 i+1,right 指向数组末尾,计算三者之和。如果和大于0,说明数值偏大,right 左移;如果和小于0,说明数值偏小,left 右移;等于0 就记录答案。

这里最关键的是去重,而且去重有三处:第一处是外层循环对 i 去重,第二处是找到答案后对 left 去重,第三处是对 right 去重。很多人在这里写错。

先说 i 的去重。判断条件应该写成if (i > 0 && nums[i] == nums[i-1]) continue;,而不是if (nums[i] == nums[i+1]) continue;,这两者差别非常大。

我举个例子:数组是 [-1, -1, 2],正确答案是 [-1, -1, 2]。如果写成nums[i] == nums[i+1]去重,i=0 时发现 nums[0] == nums[1],会直接把整个组合跳过去,正确答案就丢了。而写成nums[i] == nums[i-1],i=0 时没有前一个元素,继续处理;i=1 时发现 nums[1] == nums[0],才跳过;这样保留的是每个相同数字中的最后一个位置作为固定点。简单说,nums[i] == nums[i-1]是确保当前固定值第一次出现时才处理,nums[i] == nums[i+1]则是错误地跳过了需要处理的组合。

再说找到答案后的 left 和 right 去重。网上代码常见的写法是:

while (left < right && nums[left] == nums[left + 1]) left++; while (left < right && nums[right] == nums[right - 1]) right--; left++; right--;

这里的顺序也很有讲究,要先移动指针跳过重复值,再对 left 和 right 做常规的收拢。因为找到一组答案后,如果 left 右边和 right 左边有和当前值相同的元素,直接跳过可以避免产生重复三元组,而且不会漏掉其他组合。

有人会问:为什么不能在 while 循环开头就做 left 和 right 的去重?因为如果还没有找到答案,就贸然跳过相同值,可能错过正确的组合。比如数组 [-2, 0, 0, 2, 2],固定 -2 后,left 指向 0,right 指向 2,此时正好和为 0。但如果开头就跳过重复的 0 和 2,可能直接错过这个结果。正确做法是:先判断是否等于 target,等于了再统一去重,然后再移动。

4.3 边界判断和完整实现

外层循环里还有两个常规优化:如果 nums[i] > 0 直接 break。因为数组已经排过序,第一个数大于0,后面任意两个数也一定大于等于零,和不可能为0了。另一个常用陷阱是 i < nums.size() 但不要越界,left 和 right 每次更新后要重新检查 left < right。

完整代码如下:

class Solution { public: 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; } };

去重是这道题真正想考察的能力,如果能在面试中把三处去重为什么这么写、为什么不能那么写讲清楚,这道题基本就过关了。

5. LeetCode18 四数之和:三数之和的套娃升级版

5.1 外层套一层循环,但剪枝条件完全不同

四数之和的求解思路是在三数之和的基础上再套一层循环:固定 i 和 j,然后对剩余区间用 left 和 right 双指针。整体框架几乎一模一样,唯一让人翻车的地方在剪枝。

很多人在三数之和里学到了“nums[i] > 0 就 break”,到了四数之和想当然写成nums[i] > target就 break,这里有个大坑:如果 target 是负数,nums[i] > target根本不能说明后面没有合适的组合。

我举个例子:nums = [-4, -1, 0, 0],target = -5。排序后 nums[0] = -4,而 -4 比 -5 大,如果直接按这个条件 break,就会漏掉正确答案 [-4, -1, 0, 0]。因为 target 是负数时,第一个数稍微大一点,后面还可以用更小的负数把总和拉回 target。

代码随想录推荐的安全写法是:if (nums[i] > target && nums[i] >= 0) break;,也就是只有当前数字已经大于 target,且当前数字本身非负时,才说明后续组合不可能更小了。同理,第二层循环的剪枝也写成if (nums[i] + nums[j] > target && nums[i] + nums[j] >= 0) break;

5.2 两个关键难点:负数target与int溢出

四数之和的第二个难点是溢出。nums[i] + nums[j] + nums[left] + nums[right] 四个 int 相加,在极端情况下可能超过 int 范围。比如题目给了很大的测试数据,四个数都接近 2^31,加起来的和直接溢出变成负数,就会导致比较逻辑完全错误。

解决办法很简单,计算总和时用 long 类型,代码里写成:

long sum = (long)nums[i] + nums[j] + nums[left] + nums[right];

先强转一个数成 long,后续相加就是 long 运算了。不能只写long sum = nums[i] + nums[j] + nums[left] + nums[right];,因为等号右边是 int 先相加溢出后,再把结果赋给 long,已经来不及了。这是我实际踩坑时发现的,一定要先把其中一个数强转。

另外,四数之和还多了两层去重逻辑:i 的去重是if (i > 0 && nums[i] == nums[i - 1]) continue;,j 的去重是if (j > i + 1 && nums[j] == nums[j - 1]) continue;。注意 j 的起始位置比 i 大 1,所以判断要去掉 j == i + 1 的情况,否则会把第一次出现的 j 值误跳过。

5.3 完整代码与剪枝优化

先给出基础版本代码:

class Solution { public: 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++) { if (nums[i] + nums[j] > target && nums[i] + nums[j] >= 0) break; if (j > i + 1 && nums[j] == nums[j - 1]) continue; int left = j + 1; int right = n - 1; while (left < right) { long sum = (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; } };

如果想在性能上进一步优化,还可以在循环开头加两组更激进的剪枝:如果当前 i 与最小的三个后续数之和已经大于 target,直接 break;如果当前 i 与最大的三个后续数之和小于 target,直接 continue。代码如下:

if ((long)nums[i] + nums[i + 1] + nums[i + 2] + nums[i + 3] > target) break; if ((long)nums[i] + nums[n - 3] + nums[n - 2] + nums[n - 1] < target) continue;

这两行属于锦上添花,面试时提出来会显得你对极限情况考虑得更周到。不过写的时候要小心数组越界,确保 i + 3 和 n - 3 都在合法范围内。

6. 实操心得:我刷完四题的复盘与避坑记录

6.1 高频错误Top5速查表

这四道题刷下来,我总结了一份高频错误速查表,都是自己在做题或调试时真实遇到过的,按出现频率从高到低排列:

错误类型涉及题目典型表现正确做法
454结果少算454count 每次都只加1累加 it->second 频率值
454误用下标访问454用 umap[target] 判断存在,插入脏数据用 find 判断后累加
383未判断长度383magazine 比 ransomNote 短时多跑循环先判长度,直接返回 false
15去重位置错误15在 while 开头对 left/right 去重,错过答案在找到一组答案后再去重
18溢出18四个 int 相加结果溢出,判断出错计算时先强转一个数为 long
18负数剪枝失误18nums[i] > target 就 break,漏掉正确答案改成 nums[i] > target && nums[i] >= 0

6.2 我平时排查这类题的调试方法

如果你卡在某个用例上,我的经验是别急着看题解,先做三件事。

第一,最小化测试。把数组缩小到三四个元素,手动跑一遍逻辑,看是哪一步判断出了问题。比如三数之和我经常用 nums = [-1, 0, 1, 0] 这种带重复值的简单用例,能很快暴露去重位置写错的问题。

第二,打印关键指针值。在 while 循环里打印 i、left、right 和当前 sum,会非常直观。尤其是三数之和这种双指针题,指针移动的顺序错了,通过打印一眼就能看出来。

第三,拿全0数组和负数target做极端测试。全0数组能检验去重逻辑,比如 nums = [0, 0, 0, 0] 应该只返回一个三元组 [0, 0, 0];四数之和里 target 为负数的情况也能验证剪枝是否过度。

6.3 这四道题怎么刷效率最高

如果按训练营的节奏走,我会建议先把383这类简单题快速过掉,建立信心;然后集中精力啃454和15这两道代表题。454代表“哈希表分组合并”的套路,15代表“排序+双指针+去重”的套路;把这两个套路吃透,18就是在15的基础上修改几行剪枝条件。四道题分配到一天里,时间上大概就是:383约20分钟,454约30分钟,15约40分钟,18约40分钟,剩下的时间用来复盘和整理笔记。

不要追求每道题一次AC。我自己刷15题的时候,去重逻辑来回改了三版才完全跑通,但那之后遇到三数之和、四数之和,以及后面更复杂的双指针题目,都能比较快地套上模板。把错误记录下来,比多刷两道新题更有价值。

最后分享一个我个人的整理习惯:把今天四道题的模板代码放在一个文件里,用注释标注每处去重和剪枝的理由。比如15题的“nums[i] == nums[i-1]”和“nums[i] == nums[i+1]”的区别,18题的负数 target 剪枝条件,454的频率累加逻辑。面试前翻一遍这个文件,比重新刷十道题都管用。希望这篇复盘对你的训练营打卡也有帮助。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询