“两数之和”是力扣第1题,也是无数人刷题之旅的第一站。我敢说,十个刷过力扣的人里,至少八个第一次提交这道题时写的是两层 for 循环,然后盯着 O(n²) 的复杂度和超时提示陷入沉思。这道题表面上是“从数组里找两个数,让它们的和等于目标值”,但你真正把它吃透之后,哈希表查值、空间换时间、双指针扫描、输入边界处理这一整套刷题基本功,基本就都带出来了。
这篇笔记我会从题目解读、三种解法对比、关键细节拆解、踩坑实录四个角度完整过一遍。不管你是刚准备刷题的新手,还是已经刷了百八十题想回来查漏补缺的老手,这篇文章都值得看完——尤其最后那部分关于重复元素和返回顺序的细节,很多人刷了好几遍都没意识到。
1. 题目到底在考什么:先别急着写代码
1.1 题干信息与隐藏条件
先看题干。题目要求很简单:给定一个整数数组nums和一个整数目标值target,在数组中找出和为目标值的那两个整数,并返回它们的数组下标。
但题干里有几条“隐藏条件”,很容易被忽略,却直接影响你写代码的方式。
第一条,“每种输入只会对应一个答案”。意思是只要找到一组符合条件的数就能直接返回,不需要继续往后找。很多人刷题时会下意识地收集所有答案,结果写了多余代码。知道“只有一组解”这个前提,你的代码就可以在找到答案后立刻终止。
第二条,“数组中同一个元素在答案里不能重复出现”。这个限制特别容易被新手忽略,尤其在数组里有重复数字的时候。比如nums = [3, 3]、target = 6,正确答案是[0, 1]而不是[0, 0]。后者的意思是把同一个下标的元素用了两次,这不符合题意。
第三条,数组本身不保证有序。这一点在暴力解里无所谓,但如果你想用双指针解法(也就是后面提到的两数之和 II 的思路),就得先排序。可排序会改变元素原本的下标,所以原题这种“返回下标”的版本,双指针并不能直接套用。这也是为什么很多人在 LeetCode 讨论区看到双指针解法后感到困惑——因为那双指针解法是针对“返回数值”的变体,或者在排序后额外记录原下标的版本。
为什么要抠这些隐藏条件?因为实际面试时,面试官不会完全照搬原题。他会改条件,比如“如果没有解怎么办”“如果要求返回所有不重复的组合呢”“如果数组里有重复数字呢”。你只有在刷题时就对这些条件足够敏感,才能在被追问时从容应对。
1.2 为什么第一版总是两层循环
几乎所有刷过这道题的人,第一版代码都是暴力枚举:外层循环取第一个数,内层循环取第二个数,两数相加等于target就返回下标。代码大概长这样:
def two_sum_brute(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 []为什么大家都从暴力解开始?因为它最简单、最不容易出错。在你对题目都还没完全理解的时候,直接用最笨的办法先跑通,是成本最低的验证方式。我个人的习惯是:算法题先写暴力解,确认题意理解正确,再考虑优化。这看起来像是多此一举,但实际上能帮你规避大量因题意理解偏差导致的返工。
暴力解的问题是时间复杂度太高。外层循环 n 次,内层循环平均 n/2 次,总操作次数大约是 n²/2。LeetCode 的测试数据中,n经常到 10⁴ 甚至 10⁵ 量级,10⁵ 的平方是 10¹⁰ 次操作。Python 一秒钟能执行的简单操作大约是 10⁷ 到 10⁸ 次,所以暴力解在数据量大的时候必然超时。
这也是这道题存在的意义:它逼着你引入一种新数据结构——哈希表,来把时间复杂度从 O(n²) 降到 O(n)。
2. 三种主流解法与完整实现
2.1 暴力枚举:最简单的 AC 方案
虽然是暴力,但代码里也有细节要注意。内层循环的起点必须是i + 1,而不是 0。
很多新手在内层循环里写for j in range(n),这样会导致两个问题:一是i和j相等时会比较同一个元素,比如nums = [3, 3]时会把nums[0] + nums[0]判定为有效结果,这违反了“同一个元素不能重复使用”的约束。二是做了大量重复比较,nums[0]和nums[1]会比较,nums[1]和nums[0]又会比较一次,白白浪费计算。
正确的内层循环起点是i + 1,这样每对组合只会被检查一次。
暴力解在什么情况下是合理的选择?答案是n很小的时候。如果面试官说数组长度最多只有几十,那你直接暴力解完全没问题,甚至更不容易出错。这也是一个重要的刷题经验:不要盲目追求最优解,要根据数据规模选择最合适的方案。上来就写哈希表当然没问题,但如果数据量小,暴力解写起来更快、调试更容易。
2.2 两遍哈希表:先建表再查找
暴力解的时间瓶颈在于:对于每个nums[i],你都要遍历剩下的所有元素来寻找target - nums[i]。这个“查找”操作如果能把 O(n) 降到 O(1),整体复杂度就是 O(n)。
哈希表(Python 里的dict)就是干这个用的。我们可以先把数组里所有元素的值作为 key、下标作为 value 存进哈希表,然后再次遍历数组,对每个nums[i]直接查表看target - nums[i]是否存在。
def two_sum_hashmap_two_pass(nums, target): hashmap = {} for i, num in enumerate(nums): hashmap[num] = i for i, num in enumerate(nums): complement = target - num if complement in hashmap and hashmap[complement] != i: return [i, hashmap[complement]] return []这段代码里有一行很关键:hashmap[complement] != i。
为什么需要这个判断?因为存在一种特殊场景:nums[i]恰好等于target / 2。举个例子,nums = [3, 3]、target = 6,第一遍建表时,hashmap[3]先存下标 0,然后被覆盖成下标 1。第二遍遍历到i = 0时,complement = 3在哈希表中存在,但hashmap[3]的值是 1(不是 0),所以hashmap[complement] != i成立,正确返回[0, 1]。
但如果输入是nums = [3]、target = 6,第二遍遍历i = 0时,complement = 3在哈希表中存在,但hashmap[3] == i,说明找到的是同一个元素,不能使用。如果没有!= i这个判断,代码就会错误地返回[0, 0]。
两遍哈希表的时间复杂度是 O(n),空间复杂度是 O(n)。它是暴力解到一遍哈希表之间的“过渡版本”,理解它能帮你更清楚地看到“为什么”一遍哈希表可行。
2.3 一遍哈希表:边遍历边查,最优解
两遍哈希表需要先完整建表,再遍历查找。但实际上,你完全可以在一次遍历中同时完成“查找”和“建表”两件事。
核心思路:遍历数组时,对每个nums[i],先检查target - nums[i]是否在哈希表中。如果在,直接返回;如果不在,就把nums[i]和它的下标存入哈希表,继续遍历。
def two_sum(nums, target): hashmap = {} for i, num in enumerate(nums): complement = target - num if complement in hashmap: return [hashmap[complement], i] hashmap[num] = i return []同样用nums = [3, 3]、target = 6推演一遍:
i = 0,num = 3,complement = 3,哈希表为空,查不到。把hashmap[3] = 0存入。i = 1,num = 3,complement = 3,在哈希表中查到hashmap[3] = 0,返回[0, 1]。
注意,这里不需要额外的hashmap[complement] != i判断。因为我们在把num存入哈希表之前就完成了查询,所以查到的一定是之前遍历过的元素,不可能是当前元素。这就是“先查后存”的顺序优势。
我再给一个 JavaScript 版本,方便用 JS 刷题的朋友直接参考:
function twoSum(nums, target) { const map = new Map(); for (let i = 0; i < nums.length; i++) { const complement = target - nums[i]; if (map.has(complement)) { return [map.get(complement), i]; } map.set(nums[i], i); } return []; }三种解法对比如下:
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 暴力枚举 | O(n²) | O(1) | 数据量小或确认题意 |
| 两遍哈希表 | O(n) | O(n) | 理解哈希表建表过程 |
| 一遍哈希表 | O(n) | O(n) | 最推荐,代码简洁且高效 |
3. 关键细节与“为什么”拆解
3.1 哈希表查找为什么是 O(1)
很多初学者只知道“用哈希表快”,但说不清快在哪里。这里用一个类比你就能彻底理解。
想象有一家酒店,前台有一本“房客登记簿”。如果你要找一个叫“张三”的人住在哪个房间,你可以选择挨个敲门问,这就是线性查找,O(n)。你也可以去前台查登记簿,按“张三”这个名字直接找到他的房间号,这就是哈希表查找,平均 O(1)。
哈希表内部做的事情其实是:把 key(比如数字 3)通过一个哈希函数计算出一个数字,再把这个数字映射到内部数组的某个位置。这个计算和定位过程不依赖数据总量,所以耗时基本恒定。
Python 的dict底层就是哈希表。key 先计算哈希值,再定位到槽位;如果发生哈希冲突,Python 采用开放寻址法处理。Java 的HashMap则是链地址法加红黑树。这些底层细节在刷题阶段不用深究,但你只需要记住:平均情况下,哈希表的插入和查找都是 O(1),这足以让两数之和这道题的复杂度从 O(n²) 降到 O(n)。
这里顺便提醒一句:哈希表的最坏情况是 O(n),因为如果所有 key 的哈希值都一样,冲突会非常严重。但 Python 对整数、字符串这些内置类型的哈希实现质量很高,刷题场景不用担心这个问题。
3.2 为什么“先查后存”能解决重复元素问题
这是两数之和里最容易讲明白、也最容易踩坑的一个点。
如果你把“先查后存”的顺序反过来,先存当前元素再查,会怎样?还是用nums = [3, 3]、target = 6:
i = 0,先存hashmap[3] = 0,再查complement = 3,此时查到hashmap[3] = 0,返回[0, 0]。
这个结果是错误的,因为下标 0 这个元素被用了两次。
正确的流程是“先查后存”:当前元素还没进哈希表时,它只能“看见”已经遍历过的历史元素。这样即使数组里有重复元素,你匹配到的也一定是两个不同下标的元素,天然满足“同一个元素不能重复出现”的约束。
3.3 返回顺序与下标规则
这道题的输出要求是返回两个下标,顺序无关紧要。return [hashmap[complement], i]和return [i, hashmap[complement]]都能 AC。但我建议统一写成“先返回查到的历史下标,再返回当前下标”,也就是[hashmap[complement], i]。这样你一眼就能看出前一个是之前存的,后一个是当前遍历到的,逻辑清晰,不容易搞混。
还有一个容易被忽略的点:有些变种题要求“返回两个数的值”,而不是下标。这时你的哈希表依然可以存下标,但返回时要仔细看题目要求。刷题最忌讳的就是把做题模板背熟了,结果改一个输出格式就懵了。
4. 常见问题与排查技巧实录
4.1 数组里有重复数字时,哈希表索引被覆盖了怎么办
这是问得最多的问题之一。
场景:nums = [2, 2, 7, 11, 15]、target = 9。两遍哈希表第一遍建表时,hashmap[2]先被写成 0,又被写成 1,最终保留了下标 1。第二遍遍历到i = 0时,complement = 7,在哈希表中查到下标 2,返回[0, 2],正确。
看起来覆盖并没有造成问题,因为题目保证了“只有一组有效答案”。但你要知道,覆盖行为本身会丢失信息。如果题目改成“返回所有满足条件的组合”,且数组里有多个相同的数,你就不能用简单的hashmap[num] = i覆盖写入了。这种情况下,要么用hashmap[num] = [i1, i2, ...]记录同一个值的多个下标,要么换排序双指针思路去重。
4.2 找不到答案时,程序返回什么
原题保证一定有解,但你写代码时依然建议在循环结束后加一行默认返回值:
return []这样做的意义在于:一是防止函数在没有返回值时隐式返回None,二是在面试中被追问“如果没有解呢”时,你已经提前做好了处理。面试官看到你的代码第一反应可能不是算法本身,而是边界处理是否完善。
4.3 尝试用测试用例跑一遍
刷题笔记最大的价值,除了代码,就是测试用例。我每次写这道题都会拿这几组数据喂进去:
- 普通情况:
nums = [2, 7, 11, 15]、target = 9 - 两个相同元素:
nums = [3, 3]、target = 6 - 包含负数:
nums = [-1, -2, -3, -4]、target = -5 - 包含 0:
nums = [0, 1, 4, 0]、target = 0 - 找不到答案(仅限扩展):
nums = [1, 2, 3]、target = 7
每换一种写法,就把这些用例跑一遍。尤其是“两个相同元素”和“包含负数”,最容易暴露“先存后查”和“返回自己”的问题。
4.4 一段可复用的刷题模板
如果你在本地练习,可以准备一个带测试的模板文件,把测试用例一起写进去:
from typing import List def two_sum(nums: List[int], target: int) -> List[int]: hashmap = {} for i, num in enumerate(nums): complement = target - num if complement in hashmap: return [hashmap[complement], i] hashmap[num] = i return [] if __name__ == "__main__": tests = [ ([2, 7, 11, 15], 9, [0, 1]), ([3, 3], 6, [0, 1]), ([-1, -2, -3, -4], -5, [0, 2]), ([0, 1, 4, 0], 0, [0, 3]), ] for nums, target, expected in tests: result = two_sum(nums, target) print(nums, target, result, "PASS" if result == expected else "FAIL")这里用了typing.List做类型标注,好处是代码可读性更高,你在本地 IDE 里写的时候还能自动补全。刷题阶段把模板准备好,能省下不少时间。
5. 举一反三:从两数之和到整个数组题体系
5.1 变体一:两数之和 II(有序数组)用双指针
如果你遇到的是“升序排列的数组”,比如 LeetCode 的 167 题,那么可以用双指针把空间复杂度降到 O(1)。
思路:left指向数组开头,right指向数组末尾。计算nums[left] + nums[right],如果和小于target,说明需要更大的数,left右移;如果和大于target,说明需要更小的数,right左移;相等则返回。
def two_sum_sorted(nums, target): left, right = 0, len(nums) - 1 while left < right: current_sum = nums[left] + nums[right] if current_sum == target: return [left + 1, right + 1] elif current_sum < target: left += 1 else: right -= 1 return []注意这里返回的下标是从 1 开始计数的,因为题目可能这样要求。双指针为什么快?因为数组有序,指针移动时天然避开了大量无效组合,时间复杂度 O(n),空间 O(1)。
这里有一个很多人都会问的问题:排序之后,双指针适用于原题“两数之和”吗?答案是,如果题目要求返回的是原数组的下标,那么不能直接排——排序会打乱下标。你需要在排序前记录每个元素的下标,或者直接用哈希表方案。这也是为什么原题主流的解是一遍哈希表,而不是双指针。
5.2 变体二:三数之和、四数之和
三数之和(LeetCode 15 题)的核心套路是:先排序,然后固定一个数,剩下的问题就是一个“有序数组的两数之和”,用双指针解决。
伪代码思路如下:
- 排序数组。
- 外层指针
i从 0 遍历到n - 3。 - 如果
nums[i]和上一个数相同,跳过(去重)。 - 内层用双指针
left = i + 1、right = n - 1,找target = -nums[i](假设三数之和为 0)。 - 找到一组后,同时移动左右指针,并跳过重复值。
你会发现,三数之和的核心,依然是对“两数之和”思想的应用。这也是为什么把两数之和吃透如此重要——它是后续一系列数组问题的地基。
四数之和(LeetCode 18 题)也类似,无非是再套一层循环,固定两个数,剩下两个数用双指针。套路一样,只是去重逻辑要更细心。
5.3 面试官追问:如果数据量很大怎么办
这是一个很经典的开放性问题。两数之和的标准解法需要 O(n) 的额外空间,但如果数组大到内存放不下,比如数据在磁盘上,哈希表方案就不适用了。
你可以这样回答:先分析瓶颈——内存无法容纳整个数组和哈希表,此时需要分治思路:把数据分块读入,在每一块内部用哈希表或暴力解处理,跨块的部分则需要外部排序后归并查找。或者,如果你对数据分布有了解,可以用近似索引结构进一步优化。
这道追问的意图不是让你设计一套完整系统,而是考察你是否清楚“哈希表用空间换时间”的代价。你只要说出“空间复杂度 O(n) 在大数据量下是瓶颈,需要用分治或外部存储方案”这个方向,就已经算合格了。
6. 刷这道题最容易踩的 3 个坑(实操心得)
6.1 坑一:把当前元素先存进去再查,导致匹配到自己
这个坑我前面反复强调过。代码表现为:
hashmap[num] = i if target - num in hashmap: return [i, hashmap[target - num]]当num * 2 == target时,这个写法会返回[i, i]。哪怕不返回[i, i],也可能因为覆盖了历史下标而漏掉正确答案。
解决方法只有一个:记住“先查后存”的顺序。如果你实在记不住,就用两遍哈希表,建表和查找分开,再加!= i判断,也能保证正确。
6.2 坑二:只考虑正数,忘了数组里有负数
很多新手在本地测试时都用全正数用例,导致代码在负数场景下暴露 bug。哈希表方案通常不受负数影响,但暴力解和基于“排序 + 双指针”的写法,如果没考虑负数,逻辑可能会出错。
比如nums = [-1, -2, -3, -4]、target = -5,正确答案是[0, 2]。暴力解里依然是nums[i] + nums[j] == target逐对判断,没区别;但如果你自己优化成了“固定一个数,在剩余部分用二分查找找 target - nums[i]”,二分查找的前提是有序,你就要确保剩余部分有序。数组无序时,这些额外优化都要重新审视。
我的建议是:本地测试用例一定要包含负数、0、重复元素这三种情况。跑通它们,你的代码在边界处理上基本就稳了。
6.3 坑三:暴力解内层循环从 0 开始,做无效比对
暴力解的经典错误是:
for i in range(n): for j in range(n): if i != j and nums[i] + nums[j] == target: return [i, j]虽然加上了i != j判断,但这样会重复比较大量组合,数据量稍大就容易超时。正确做法是内层从i + 1开始,一次比较只覆盖不同的组合。
实际上,我在刷这道题时有一个习惯:先写暴力解跑通样例,然后立刻改成一遍哈希表,最后再对比两个版本的复杂度。这不是在做无用功,而是刻意练习“从 O(n²) 到 O(n)”的优化思路。面试时,如果时间紧张,你甚至可以直接写一遍哈希表;但如果面试官让你讲讲优化过程,你能从暴力解推导出哈希表方案,他会很满意。这个进阶过程,比单纯背答案有价值得多。