如果要在刷题界选一道“最熟悉的陌生人”,我大概率会投给两数之和。它是LeetCode的第1题,也是无数人打开编辑器写下的第一道算法题。但有意思的是,我面试过不少候选人,十个里有八个能秒答“用哈希表”,可一旦追问“为什么哈希表解法要先查再插”“重复元素出现时索引怎么处理”“如果要求返回所有组合你怎么办”,能讲清楚的往往不到一半。这篇文章不打算只丢一个标准答案让你背走,而是把这题的题意拆解、暴力解与最优解的完整推导、写代码时真正容易踩的坑、面试官最想听什么,以及从它延伸出去的题型都铺开讲一遍。如果你正准备刷题,或者想帮新人讲明白这道题,这篇应该能直接用。
1. 题目条件里藏着决定方案走向的三个细节
1.1 “同一个元素不能使用两次”到底是什么意思
题目描述其实很简短:给定一个整数数组nums和一个整数目标值target,请你在该数组中找出和为目标值的那两个整数,并返回它们的数组下标。
关键约束是结尾那句:同一个元素不能使用两次。
很多人以为这句话只是废话,但实际上它直接决定了暴力循环的写法,也决定了哈希表解法里的插入顺序。举个例子,nums = [3, 3], target = 6,合法答案是[0, 1],也就是把数组里两个不同位置的3各用一次。如果你在暴力解里写j从头开始遍历,nums[0] + nums[0]也等于6,就会返回[0, 0],这明显违反约束。
这个约束还影响一个很多人没意识到的点:如果题目允许同一个元素使用两次,那么nums = [3], target = 6的答案就该是[0, 0]。但本题不允许,所以这组输入是无解的。后面会看到,这个细节跟哈希表“先查再插”的顺序强相关。
1.2 “返回下标”让排序解法失去了直接可行性
很多脑子转得快的人会想:这题排个序,然后左右双指针往中间夹逼不就行了吗?
确实,排序加双指针能在O(n log n)时间内找到两个数值,但题目要的是“原始下标”。排序会打乱元素的原始位置,你就算找到了数值,怎么知道它们在原数组里的位置?
有人会提出,可以先把(value, index)打包成结构体再排序,那就得额外开一块O(n)的存储。还有人会说,排完序用二分查找target - nums[i],但二分同样绕不开索引对应关系的问题。
想明白这一点,你就能理解为什么这道题的“正统”解法是哈希表:它能在不破坏原数组、不丢原始下标的前提下,用空间换时间。如果题目改成“判断数组中是否存在两个数相加等于 target”,那排序加双指针就是非常干净的解法。面试的时候你如果能主动说出这个区别,会给人“真的理解题意”的印象。
1.3 唯一解假设给了我们哪些便利
LeetCode原题有一句话:你可以假设每个输入只对应一种答案,而且你不可以重复使用同一个元素。
这个“唯一解”假设效力很大。意味着你一旦找到答案,可以直接return,不需要把所有组合都收集起来,也不需要考虑多个答案之间的顺序。代码可以写得很干脆。
但如果你把题目改一下,比如“返回所有不重复的两数组合”,那哈希表方案就要大改,得考虑排序去重、处理重复元素、收集所有结果。我在第六节会展开说这个变体。现在先记着:原题的简单,有一部分来自这个很强的假设,而不是解法本身真的和那些变体完全通用。
2. 暴力双循环:作为兜底方案,它的边界隐患也很多
2.1 两层循环的写法与时间复杂度
最朴素的写法如下:
def two_sum_bruteforce(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] return []逻辑没有任何技巧:枚举所有下标对(i, j),检查两数之和。这里有个习惯很多人会忽略,就是内层循环从i + 1开始,而不是从0开始。这既避免了i == j时同一个元素被用两次,也避免了重复检查同一对组合。
复杂度也清楚:假设数组长度为n,比较次数大概是n*(n-1)/2,时间复杂度O(n^2),空间复杂度O(1)。当n = 10^4时,约5000万次比较,勉强能跑;当n = 10^5时,约50亿次比较,基本就卡死了。这就是为什么暴力解在LeetCode大数据范围下会超时。
2.2 暴力解的两个常见错误
我在给新人做Code Review时,见过两个高频错误:
第一个是内层从0开始。一旦nums里有两个相邻元素恰好等于 target 的一半,比如[3, 3],就会错误地返回[0, 0]。这种错误特别隐蔽,因为大部分人测试的时候用的是[2, 7, 11, 15]这种不涉及重复元素的用例。
第二个是没处理无解的情况。如果nums = [1, 2, 3], target = 7,函数会正常跑完两层循环然后退出,这时如果没有最后的return [],在Python里就会隐式返回None,这就给调用方埋了坑。你可能会说“题目保证有解”,但在真实工程里,输入来源可没有“LeetCode保证”这种东西。
2.3 兜底方案在面试中的正确用法
有些候选人觉得,暴力解这么简单,说出来会不会显得很菜?恰恰相反,面试里先说暴力解是正常的,也是面试官预期中的起点。关键在于说完之后,你得主动补一句“这个方案时间复杂度是O(n^2),瓶颈在于每个数都要和之前所有的数重新比较一次,所以我想试试用哈希表把已经见过的数记下来”。
这句话一出口,面试官就知道你不是只会背题,而是真的在从需求出发推导方案。真正减分的行为是:明明只会暴力解,却假装考虑过优化,或者一上来就背诵哈希表代码但说不清为什么。
3. 哈希表解法:为什么先查再插的顺序不能反
3.1 核心思路:把“找另一个数”变成“查表”
假设当前遍历到了下标i,元素值是num。我们要找的是能和它配对的那个数,也就是complement = target - num。问题变成:之前遍历过的元素里,有没有出现过complement?如果有,它的下标是多少?
这就是哈希表派上用场的地方:我们用一个字典seen,键存元素值,值存该元素第一次或最近一次出现的下标。每遍历到一个新数,就查一下target - num在不在seen里。如果在,答案就是[seen[complement], i];如果不在,就把当前元素和它的下标存入哈希表,继续遍历。
这个思路叫“回看法”,或者叫“边遍历边查”。它本质上是在用空间记录历史信息,从而避免每步都回头扫描整个数组。打个比方:你在玩连连看,每翻一张新牌,就看看已经亮过的牌里有没有能和它配对的;不需要先把所有牌翻完再配对,也不用每次回头重新翻之前的所有牌。
3.2 先查后插与先插后查的差异(重点)
这一步是整道题最容易翻车的地方,我单独拿出来说。
正确顺序是:先查哈希表,判断complement是否已经存在,然后把当前数插入哈希表。写成代码是这样:
def two_sum_hashmap(nums, target): seen = {} for i, num in enumerate(nums): complement = target - num if complement in seen: return [seen[complement], i] seen[num] = i return []为什么不能先插后查?看一个最直观的例子。nums = [3, 3], target = 6。
如果先插后查:
- 遍历
i = 0,num = 3,先执行seen[3] = 0,再查complement = 3,发现3 in seen,于是返回[0, 0]。
这结果当然不对,因为下标0只对应一个元素,不能自己和自己相加。更离谱的例子是nums = [3], target = 6,数组里只有一个3,但先插后查也会返回[0, 0],直接把无解判成有解。
反过来,先查后插:
- 遍历
i = 0,查complement = 3,此时哈希表为空,查不到;执行seen[3] = 0。 - 遍历
i = 1,查complement = 3,哈希表里有,返回[0, 1]。
正确。所以“先查后插”不是风格问题,是正确性问题,它保证了当前遍历到的元素还没被记录,不会匹配到自己。
还有一个小点值得说:当数组中同一个值出现多次时,我们的seen[num] = i会不断更新,保留最新下标。这种覆盖策略配合先查后插是安全的。比如nums = [3, 3], target = 6,遍历到第二个3时,哈希表里存的是第一个3的下标0,直接配对返回[0, 1]。如果把目标改成nums = [2, 3, 3], target = 5,遍历到第二个3时,哈希表里已存在2和第一个3,complement = 2也在表里,返回[0, 2],同样正确。覆盖旧下标不会导致答案丢失,因为如果旧下标能配对,在它被覆盖之前就该返回了。
3.3 完整代码实现与复杂度对比
Python完整版:
class Solution: def twoSum(self, nums, target): seen = {} for i, num in enumerate(nums): complement = target - num if complement in seen: return [seen[complement], i] seen[num] = i return []如果你平时写Java,逻辑也一样:
Map<Integer, Integer> map = new HashMap<>(); for (int i = 0; i < nums.length; i++) { int complement = target - nums[i]; if (map.containsKey(complement)) { return new int[]{map.get(complement), i}; } map.put(nums[i], i); } return new int[]{};C++用unordered_map也完全一致,关键是“先查后插”。
三种常见方案的复杂度对比如下:
| 方案 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 暴力双循环 | O(n^2) | O(1) | 数组极小、思路兜底 |
| 排序后双指针 | O(n log n) | O(1) | 允许改数组、不要求原下标 |
| 哈希表一遍遍历 | O(n) | O(n) | 大多数场景、必须返回原下标 |
哈希表解法之所以是这道题的“最优解”,是因为它把时间复杂度从平方级别降到线性级别,付出的代价只是O(n)的额外空间。这个“空间换时间”的权衡,在算法面试里出现的频率极高,值得反复体会。
4. 边界用例实测:负数、重复元素、无解场景逐个过
4.1 经典用例与负数场景
我整理了几个有代表性的用例,建议你写完代码后至少跑一遍:
| 输入 nums | target | 期望输出 | 说明 |
|---|---|---|---|
| [2, 7, 11, 15] | 9 | [0, 1] | 最经典用例 |
| [3, 2, 4] | 6 | [1, 2] | 答案不在开头两个位置 |
| [-3, 4, 3, 90] | 0 | [0, 2] | 负数参与配对 |
| [3, 3] | 6 | [0, 1] | 重复元素必须使用不同下标 |
| [1, 2, 3] | 7 | [] | 无解场景 |
关于负数,多说一句。有些初学者会担心哈希表存负数出问题,实际上完全不用担心,哈希表对键的类型没有大小比较要求,-3和3就是完全不同的两个键。complement = 0 - (-3) = 3,到第2个下标找到3,配对正确。反过来,如果target本身是负数,比如nums = [-2, 1], target = -1,complement = -1 - (-2) = 1,也能正常匹配。
4.2 重复元素:同一元素重复出现时索引怎么取
重复元素是这道题最容易让新手慌乱的地方,因为哈希表是“一个键对一个值”,同一个值出现两次,到底存哪个下标?
答案在3.2已经说明:存最新下标,并且这个覆盖是安全的。我再补一个更复杂的例子来加深理解。
假设nums = [2, 3, 2, 4], target = 6。正确组合是下标2和3,即2 + 4 = 6。
遍历过程:
i = 0,num = 2,seen为空,存seen[2] = 0i = 1,num = 3,complement = 3,没找到,存seen[3] = 1i = 2,num = 2,complement = 4,没找到,更新seen[2] = 2i = 3,num = 4,complement = 2,找到了seen[2] = 2,返回[2, 3]
如果按照“第一次出现下标优先”的策略(即不更新seen[2] = 0),结果也一样会返回[0, 3],0 + 4 = 6,同样正确,因为题目只要一组唯一解。所以覆盖不会引入错误,只是影响了返回哪一组解而已。
4.3 无解时的约定:先说清楚再写代码
LeetCode原题保证一定存在答案,所以很多题解压根不写无解分支。但真实面试中,强烈建议你在写代码前开口确认:“如果找不到怎么办?返回空数组还是[-1, -1]?”
这个提问本身是加分项,说明你在考虑异常路径。
我自己实现时,习惯返回空数组[],而不是返回None。原因很简单:None在语义上容易被误认为“结果为空”和“函数出错”两种情况,调用方还得额外区分;空数组的语义更明确,并且可以和调用方用len(result) == 2来统一判断。当然,如果面试官有明确偏好,按他的要求来。
5. 面试官真正想看的是你的推导路径
5.1 为什么第一题选它:哈希范式的最佳入门
LeetCode把两数之和放在第1题,不是因为它难,而是因为它是“哈希表思想”最浓缩的演示。它需要的数据结构知识只有一个:哈希表;它考察的能力却有好几个:读题、识别约束、复杂度分析、边界处理、从低效方案到高效方案的推导。
面试里用这道题热身也很合适。十分钟左右能聊完,但能聊出很多维度。我遇过不少候选人,上来直接默写出一段哈希表代码,写得完全正确,但问他“为什么这个解法是O(n)”,他答不上来。代码是背的,理解是散的,这类候选人虽然这道题过了,但后续题基本会露馅。
5.2 从暴力到最优的引导式回答模板
我建议按下面的节奏组织你的回答,既显得自然,也不会被面试官打断:
- 复述题目,确认边界:“我需要返回两个下标,可以假设只有唯一解吗?无解的时候返回什么?”
- 先说暴力解:“最直接的做法是两层循环,枚举所有下标对,时间复杂度
O(n^2),空间O(1)。” - 主动指出瓶颈:“每个数都要和之前所有的数再比一次,重复比较太多了。我想把已经看过的数记下来。”
- 提出哈希表方案:“遍历的时候,查
target - num是否在之前出现过,可以用哈希表把值和下标存起来,这样每次查表是O(1),整体是O(n)。” - 讲细节:“注意要先查后插,避免当前元素匹配到自己。”
- 展示知识面:“如果数组有序且要求空间
O(1),排序后双指针也很合适;但本题要返回原始下标,所以哈希表更直接。”
这六步走完,面试官对这道题的考察基本就结束了。你会发现,真正拉开差距的不是那几行代码,而是第3步到第5步的思考过程。
5.3 如果面试官追加限制,你该如何应变
面试官有时候不会让你爽快结束,他会加一句:“如果不能用额外空间呢?”或者“如果数组特别大,内存放不下哈希表呢?”
这类追加条件,考的是你对方案适用面的认知,而不是要求你现场写一个完美方案。我的建议是:
- 如果限制空间
O(1),那哈希表方案直接被否掉,可以提出先排序再双指针,但要指出排序会改变原数组,如果必须返回原下标,需要一个额外的位置映射,这又需要空间。也就是限制本身可能和“返回原下标”冲突,这时要敢于和面试官确认约束。 - 如果限制“不能修改原数组”,那么排序方案要谨慎,哈希表依然是稳妥的。
- 如果限制“数据规模极大,内存受限”,那问题的脱裤子放屁了,得考虑外部排序或者流式处理方案,这已经属于工程层面的展开,面试时能说出“可以先落盘再分块处理”这种思路,已经足够。
这些应变不需要你把代码现场写出来,把取舍讲清楚,面试官已经满意了。
6. 从两数之和发散:一张网能展开的经典题目
6.1 有序数组版:双指针把空间复杂度降到O(1)
如果输入数组是有序的,比如LeetCode 167. 两数之和 II - 输入有序数组,可以用左右双指针:
def two_sum_sorted(nums, target): left, right = 0, len(nums) - 1 while left < right: cur = nums[left] + nums[right] if cur == target: return [left + 1, right + 1] elif cur < target: left += 1 else: right -= 1 return []为什么移动指针是安全的?因为数组有序。如果当前两数和小于target,说明需要更大的数,右指针向左移动只会让和更小,只有左指针右移才能让和变大;反过来,如果当前和大于target,只有右指针左移能让和变小。这个“夹逼”过程每次排除掉一个不可能的位置,所以整体O(n),空间O(1)。
这个变体和哈希表版本形成了很好的对照:有序这个额外条件,给了你一个省内存的选择。也是从两数之和走向双指针这类大型考点的重要跳板。
6.2 三数之和:排序+双指针的思路演进
两数之和如果升级到三数之和,想用哈希表硬套就会很痛苦,因为要求“返回所有不重复的三元组”,而且存在负数、零、重复元素。
三数之和的正确思路,恰恰是我们在题目条件第一小节里“否掉”过的排序方案:先排序,固定一个数,剩下两个数用双指针找。排序在这里除了提供夹逼能力,还有一个额外作用:让重复元素相邻,方便去重。固定第一个数时,如果当前值和上一个值相同,直接跳过;双指针找到一组答案后,也要把左右指针移动到不同的值上。
很多人在三数之和卡住,本质上就是因为没吃透两数之和里“唯一解假设帮我们省掉了去重和枚举所有组合的麻烦”。所以两数之和刷到位,三数之和上手会快很多。
6.3 “边遍历边查”范式的其他应用场景
两数之和真正的价值,在于那个“遍历过程中维护历史信息,每步查询一次”的范式。它还会反复出现在和它看起来八竿子打不着的题目里:
- 和为K的子数组:用前缀和加哈希表计数,遍历时查“当前前缀和减去K”是否出现过,逻辑和两数之和几乎一一对应。
- 最长连续序列:先把所有数放入哈希集合,遍历每个数时向左右扩展看连续长度,核心也是哈希表的快速查询。
- 同构字符串、单词规律:用两个哈希表建立互相映射,本质也是“记录已见过信息并查询”。
这些题看起来复杂度完全不同,但思维底座都来自两数之和。把这道第1题真正嚼透,后面啃中等题、困难题时,你会在很多角落看到它的影子。
我自己的习惯是,每次带新人或复习时,都会把这道题从头到尾过一遍:先画图,讲清楚为什么要返回下标、为什么要先查后插、为什么覆盖最新索引不会出错。这三个“为什么”搞清楚,比记忆十道题的模板都值钱。两数之和的代码只有短短几行,思维密度却一点都不低。如果你正在刷题初期,建议别急着往下刷,先把这道题的所有细节都吃掉,它后面用的地方还多着呢。