先交代一下我为什么会写这篇文章。这些年我经常参与算法面试,也带过不少新人。面试候选人时,我几乎每次都会问"两数之和",因为这道题几乎所有刷过题的人都会碰见。但有意思的是,真把它当回事的人并不多——大多数人都只是背了一版哈希解,把答案默写出来,然后就进入了下一题。一旦我追问一句"为什么哈希表查找是 O(1)",或者"如果数组特别大,内存装不下哈希表,你还能怎么解",很多人的思路就卡住了。
所以我想花一整篇的篇幅,把"两数之和"这道题从题目到解法再到工程应用完整拆一遍。这篇文章不仅适合刚开始刷算法题的同学,也适合需要给团队做算法内部分享、或者正被算法面试折磨的工程师。我会把暴力枚举、哈希表、排序+双指针这三种主流解法讲清楚,再把它的变体(三数之和、BST版本、数据流版本)和真实项目里的对应场景都展开,最后把我踩过的坑一次说完。
1. 这道题为什么被称作算法刷题第一题
1.1 题目本身其实只有三句话
先把原题原样贴出来:
给定一个整数数组 nums 和一个整数目标值 target, 请你在该数组中找出和为目标值 target 的那两个整数,并返回它们的数组下标。 你可以假设每种输入只会对应一个答案。但是,数组中同一个元素不能使用两遍。 你可以按任意顺序返回答案。这几句话信息量其实不小。我面试的时候见过不少人在这些细节上翻车,所以一句一句拆开看。
第一句定义了输入是两个参数:一个数组和一个目标值。第二句是关键——返回的是数组下标,不是值本身。我见过有候选人最后返回了[2, 7]而不是[0, 1],等于白写。第三句隐藏了三个约束:答案唯一、同一个下标不能使用两次、输出顺序不限。许多解法(尤其是后面要讲的哈希表)正是建立在这几个约束上才成立的。
1.2 它究竟在考察什么
只看表面,它考的是你写没写过基础循环。但面试官看问题的角度完全不同。
第一,它考察你如何把一个问题从"人能理解"翻译成"机器能执行"。暴力枚举是最直白的翻译,但这并不丢人。第二,它考察复杂度意识。同样是正确解法,O(n²) 和 O(n) 的差距,在 n=10⁵ 时就意味着 10¹⁰ 次运算和 10⁵ 次运算的区别,前者在普通机器上要跑几十秒,后者眨眼完成。第三,它考察数据结构选择能力。数组查找是 O(n),哈希表查找是 O(1),这种"用空间换时间"的权衡,是整个算法面试最核心的考察点。
1.3 为什么这道题总被放在第一题
这个位置不是随便给的。它不需要任何前置算法知识,一个刚学完循环和数组的人就能动手做,但它又能自然引出哈希表这种最常用的数据结构。
更重要的是,它是"从暴力到最优"的极佳样本。几乎所有经典算法题,都能用这条思路去套:先想最笨的办法,分析重复计算在哪里,找出可以通过某种数据结构优化的部分,然后实现更优解法。两数之和把这套流程压缩到了二十行代码里,所以我一直觉得,这道题是算法思维的压缩包。
2. 暴力枚举:先写对,再写好
2.1 双重循环的实现思路
最直白的思路是:拿出第一个数,在剩下的数里找有没有target - 第一个数;如果没有,再拿出第二个数继续找。这就是暴力枚举,也叫穷举。
def two_sum(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 []这里有两个细节值得注意。一是内层循环j从i + 1开始,而不是从 0 开始。这既保证了"同一个元素不会被使用两遍",也避免了重复配对,比如[0, 1]和[1, 0]会被当成两种答案的问题。二是找不到时返回空数组,这是很多题目默认的行为,不要返回None让调用方去猜。
2.2 复杂度算清楚
外层循环 n 次,内层循环平均 n/2 次,总共约 n²/2 次比较,时间复杂度就是 O(n²)。整个过程只用了几个临时变量,空间复杂度 O(1)。
如果数组长度只有 100,这个复杂度完全无所谓;但长度到了 100 万,内层循环就可能要执行约 5×10¹¹ 次,这在真实机器上是不可接受的。这也是算法复杂度的意义所在:很多时候不是"不能解",而是"解不完"。
2.3 为什么面试时先提暴力解不是减分项
我第一次参加算法面试时,紧张到直接写哈希解,结果被追问得一愣一愣的。后来一位面试官朋友告诉我,他其实更希望候选人先分析暴力解,再过渡到优化解,因为这才是真实工程里的思考路径——先确保功能正确,再考虑性能。上来就写最优解,反而让他怀疑是不是背题。
所以更稳妥的面试节奏是:先口头说一句"最朴素的做法是双重循环,复杂度 O(n²)",确认思路正确后,再深挖优化。把暴力分析清楚,还能帮你验证对题目的理解没有偏差。
2.4 暴力解在真实场景里并非一无是处
说句公道话,O(n²) 不是永远都不可用。如果数组只有几十个元素,双重循环的代码比哈希表简单得多,也没有哈希冲突、内存占用等问题。在某些嵌入式环境、内存极其受限的场景里,O(1) 空间的解法反而比 O(n) 空间的解法更合适。算法题里的"最优解",到真实项目里未必是最优,这一点等讲到工程落点的时候还会再展开。
3. 哈希表一次遍历:面试官真正想要的那个答案
3.1 优化思路到底从哪冒出来的
重新审视暴力解法的内层循环,它本质上在做一件事:在数组里查找target - nums[i]是否存在。既然每次都要查找,那我们自然想到:能不能提前把所有元素放到一个可以 O(1) 查找的结构里?
这个结构就是哈希表。思路非常朴素:遍历数组,对于每一个nums[i],只关心一个问题——在已经见过的元素里,有没有target - nums[i]。
用一个实际例子走一遍。nums = [2, 7, 11, 15],target = 9:
- i=0,
nums[0]=2,需要找 7。此时哈希表为空,没找到,把2存进去,键是 2,值是下标 0。 - i=1,
nums[1]=7,需要找 2。此时哈希表里有 2,命中,返回下标[0, 1]。
整个过程只遍历一次数组。每个元素进来时,先查"补数"存不存在,不存在就把自己存进去,等后面的元素来查。这就是"一次遍历"的精髓。
3.2 先查后存 vs 先存后查:一个容易写错的细节
这里有个细节必须强调:必须先查,再存。如果先把自己存进去再查,就会出问题。
数组[3, 3],target=6。如果先把nums[0]=3存进去,再查target - nums[1] = 3,会发现哈希表里已经有一个 3 了。此时哈希表里的下标是 0,而当前下标是 1,结果返回[0, 1],看起来没问题。
但换个情况,数组只有[3]一个元素,target=6。先存后查时,自己会被查到自己,错误返回[0, 0]。这就是题目里"同一个元素不能使用两遍"想要规避的情况。
一次遍历的解法,因为每个元素都是先查后存,当前元素还在"外面",不可能找到自己,天然规避了这个 bug。这是一个很隐蔽的细节,面试时主动说出来,非常加分。
3.3 两种哈希表写法放在一起对比
两次遍历版本,逻辑更直观但需要额外判断:
def two_sum_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 []一次遍历版本,也是我最推荐的写法:
def two_sum_one_pass(nums, target): hashmap = {} for i, num in enumerate(nums): complement = target - num if complement in hashmap: return [hashmap[complement], i] hashmap[num] = i return []两次遍历好理解,但多建了整张表,多了一轮循环,还要专门判断hashmap[complement] != i。一次遍历在时间、空间上都更优,而且代码更简洁。面试时建议直接上一边遍历版本,但前提是你能把"先查后存"的道理说清楚。
3.4 哈希表查找为什么是 O(1):用查字典来理解
很多人背下了"哈希表查找 O(1)",但要讲清原理就卡壳。我用一个类比来解释。
想象一本按拼音排序的字典。如果想找"猫",拼音排序可以二分查找,每次砍一半,复杂度 O(log n)。但如果你在字典侧面做一个索引:每个首字母对应一个页码范围,那查"猫"只需要先翻到"m"这一页,再在那一小块里找。哈希表就是这个索引的极致版——通过哈希函数,直接把 key 映射到存储位置,不需要逐个比较,所以大多数情况只需要一次计算加一次内存访问。这就是"期望 O(1)"的含义。
严格来说,哈希表最坏情况会退化成 O(n)。如果哈希函数设计得不好,或者数据恰好全部碰撞,所有 key 都落在同一个桶里,查找就又变成线性扫描。在算法题里,我们默认哈希表是 O(1);真实工程里,选择哈希函数、处理哈希冲突从来都不是纯理论问题。
提示:面试时被追问"哈希冲突怎么办"时,可以从链地址法、开放寻址法、扩容重哈希这几个方向回答,不要只说"不知道,反正题目能过"。
3.5 Java 和 C++ 实现参考
只给 Python 不太够,面试里最常遇到的还是 Java 和 C++,我也都贴一遍。
Java:
class Solution { public int[] twoSum(int[] nums, int target) { 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[0]; } }C++:
class Solution { public: vector<int> twoSum(vector<int>& nums, int target) { unordered_map<int, int> hashmap; for (int i = 0; i < nums.size(); i++) { int complement = target - nums[i]; if (hashmap.count(complement)) { return {hashmap[complement], i}; } hashmap[nums[i]] = i; } return {}; } };注意 Java 里HashMap存在自动装箱和哈希碰撞的问题,但作为算法题标准写法没有问题。如果面试官往工程方向深挖,你可以补充一句:数据量极大时可以考虑用IntIntHashMap之类的原始类型哈希表减少装箱开销。
3.6 复杂度总结
时间上,每个元素最多做一次哈希查找、一次哈希插入,整体 O(n)。空间上,最坏情况所有元素都进了哈希表,O(n)。这就是典型的"空间换时间"——用 O(n) 的额外内存,把暴力解的 O(n²) 时间降到了 O(n)。
4. 排序+双指针:另一种值得掌握的思路
4.1 双指针是在什么前提下成立的
哈希表解法最怕的场景有两个:一是不能破坏原数组的顺序,但可以接受 O(n) 空间;二是题目要求返回值而不是下标,或者输入数组本身已经有序。这时候双指针就登场了。
排序+双指针的核心思想是:先把数组排好序,然后用一左一右两个指针向中间移动。但"双指针"这个技巧能成立的前提,是数组有序。这个前提决定了它的适用范围。
LeetCode 167 就是典型例子,输入数组已经有序,要求找两个数的下标。哈希表照样能做,但双指针的空间复杂度只有 O(1),更优。
4.2 指针移动的逻辑为什么是对的
先看代码,再解释为什么指针只能这样移动。
def two_sum_sorted(nums, target): left, right = 0, len(nums) - 1 while left < right: current = nums[left] + nums[right] if current == target: return [left, right] elif current < target: left += 1 else: right -= 1 return []为什么current < target时 left 右移?因为数组有序,nums[left]是较小的那一端,nums[right]是较大的那一端。当前和太小,说明小的那头还可以更大一点,所以 left 往右走。同理current > target时,需要把大的那头往左收,所以 right 左移。
为什么这样移动不会漏掉正确答案?关键在于每一步都排除一个不可能的区域:如果当前和小于 target,那么以当前 left 为左边界的所有组合都不可能凑出 target——因为 right 已经是最大可选值了,left 不动时和只会越来越小。所以可以把 left 整个排除。反过来同理。这个"排除不可能区域"的想法,是双指针一类题目共通的正确性证明思路。
4.3 时间空间复杂度
排序用高效的排序算法,平均 O(n log n)。双指针阶段每个元素最多被指针扫到一次,是 O(n)。整体 O(n log n)。空间上,如果不考虑排序过程本身使用的临时空间,双指针阶段是 O(1),比哈希表的 O(n) 有明显优势。
4.4 三种解法到底怎么选
我用一张表把三种解法放在一起对比:
| 解法 | 时间复杂度 | 空间复杂度 | 能否返回原始下标 | 适用场景 |
|---|---|---|---|---|
| 暴力枚举 | O(n²) | O(1) | 能 | n 很小、代码最简单 |
| 哈希表一次遍历 | O(n) | O(n) | 能 | 数组无序,必须保下标 |
| 排序+双指针 | O(n log n) | O(1) | 不能(除非额外记录) | 数组有序、或只要求返回组合 |
注意最后一行:排序会打乱原始下标。如果题目要求返回原始数组下标,就不能单纯排序+双指针,除非你在排序前额外保存一份"值->原始下标"的映射,或者用结构体同时保存值和原始下标。这也是很多人在 LeetCode 167 上卡住的原因——167 恰恰给的是一个已经排好序的数组,所以这个问题不存在。
5. 两数之和的变体地图:从三数之和到数据流
5.1 变体一:求所有不重复的配对
原题只要求返回一组答案,但实际场景里经常需要"找出所有和等于 target 的不重复组合"。哈希表在去重这个问题上很麻烦,容易写错。更稳妥的做法是排序+双指针,配合跳过重复元素。
以下代码框架可以直接背下来,也是后面三数之和的基础:
def two_sum_all_pairs(nums, target): nums.sort() res = [] left, right = 0, len(nums) - 1 while left < right: s = nums[left] + nums[right] if s == 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 s < target: left += 1 else: right -= 1 return res那两个跳过重复元素的 while 循环是精髓:命中目标后,如果左右两侧存在相同值,直接移动会得到完全一样的组合,必须一次性跳过去。
5.2 变体二:三数之和
三数之和是两数之和最经典的扩展,面试频率比两数之和本身还高。核心思路是先排序,再固定一个数,剩下的两个数用双指针找。也就是把"三数之和"降级为"两数之和"。
def three_sum(nums, target): nums.sort() n = len(nums) res = [] for i in range(n - 2): if i > 0 and nums[i] == nums[i - 1]: continue left, right = i + 1, n - 1 while left < right: current = nums[i] + nums[left] + nums[right] if current == target: 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 elif current < target: left += 1 else: right -= 1 return res去重逻辑有三层:外层固定值跳过重复;内层命中后,左右指针各自跳过重复。理解了双指针的移动逻辑之后,三数之和完全不需要死记硬背。
5.3 变体三:二叉搜索树中的两数之和
如果输入不是数组,而是一棵二叉搜索树,怎么做?BST 有一个天然性质:中序遍历结果是递增序列。所以最直接的思路是:先中序遍历把树转成有序数组,然后套双指针。这就是把一道树形题降级成数组题。
面试时先给出"中序遍历+双指针"的方案,一般就足够了。如果继续深挖,可以提"用两个迭代器从树的两侧向内遍历,不额外开数组",但这个实现复杂度会高不少,需要深刻理解 BST 前驱和后继的概念。
5.4 变体四:数据流场景
真正的数据往往不是一次性给出的数组,而是持续到达的流。每来一个新数,都需要判断"之前是否出现过某个数,和它加起来等于 target"。这个场景其实就是哈希表一遍遍历的流式版本:每来一个新数,先查补数在不在集合里,再把新数插进去。
真实工程里这种模式非常常见。比如接口防重:每来一个请求,先查请求签名是否在缓存里,不在就写入并放行。可以说,两数之和的哈希解法,本质上就是在教你一个"先查后存"的流处理套路,这也是它被称作经典的原因之一。
6. 从刷题到工程:两数之和思想在真实项目里的落点
6.1 缓存设计里的先查后存
真实项目里最常见的哈希表应用就是缓存。读缓存时,先查 key 是否存在,命中则返回,不命中则查数据库并回填。这个流程和两数之和的一遍遍历完全一样:先查补数,查不到再存自己。顺序一旦错乱就会出大问题——如果先写入缓存再查询,并发场景下可能读到刚写入但还没完全初始化的脏数据。
6.2 幂等与去重系统
在支付、订单这类系统里,幂等是刚需:同一笔订单号不能重复处理。常规实现就是把"已处理的订单号"放进哈希集合,每次新请求先查是否存在,存在就拒绝,不存在就写入并处理。这和两数之和里"查找补数"的思维同源。
而且一旦数据量达到千万级、上亿级,这个内存哈希集合自然会演进成 Redis、BloomFilter 等分布式方案,但底层的"先查后存"思想没有变。很多人在学了布隆过滤器之后,才回头发现原来它和两数之和的哈希表是一脉相通的。
6.3 请求合并与参数配对
还有一种工程场景:判断两个参数组合是否已经存在。比如表中 (A, B) 这个组合是否唯一,或者一个批处理任务中 (A, B) 是否重复提交。最常规的做法就是把 A 和 B 拼成一个 key,放进哈希集合。这实际上就是两数之和的键值设计思路:把两个数的组合映射成一个可比较的 key,然后查表、存表。
6.4 从算法到架构的变量:数据量
算法题里 n 通常只有 10⁴ 到 10⁵ 量级,哈希表 O(n) 空间无所谓。但真实系统的数据量可能是 10⁹。这时"O(n) 空间"不再是可忽略的成本。
如果内存装不下,就要考虑位图、布隆过滤器、外排序、归并等替代方案。我自己做数据去重时,几百万数据用哈希集合没问题,但到千万级以上就开始评估布隆过滤器和数据库索引了。算法题的价值不在于照搬,而在于给你一个基础模型,让你能基于这个模型做工程取舍。
7. 我在面试和笔试中踩过的坑,一次说清
7.1 坑一:把下标和值搞混
这是最冤枉的失分点。题目要返回下标,有人写成了返回值。我建议在写代码之前,先把题目要求的输出格式用一句话写出来,比如"返回两个下标组成的数组"。写完代码后,再对着这个要求检查一遍 return 语句。这个习惯治好了我一半以上因为低级失误导致的测评不通过。
7.2 坑二:没处理空数组和单元素数组
边界条件是面试里的必考项。如果 nums 是空数组,暴力解法的循环天然不进入,返回[]即可;单元素数组也类似。哈希解同样不受影响。但一定要主动思考这些边界,否则写出来的代码遇到极端测试用例,可能直接数组越界。
7.3 坑三:target 为负数或 0 时乱了阵脚
两数之和没有规定数组和 target 必须为正数。nums = [-3, 4, 3, 90],target = 0,答案应该是[0, 2]。有些人一看到负数就觉得不对,其实解法没有任何变化:负数照样进哈希表,补数照常计算。所以千万别对输入做无根据的假设,尤其在面试手写代码时。
7.4 坑四:整数溢出的隐患
如果nums[i]非常大,接近整数上限,那么nums[i] + nums[j]在 Java 里可能溢出成负数,导致明明和等于 target 却比较不出来。更稳妥的写法是避免直接相加,改成比较target - nums[i] == nums[j],用减法替代加法。这既避免溢出,也更容易让面试官看到你在处理边界情况。
7.5 坑五:哈希表覆盖引发重复元素问题
数组里如果有重复元素,两次遍历的哈希表法有可能查到自己。比如[3, 3],target=6,如果哈希表把键 3 的值覆盖成后一个下标 1,那么第一次遍历时 complement=3,查到的下标已经变成 1,会和自身下标重复,返回[1, 1]这种错误。一遍遍历的"先查后存"天然不存在这个问题,这也是我推荐一边遍历版本的原因之一。
7.6 坑六:面试时一上来就闷头写最优解
这是心态问题。我见过太多候选人,题目还没分析,就开始敲哈希表,万一思路卡住,整场面试都乱了。更好的方式是:先跟面试官对齐思路,"我准备先用暴力解分析一下,再优化"。说完暴力解的复杂度,再说"这里可以用哈希表把查找从 O(n) 降到 O(1)",然后开始写。整个过程显得有章法,你自己也不容易紧张。
提示:如果遇到完全没做过的题,也先走一遍"暴力解 -> 复杂度分析 -> 优化方向"的流程。这个节奏在面试里比最终解法的完美程度更重要。
最后,分享一个我自己的习惯。刷完两数之和之后,我没有急着进入下一题,而是花时间把自己代入面试官的角色,试着反问自己几个问题:如果数组很大内存装不下怎么办?如果要求返回所有组合怎么办?如果输入本身有序,能不能更省?这些问题就是我列出的变体章节的来源。事实证明,被这些问题"折磨"过之后,再去面试时遇到原题,几乎都能讲出比标准答案更深一层的东西。两数之和看起来简单,真正吃透它,你等于拿到了通往哈希表、双指针、复杂度分析这三座大山的钥匙。