1. 这两道题为什么值得专门做一篇笔记
先说个有意思的现象:很多刷 LeetCode 的人,第一周斗志最旺盛,打开题库挑最简单的题开刷,结果挑中的往往是“两数之和”。然后刷完这一题就兴冲冲地跑到社区发帖:“AC 了,打卡。”过了几天刷到“三数之和”,直接卡住,心态崩了,帖子变成:“为什么两数之和能做,三数之和就不会了?”
这个场景出现得太频繁了,以至于我每次看到都觉得,两数之和和三数之和放在一起讲,其实是算法入门里最被低估的一课。它俩看起来是两道题,本质上是同一个“找目标组合”问题族,但解法思路的转折点,恰好卡在从“哈希表的时代”跳到“排序加双指针的时代”这个分水岭上。刷题的人如果只是背答案,很容易在这里瞎掉。
说真的,基础算法精讲系列我最早想动的题目不是这两道,而是二分查找。后来重新备课的时候改了主意。因为二分查找再基础,它考验的还是“在一个单调序列里定位某个值”这个单一能力;而两数之和、三数之和这一组题,考验的是对暴力枚举的优化思维、哈希表的空间换时间、排序预处理带来的结构性收益、双指针的移动规则、去重的边界条件。一题串起来的核心概念太多了,非常适合当整个系列的第一篇。
在写这篇笔记之前,先明确一下刷题的目标人群。这篇笔记不是写给那种已经能默写二叉树遍历、前缀和信手拈来的选手看的,而是写给以下这些人的:
- 刚刚决定开始刷题,但翻开两数之和题解区,满屏都是“哈希表一次遍历”却看不懂为什么要一次遍历的初学者;
- 已经背过两数之和代码,但换个问法(比如返回所有不重复组合)就懵掉的半桶水;
- 准备面试前系统过一遍双指针题型的同学,需要一个能把两数之和和三数之和完整串起来的脉络。
这篇文章的定位是“笔记”,不是“题解搬运”。我会把每一步为什么这样做讲透,代码用 Python3 写,并且会把我在实测中踩过的坑、以及讲解时学生最容易问的问题一并放进来。整篇下来大约能覆盖这一族题里 80% 的思维底层逻辑。
2. 为什么我把两数之和放在基础算法精讲第一题:一道题的思维范式转换
2.1 暴力解法不是“解法”,是一个需要看穿的诅咒
先把这个题目摆出来。题目本身简洁到不像一道算法题:给定一个整数数组nums和一个整数目标值target,请你在该数组中找出和为目标值的那两个整数,并返回它们的数组下标。
看到这道题,条件反射式的做法是双循环:
def two_sum_brutal(nums, target): n = len(nums) for i in range(n): for j in range(i + 1, n): if nums[i] + nums[j] == target: return [i, j]这段代码没有任何语法问题,测试用例小的时候也能过,时间复杂度是 O(n²),空间复杂度是 O(1)。如果这是大学数据结构课的实验题,交上去拿个及格没问题。但这是算法刷题,它考的不是你会不会写循环嵌套,而是你能不能意识到一件事:内层循环做的“查找”工作,是一次可以被彻底消除的重复劳动。
为什么说是“重复劳动”?仔细盯住内层循环,它在做什么?它从i+1到n-1,一个一个看nums[j]是不是等于target - nums[i]。也就是说,对于每一个i,你都在做一次线性扫描。如果这个数组有 10 万个数,外层 10 万次,每次扫描平均 5 万次,算下来是 50 亿次比较。而其中绝大多数比较,当你扫到某个值的时候,你当下只关心一个问题:这个值我见过没有?你根本不关心它在数组的什么位置出现过——不,准确地说,你关心它的位置,但你关心的是“能不能快速知道它的位置”。
暴力解法最痛的点就在这里:它把“值”和“位置”绑定得太死了。你想找的是值,最后要返回的是位置,但你的扫描方式,让“检查一个值是否出现过”这个操作,变得跟数组长度成正比。数组越长,越拖沓。
这个思维上的转变,是这道题真正的考点:你要学会把“查找某个元素是否存在”的代价从 O(n) 降低到 O(1)。而做到这一点的工具,就是哈希表。
2.2 哈希表的引入:空间换时间,是有代价的
Python 里的dict就是哈希表的核心实现。它给你提供的核心能力是:给定一个key,平均 O(1) 时间返回对应的value。注意是平均 O(1),极端情况下(哈希冲突非常严重)可能退化,但竞赛和面试环境里,Python 的dict几乎是稳定的 O(1) 查找。
回到题目,这次我们不搞双循环,改成一个循环:
def two_sum_hash(nums, target): seen = {} for i, num in enumerate(nums): need = target - num if need in seen: return [seen[need], i] seen[num] = i我来讲讲这段代码的执行逻辑,因为初学者经常在“先查字典还是先存字典”这个问题上绕晕。
用示例来演示,假设nums = [2, 7, 11, 15],target = 9。
- 第一轮,
i = 0, num = 2。算need = 7,去seen里找有没有 7,没有。于是把2 -> 0存入seen。 - 第二轮,
i = 1, num = 7。算need = 2,去seen里找,发现有!它的下标是 0。于是返回[0, 1]。
注意,这段代码里,我每次都是先查再加。为什么要先查?因为要找的是“两个数和为 target”,如果我先把自己加进去了,当num等于target / 2的时候,就会查到自己。比如nums = [3, 3], target = 6这种用例,如果你先存后查,第一轮你就可能返回[0, 0],这显然是错的。先查后存,保证每次查到的都是“自己之前已经遍历过的元素”,逻辑上绝对安全。
哈希表解法的时间复杂度是 O(n),空间复杂度是 O(n)。这就是典型的空间换时间:我把之前看过的元素全部记在一个“本子”上,以后每个新元素只需要翻一下本子就知道能不能配对。
这段的原理讲完,有人会问:这不是很简单的道理吗?为什么这么多人卡在这里?
因为很多人在写暴力解法的时候,脑中的循环结构是“我找你和别人”,而哈希表解法要求你把循环结构改成“我看一个,记一个,再问一个”。这个“状态”的维护,是初学者的第一道坎,也是后面所有高效算法的共同雏形——做一件事的时候,沿途把信息记录下来,后面再用。滑动窗口的窗口状态、前序遍历的路径记录、并查集的集合合并,全都是这个思路。
3. 两数之和的Python3实现细节:性能、边界和面试高频变形
3.1 三种写法的对比和取舍
两数之和这道题最坑的是:它在 LeetCode 上有个硬性要求,不能使用两次循环的暴力解法来糊弄,且题目要求返回下标。基于这个要求,可以用的写法其实有好几套,我在这里把它们都列出来,方便对照选择:
| 写法 | 时间复杂度 | 空间复杂度 | 适用场景 | 返回值 |
|---|---|---|---|---|
| 暴力双循环 | O(n²) | O(1) | 数据量极小 | 下标 |
| 两遍哈希表 | O(n) | O(n) | 需要先构建完整映射 | 下标 |
| 一遍哈希表 | O(n) | O(n) | 常规最优解 | 下标 |
| 排序+双指针 | O(n log n) | O(1)(不算排序空间) | 需要返回组合值时常用 | 值 |
两遍哈希表的思路是先把所有元素的下标存进字典,然后第二遍遍历时查。它比一遍哈希表多一次完整遍历,逻辑上更直白,但缺点是如果数组里有重复元素,第二次查的时候需要小心取到的下标是不是自己。
比如nums = [3, 3], target = 6,第一遍构建字典的时候,3 这个 key 会被后面的 3 覆盖,最后字典里存的是3 -> 1。第二遍遍历到第一个 3 的时候,查need = 3,得到下标 1,返回[0, 1],凑巧也是对的。但如果你遍历到第二个 3,查到的下标还是 1,返回[1, 1]就错了。两遍哈希表必须加一个判断:查到的下标不能等于当前下标。一遍哈希表就没有这个烦恼,因为它天然规避了“查到自己的问题”。
面试里我自己更推荐写一遍哈希表版本:代码短,逻辑闭环,且能展示你对状态更新的理解。如果你在面试白板上写两遍哈希表,面试官多半会追问一句“能不能只遍历一次”——与其被追问,不如直接主动上最优解。
3.2 返回值变形:LeetCode 167 和“返回所有组合”的区别
有一种很常见的面试追问:如果题目改成“返回两个数的值本身,而不是下标”,你要怎么改?
其实逻辑完全不用动,只是return [seen[need], i]改成return [need, num]就行。但如果这道题改成“请你返回所有和为 target 的不重复二元组”,难度就上来了,因为去重逻辑出现了。
我直接给出代码:
def two_sum_all_combo(nums, target): nums.sort() res = [] left, right = 0, len(nums) - 1 while left < right: cur = nums[left] + nums[right] if cur == target: res.append([nums[left], nums[right]]) while left < right and nums[left] == nums[left + 1]: left += 1 while left < right and nums[right] == nums[right - 1]: right -= 1 left += 1 right -= 1 elif cur < target: left += 1 else: right -= 1 return res注意这个代码里,一旦找到一组目标组合,我先去重,再同时移动两个指针。为什么不能只动一个?因为如果你找到一组后只动 left,那 nums[right] 不变,新的 nums[left] 和 nums[right] 的和一定大于 target(因为数组是有序的,nums[left] 变大了),这个思路在实际调试中会很容易造成指针越界或者死循环。所以匹配成功时,两个指针必须同时移动。
这道变体题其实就是三数之和的前置,我先放在这里,就是为了让你感受到排序之后,双指针操作起来有多顺,也为下一节的主要内容做铺垫。
3.3 关于 Python3 字典的一个实测小细节
每次讲这道题,我都会收到至少一个学生问:为什么用if need in seen判断,而不是直接用seen.get(need)?
这两种写法在功能上差不多,但in操作对于 Python 的dict来说是直接操作哈希索引,不涉及函数调用开销;而.get是一个方法调用,即使内建方法很快,在 LeetCode 的千万级测试数据下,差异也会被放大一点点。作为刷题习惯,我建议能写in就写in,保持判断的语义最清晰。另外,seen这个名字也比map、hashtable更直观——它就是“已经看过的元素”。
4. 三数之和的排序双指针:同族不同法的关键转折
4.1 为什么不能直接套用两数之和的哈希套路
三数之和的正题是这样的:给定一个整数数组nums,判断是否存在三元组[nums[i], nums[j], nums[k]],满足i、j、k互不相同,且nums[i] + nums[j] + nums[k] = 0。注意,此题明确要求返回所有不重复的三元组,而不是下标集合。也就是说,结果里不能出现重复的三元组,比如[-1, 0, 1]和[1, 0, -1]算是同一种组合。
新手拿到这题的第一反应就是套上一题的思路:遍历一个数,剩下的两数之和问题交给哈希表。这个思路本身没错,但会撞上两个很致命的坑。
第一个坑:哈希表天然不在乎顺序。两数之和之所以能用哈希,是因为返回下标时,顺序是无所谓的,[i, j]和[j, i]在题目语义上是同一个答案;但三数之和要求输出组合值,哈希表去重的代价会非常高。你得把每个满足条件的三元组都找出来,然后排序,再塞进一个set里,最后还要防止排序后的元素顺序不同导致重复计算,整个流程充满了不必要的复杂度。
第二个坑:找三元组的数量是组合级的。两数之和只需要找到一个答案就可以返回,而三数之和要把所有答案全找出来。哈希表在这件事上能办到,但去重逻辑会写得让你怀疑人生。我来给你展示一下“纯哈希去重流”的问题,假设数组是[-1, -1, 0, 1],你用哈希表找两数之和,-1和1会组队,但你无法在遍历时快速判断“当前这个-1和之前那个-1属于同一位置”,或者更准确地说,你判断的代价太大。
所以这个问题的正确打开方式,是换一个完全不同的思路:先排序,再用双指针。
4.2 排序带来的结构性收益:双指针为什么能减少循环层级
排序这个预处理操作,在暴力解法里看起来是“多此一举”,但在区间搜索问题里,它是划时代的优化手段。为什么?因为一旦数组有序,你就可以利用单调性来快速判断指针该怎么走。
我先说结论框架:
def three_sum(nums): nums.sort() n = len(nums) res = [] for i in range(n - 2): if i > 0 and nums[i] == nums[i - 1]: continue if nums[i] > 0: break left, right = i + 1, n - 1 while left < right: total = nums[i] + nums[left] + nums[right] if total < 0: left += 1 elif total > 0: right -= 1 else: res.append([nums[i], nums[left], nums[right]]) while left < right and nums[left] == nums[left + 1]: left += 1 while left < right and nums[right] == nums[right - 1]: right -= 1 left += 1 right -= 1 return res在这个代码里,外层遍历是固定第一个数nums[i],内层用双指针在i+1到n-1的区间里找两个数,让三者之和为零。我们来过一遍核心逻辑。
整个数组已经从小到大排好序了。考虑某个固定的i,此时left指向区间最左端,right指向区间最右端。计算nums[i] + nums[left] + nums[right],然后看总和:
- 总和小于 0:说明当前三个数的和太小了,需要增大。左边指针往右挪一位,也就是
left += 1。 - 总和大于 0:说明太大了,需要减小。右边指针往左挪一位,也就是
right -= 1。 - 总和等于 0:找到一个答案,加入结果集,然后去重、收缩区间。
这个思路的巧妙之处在于双指针的移动是有方向性的。排序后,nums[left]和nums[right]天然夹出一个区间,你只需要不断向中间逼近,这个过程里每个 left-right 组合最多被访问一次,因此内层双指针的复杂度是 O(n),外层循环还有 O(n),总体是 O(n²)。对比暴力三重循环的 O(n³),这是质的飞跃。
4.3 三个去重细节,少了任何一个都会让你被重复答案搞疯
三数之和最磨人的不是能不能想到双指针,而是去重。面试里很多同学写完代码之后,跑示例通过了,提交却 WA(Wrong Answer),原因几乎全在去重上。
第一个去重位置,是外层循环的i。如果nums[i] == nums[i - 1],直接跳过。为什么?因为如果当前数和前一个数相同,那么固定i能产生的组合集合,和固定i - 1时完全一样。你枚举了第一次就够了,第二次纯粹是重复劳动。注意这里要用nums[i] == nums[i - 1]判断而不是nums[i] == nums[i + 1],为什么?如果是后者,你可能会在一开始的连续重复区间里错过唯一合法的一组合法使用,比如数组[-1, -1, 2],你直接用nums[i] == nums[i + 1]判断,第一个-1时发现nums[0] == nums[1]就跳过了,但[-1, -1, 2]是一个合法答案。
第二个去重位置,是匹配成功之后的left和right去重。找到一个三元组后,先把所有和当前nums[left]相等的指针统统往右挪,再把所有和当前nums[right]相等的指针统统往左挪,然后 left 和 right 再各走一步。这一步是为了保证下一次找目标时,两个指针的两个值都不会和上一组重复。
第三个细节,是对首元素nums[i]的判断。这里有个很实用的小剪枝:如果nums[i] > 0,直接终止循环。因为数组已经有序,第一个数都已经是正数了,后面两个数更大,三个正数不可能加出 0。这段代码在实际刷题中能砍掉不少无谓的遍历,尤其是数组里大正数很多的情况。
三个去重捂住一个,结果里一定出现重复三元组。我带着学生刷题时,遇到过不下五次这种情况,每次都老老实实断点看一眼,才发现在去重这里漏了。
5. 从两数到三数再往上走:复杂度进化与一整个题族
5.1 一个朴素规律:N数之和的复杂度阶梯
把两数之和和三数之和并排放在一起看,一个规律逐渐浮现出来:
- 两数之和 + 哈希表:O(n) 时间,O(n) 空间。
- 三数之和 + 排序双指针:O(n²) 时间,O(n) 空间(排序空间视语言实现)。
- 四数之和 + 排序双指针(两层固定):O(n³) 时间,O(n) 空间。
这个规律说明什么?说明“从 N 个数里找 K 个数,使它们的和等于 target”这一类问题,当 K 固定时,最优解的时间复杂度大致是 O(n 的 K-1 次方)。为什么不是 O(n 的 K 次方)?因为你枚举前 K-1 个数,最后一个数可以通过双指针或者哈希表在 O(n) 内解决,而不是再套一层循环。
带这个规律去刷题,四数之和就变得很简单了:在三数之和的外面再套一层循环。
def four_sum(nums, target): nums.sort() n = len(nums) res = [] for i in range(n - 3): if i > 0 and nums[i] == nums[i - 1]: continue for j in range(i + 1, n - 2): if j > i + 1 and nums[j] == nums[j - 1]: continue left, right = j + 1, n - 1 while left < right: total = nums[i] + nums[j] + nums[left] + nums[right] if total < target: left += 1 elif total > target: right -= 1 else: res.append([nums[i], nums[j], nums[left], nums[right]]) while left < right and nums[left] == nums[left + 1]: left += 1 while left < right and nums[right] == nums[right - 1]: right -= 1 left += 1 right -= 1 return res你仔细看这代码,和三数之和的区别,仅仅就是多了一层固定 j 的循环,以及外层去重的下标判断从i > 0变成了j > i + 1。这就是“吃透两数之和和三数之和,顺带把四数之和也拿下了”的原因。这个题族的思维递进关系清晰,非常适合作为刷题路上的模板。
5.2 边界情况和 Python 排序的注意事项
聊完大框架,我想补几个实际刷题中经常遇到的边界问题,这些问题不处理,代码可能在某些测试用例上直接崩。
第一个边界问题是数组长度不够。三数之和要求至少有三个元素,四数之和要求至少四个。如果你不提前判断len(nums) < 3就返回空列表,那么当nums = []时,nums.sort()没问题,但range(n - 2)会变成range(-2),直接不执行循环,倒也不会出错。但有些语言的实现里会有越界风险,所以我建议刷题时都养成习惯,开头就写:
if len(nums) < 3: return []第二个边界问题是排序稳定性。Python 的list.sort()是稳定排序,但三数之和这里不依赖稳定性,因为我们是数值比较,不是键值对排序。不过要小心,如果你对元素排序后还需要保留原始下标,那就要直接存下标或者用enumerate包装。
第三个边界问题是全零数组。比如nums = [0, 0, 0, 0],期望输出只有一个[[0, 0, 0]]。我们的去重逻辑能不能正确处理?外层i = 0,找到第一个三元组(0, 0, 0),然后 left 去重、right 去重,left 和 right 收缩,循环结束;外层i = 1时,nums[1] == nums[0],跳过;i = 2时同理。最终只保留一个三元组,完全正确。
第四个边界问题就是 Python 的负数和正数分界线。由于 Python 的整数没有固定范围,不存在溢出的说法,所以在做nums[i] + nums[left] + nums[right]的时候不需要考虑整型溢出,这比 C++ 和 Java 要省心很多。但要注意,这种便利不能滥用,遇到超大数的输入时,即使不会溢出,也可能因为求和太频繁导致常数因子变大,实际运行时间变长。
5.3 常见 FAQ:为什么三数之和不能用哈希表直接解
这个问题几乎每次讲必被问到。我把它写清楚,当成一个典型误区来解剖。
严格来说,三数之和用哈希表是可以解的:固定两个数,第三个数去哈希表里查。但问题在于:
第一,哈希表操作天然无序。找到的每个三元组需要先排序才能去重,这个排序操作会让代码的时间复杂度增加一个 log 因子。第二,去重逻辑极其繁琐。你必须在固定的外层循环里维护“这个索引之前有没有用过相同的值”这个状态,非常容易写错。第三,空间复杂度是无谓的。哈希表解法至少需要 O(n) 的额外空间,而排序双指针解法只需要 O(1) 的额外空间(除了排序占用的栈空间),在内存越紧张的场景,双指针的优势越大。
所以,“什么场景用哈希,什么场景用双指针”的判断标准是什么呢?我的个人经验是:如果题目要求返回的是下标,优先考虑哈希;如果题目要求返回的是不重复组合值,优先考虑排序加双指针。前者重位置,后者重组合,这个区分能帮你快速圈定方法方向。
6. 把思路变成肌肉记忆的三个习惯
6.1 刷这道题时,建议刻意记几句“口诀式”总结
我不提倡死背代码,但有几句话是值得反复默诵的,它们能帮你快速在脑内搭建起解法框架:
- “两数之和,哈希一遍;边查边存,不会自己。”
- 核心:查 need 之前先保证自己是遍历过的旧元素,先查后存。
- “三数之和,排序双指针;固定一个,两个移动。”
- 核心:先排序,再固定第一个数,用双指针在剩余区间找两个数。
- “匹配成功,双指针一起收;去重时,左重跳左,右重跳右。”
- 核心:找到一组答案后,两个指针同时移动,并把所有连续重复值跳过。
这几句话在刷题过程中反复默念,能极大减少调试时返工的概率。说实话,我每次带同学刷 LeetCode,都是先让他默写这三句话,再让他默写代码。效果好得出奇。
6.2 一步步跑一个真实案例,比看十遍题解管用
学算法最怕的就是只看不练。作为一个实操性强的建议,我特别推荐你在本地跑一遍下面的测试用例:
nums = [-1, 0, 1, 2, -1, -4],对应 LeetCode 原题示例,期望结果是[[-1, -1, 2], [-1, 0, 1]]。nums = [0, 0, 0],期望结果是[[0, 0, 0]]。nums = [3, 0, -2, -1, 1, 2],这是我自己加的刁钻用例,排序后是[-2, -1, 0, 1, 2, 3],仔细想想为什么-1和1的组合会出现在结果里。nums = [1, 2, -2, -1],排序后是[-2, -1, 1, 2],由于排序后负数在前的特点,双指针区间的收缩逻辑会被完整遍历。
拿一组用例一行行跟踪 left、right 的移动轨迹,比对着题解看十遍更有效。把指针移动的每一步都写出来,你会发现双指针的“单调性”就像是一条准绳,把所有不必要的路径都剪掉了。
6.3 和 LeetCode 题库的联动:刷完这两道,后面接什么题
基础算法精讲系列的第一篇,我把这两道题放一起讲,是因为这个题族可以一口气延伸出好几道经典题。刷完这两道,按照难度递增的关系,我推荐的刷题顺序是:
- LeetCode 1:两数之和(哈希表,入门);
- LeetCode 167:两数之和 II - 输入有序数组(排序+双指针,轻量版);
- LeetCode 15:三数之和(核心题,吃透双指针和去重);
- LeetCode 18:四数之和(套壳题,验证你理解的是不是套路);
- LeetCode 653:两数之和 IV - 输入 BST(树上的两数之和变体,考验你把哈希思维迁移到树上)。
这几道题刷下来,你对“找组合”这类问题的理解会牢固很多。尤其是第四题四数之和,如果你能不看任何题解,仅凭三数之和的模板自己扩展出来,说明你已经真正掌握这个题族的套路了。
7. 实测中容易踩到的隐形坑:调试记录与教训汇总
写下这些之前,我先说明:这一节的内容完全来自我自己的实测和带练过程中遇到的真实报错与踩坑,不是凭空想象。每个坑都有对应的调试现场。
第一个坑,很多人忽略了对nums为空或长度不足的判断。我在初学阶段写三数之和,当nums = []时,代码进入for i in range(n - 2),结果是range(-2),循环体一次都不执行,程序不会报错,但很容易误导人。真正有问题的是四数之和,n - 3如果是负数,在某些写法下可能出现索引异常。建议一开头就补上长度判断,这属于防御式编程,面试官看到也不会觉得多余。
第二个坑,是外层循环的去重条件写成nums[i] == nums[i + 1]。前面讲过的经典错误,这里再强调一遍:如果数组里有连续相等的数,用i + 1去重会直接把第一个合法值也跳过了。我实测用[-1, -1, 2]跑过一次,肉眼可见地丢掉了正确答案。这个错位很容易发生,因为人脑直觉上会觉得“既然我用了这个数,那下一个相同的数肯定重复”,但没意识到第一个数才是那个不重复组合的开端。
第三个坑,是双指针匹配成功后只移动一个指针。我见过很多同学的代码,找到一组答案后left += 1就直接进行下一轮,结果right不变,新的nums[left]和nums[right]相加一定比上一组大,导致right永远不再移动,最终陷入死循环或者漏解。这是一个典型的、写出 bug 却很难一眼看出的问题。我的建议是:一旦匹配成功,left 和 right 的移动必须“打包处理”,即去重完之后同时收缩。
第四个坑,是在三数之和里错误地用while nums[left] == nums[left + 1]去重,但忘了检查left < right。当数组里全是重复值,比如[0, 0, 0, 0],最后一个有效区间收缩到left == right之后,再访问nums[left + 1]就会越界。所有去重循环都必须先判断指针是否还在合法区间内。
第五个坑,是nums[i] > 0的剪枝剪过头。有一种常见写法是if nums[i] > 0 and target <= 0: break,这个是有默认前提的:三数之和的 target 是 0。如果你把代码改造成通用的“N 数之和等于 target”版本,nums[i] > 0直接 break 就不成立了,因为 target 可能为负数。In 三数之和这道题里,target 是 0,所以剪枝安全;但你在扩展成四数之和时,必须重新审视这个条件。
第六个坑,是在 Python3 里使用set去重然后直接返回列表。我之前见过一个同学的解法:把所有结果三元组排序后塞进set,期望这样自动去重。但问题是,同一个三元组[-1, 0, 1]和[-1, 1, 0]在排序之前是两个不同的 tuple,塞进 set 后反而产生两条“看似不同、实则相同”的记录。正确的顺序一定是:找到候选结果后,对结果排序再入 set;或者干脆用双指针原地去重,不碰 set。后者更符合算法面试的审美,因为它的空间复杂度更可控。
这些坑沉淀下来之后,我对学生的要求是:写完代码先跑边界用例,再跑重复值多的用例,最后再跑正常用例。这个顺序能覆盖 90% 的 WA 原因。
8. 最后的建议:基础算法精讲系列后续的延伸方向
这一篇是灵茶山艾府基础算法精讲系列的第一篇。很多读者看完会问,这个系列接下来会覆盖什么。我的规划是:这篇笔记只是整个算法框架的起点,后续会依次覆盖二分查找、双指针进阶(比如盛最多水的容器)、滑动窗口、前缀和、差分数组、单调栈、并查集、图论基础等场景。两数之和和三数之和的核心价值,在于建立“遍历 + 记录 + 查找”以及“排序 + 指针移动”这两种范式,它们会反复出现在后面每一类题型里。
具体到个人实操建议,我建议刷完这篇文章后,给自己定一个小目标:三天之内,用不查任何资料的方式,把三数之和的代码完整默写两遍,并把四数之和尝试独立写一遍。这是把短时记忆转成长时记忆最有效的物理手段。如果你只是“看懂了”然后合上屏幕,那和没看没太大区别。
顺便提一个很多人忽略的训练方法:刷 LeetCode 时,把每一道题的“时间复杂度推导”写一下。比如问自己:两数之和为什么是 O(n)?因为每个元素最多被访问多少次?答案是每个元素只进入字典一次,也只被查一次,所以是 O(n)。这种主动推导比被动接受题解强十倍。我带过的学员里,凡是能把复杂度推明白的,后面刷动态规划都明显比别人顺。
这道题族里还有一些值得挖的细节没有在这篇里展开,比如哈希表的冲突处理机制(Python 字典的开放寻址)、双指针的停止条件证明、去重逻辑的严谨性证明,这些内容对面试深挖非常有用。后续我会结合具体题目展开聊,这里先埋个伏笔。
最后再分享一个小技巧:如果你在面试里遇到类似的两数之和题,面试官问“还能优化吗”这样的问题时,别急着回答“不能了”。多考虑一个维度:如果数组是有序的,题目就变成了 LeetCode 167,可以用双指针做到 O(n) 时间、O(1) 空间,绕过哈希表的额外空间。这种“基于条件变化而切换解法”的能力,往往是决定面试成败的关键分水岭。我在实战中靠这个意识救过好几次场,希望你在下次面试时也能用上。