1. 题目到底在考什么:先看懂两数之和的本质
两数之和(Two Sum)在 LeetCode 上是编号第一的题,编号第一不代表最简单,而是因为它是最经典的“入门第一课”。它的描述非常短:给定一个整数数组nums和一个整数目标值target,请在数组中找出和为目标值的那两个整数,返回它们的数组下标。每种输入只对应一个答案,但是数组中同一个元素不能在答案里重复出现。
很多新手第一次看到这题的时候,第一反应是“这不就是两层循环嘛”,然后 5 分钟写出来,提交通过,觉得自己会了。但实际上面试里这道题能挖的深度比你想象中大得多:暴力解法的时间复杂度是多少?能不能优化?优化思路是什么?为什么用哈希表而不是排序加双指针?如果要求返回所有组合怎么做?如果数组是有序的,有没有更简单的写法?如果 target 是负数怎么办?数组里有两个相同的数怎么办?
这些都是在“两数之和”这个简单外壳下面藏着的真实考点。你可以把它理解为算法题里的“起步桩”——它不是为了难倒你,而是为了考察你有没有基本的算法思维:怎么从暴力解法出发,逐步优化到更优解,并且能清晰讲出每一步的理由。
另外说个题外话,很多人以为 LeetCode 热门 100 题里的题都是难题,其实排序靠前的往往是“看起来简单但能延伸出大量知识点”的题,两数之和就是最好的例子。LeetCode 周赛里偶尔也会出现两数之和的变体,比如 430 场周赛里就有类似“两数之和但带限制条件”的题目,本质上还是这套思路。
这道题适合谁来学?不只是准备面试的应届生,还包括所有想建立算法思维、想搞懂哈希表实际应用、想理解“空间换时间”这句话到底什么意思的人。哪怕你工作多年不写算法,看完这篇也会有收获,因为里面涉及的思路——用查找表减少遍历次数——在业务代码里也很常见。
2. 从暴力破解开始:为什么说 O(n²) 也能过
2.1 暴力解法的完整思路和代码
先写最直觉的解法。外层循环枚举第一个数,内层循环枚举第二个数,判断两个数相加是否等于 target。要注意的是内层循环从i + 1开始,避免同一个元素被用两次,也避免出现i和j互换后重复判断的冗余。
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 []这段代码在 LeetCode 上其实也能通过,因为题目给的数据规模通常不大。但它的问题很明显:时间复杂度是 O(n²)。如果数组长度是一万,那最坏情况下要比较五千多万次;如果是十万,那就是五十亿次。放在真实场景里,这种写法基本是跑不动的。
2.2 为什么面试时第一步先写暴力解
很多人在面试时有个误区:想一步到位写出最优解,结果卡在思考过程中,20 秒不说话,面试官印象直接打折。我自己的经验是,先快速给出暴力解法,然后把它的复杂度分析说清楚,再告诉面试官“我们还能怎么优化”。这是一个“展示思维过程”的策略,比直接甩出最优解更能体现工程思维。
暴力解本身也有值得讲的点:i + 1这个起点为什么重要?因为如果 j 也从 0 开始,你会把(0, 1)和(1, 0)判断两遍,更严重的是当i == j时,你会在同一个元素上“自己加自己”。题目明确规定同一个元素不能重复使用,这个细节就是边界条件的雏形。
复杂度分析也要说完整:时间 O(n²),空间 O(1)。空间是 O(1) 是因为除了存输入数组之外,没有用额外的数据结构。这为后面的优化提供了一个对比基线。
2.3 暴力解的局限在哪里
暴力解的核心问题是:内层循环在不停地做“查找”。在数组里逐个查找目标值,这个操作本身是 O(n) 的。如果查找能变成 O(1),那总体复杂度就能降到 O(n)。这就是哈希表的切入点。
你可以把这种优化思路理解为“查字典”:暴力解相当于每道题都从头翻一遍词典,哈希表则是先把词典里的字按拼音索引好,查一次就是一步。
所以暴力解的价值不在于“能用”,而在于它作为对照系,让你能清晰地看出每一步优化到底优化了什么。后面所有解法都围绕同一个问题:能不能把“查找”这个动作变得更快?
3. 哈希表优化:把查找从 O(n) 降到 O(1)
3.1 核心思路:边查边存
两数之和哈希表解法的经典思路是:遍历数组时,对于每个数nums[i],检查target - nums[i]是否已经存在于哈希表中。如果存在,直接返回结果;如果不存在,就把nums[i]作为 key、下标i作为 value 存入哈希表。
这就是“边查边存”。它之所以正确,是因为只要存在一对解,当遍历到这对解中的后一个元素时,前一个元素一定已经在哈希表里了。这样一遍遍历就能完成。
3.2 代码实现与细节解释
def two_sum(nums, target): hash_map = {} for i, num in enumerate(nums): complement = target - num if complement in hash_map: return [hash_map[complement], i] hash_map[num] = i return []注意这里的关键顺序:先查后存。这个顺序是刻意的。假设数组是[3, 3],target 是 6。如果先存后查,i=0 时把3:0存入哈希表,i=1 时又查到了3:0,返回[0, 1],看起来也正确。但再想一种情况:数组是[3],target 是 6,如果先存后查,遍历 i=0 时先存再查,就能查到同一个元素自己,返回[0, 0],这就违反了“同一个元素不能重复使用”的规则。所以先查后存能在逻辑上天然规避这个问题。
3.3 复杂度分析为什么是 O(n)
哈希表查找和插入的平均时间复杂度都是 O(1)。所以整个过程遍历一次数组,每个元素做一次常数时间的查找和插入,总时间复杂度为 O(n)。空间复杂度为 O(n),因为额外存储了一个哈希表,最坏情况下要存 n 个键值对。
这就是典型的“空间换时间”:用额外的 O(n) 空间,把时间复杂度从 O(n²) 降到 O(n)。在算法面试中,这种交易几乎总是划算的,因为 n 变大时,时间复杂度的影响远比空间复杂度严重。举个例子,n 从 1000 变到 10000,暴力解的时间会增长 100 倍,而哈希表解法只增长 10 倍。
3.4 哈希表冲突问题要不要考虑
有读者会问:哈希表理论上是 O(1),但如果发生大量哈希冲突,不是会退化吗?在竞赛或面试场景下,你可以用更严格的说法:平均 O(1),最坏 O(n)。但实际工程中,主流语言的标准库哈希表实现都有冲突处理机制(链表法、红黑树优化等),在可控数据范围内基本不会退化。面试时主动提这一点会加分,说明你不是只知道背模板,而是理解底层。
4. 边界条件与语言细节:那些容易翻车的坑
4.1 返回下标,还是返回值?
两数之和原题要求返回下标,这是最容易忽略的点。如果你刷过其他“两数之和”系列,比如先排序再做的题目,返回的往往是数值本身。下标和值是两套不同的逻辑,前者要求你不能打乱原数组顺序,后者允许你排序后操作。所以拿到题第一件事:看清返回什么。
这题的“坑”在于,哈希表解法天然保存了下标信息,而排序双指针法如果直接使用就会丢失下标对应关系。很多人在迁移解法时栽跟头,就是把“返回值”的题用“返回下标”的思路做了。
4.2 负数场景
设 target 可能为负数,比如nums = [-3, 4, 3, 90],target = 0。哈希表解法完全不受影响,因为target - num = 0 - (-3) = 3,照常查找。暴力解也不受影响。真正需要思考的是补数这个概念是否要求 target 为正——完全不要求。
4.3 有多个重复值的场景
LeetCode 原题限定“只有唯一答案”,但真实用例里可能有两个相同元素。前面说过,哈希表解法中,如果值相同,后存入的 key 会覆盖先前的。比如[3, 3],target = 6,i=1 时存入hash_map[3] = 1,返回结果依然是[0, 1],没问题。因为先查后存的机制保证了第一个 3 是在遍历到第二个 3 之前就被查找过了。
但如果换成“先存后查”,那当遍历到第二个 3 时,hash_map[3] 已经被覆盖成 1,返回结果就变成了[1, 1],这显然错误。所以先查后存不是可有可无的细节,而是保证正确性的关键。
4.4 Java 的 Integer 缓存陷阱
如果用 Java 写这道题,有一个非常隐蔽的坑:HashMap的 key 是Integer类型,而Integer在 -128 到 127 之间有缓存。如果你用==去比较两个Integer是否相等,大数场景下会出问题;但在哈希表中,查找和插入用的是equals和hashCode,所以不会有这个问题。
但是,如果面试官追问“两个Integer用==比较是否相等”,在 127 以内是 true,超过 127 则可能是 false,因为会自动装箱成新的对象。这个细节经常被拿来考察 Java 基础。
4.5 找不到答案时返回什么
原题保证有解,但工程实现中还是要处理无解情况,返回空数组或None。如果你在生产代码里写一个可能越界访问的解法,那是灾难。面试时返回空列表[]是常见做法,同时要说清楚“如果题目保证有解,这里也可以不处理”。
5. 举一反三:两数之和的变体与面试延伸
5.1 变体一:输入是排序数组
如果把输入数组改成有序的,就可以用双指针法,时间复杂度 O(n),空间 O(1)。左指针指向开头,右指针指向结尾,每次比较两数和与 target 的大小:和太小则左指针右移,和太大则右指针左移。这个方法的核心是“有序”带来的单调性,不需要额外的哈希表。
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] # 注意有的题目要求下标从 1 开始 elif current_sum < target: left += 1 else: right -= 1 return []这个小变体非常有价值,因为 LeetCode 上专门有“两数之和 II - 输入有序数组”这道题,解法就是双指针。面试官喜欢在追问里不断加条件,先问你无序怎么做,再说“如果有序呢”,从哈希表到双指针的切换能看出你的底层理解。
5.2 变体二:三数之和
两数之和延伸出去就是三数之和:在数组中找到三个数,使它们之和为 0。这道题的经典思路是先排序,然后固定一个数,剩余两个数用双指针查找。三数之和比两数之和多了一个“去重”的逻辑,这也是面试的高频题。
从两数之和到三数之和的进阶路径非常顺:先掌握哈希表找两数,再理解排序加双指针找两数,最后套进三数之和,你会发现大部分思路都能复用。LeetCode 热门 100 题里,三数之和紧跟两数之和之后,就是这个原因。
5.3 变体三:返回所有不重复组合
如果题目改成“找出所有和为 target 的不重复组合”,哈希表解法需要小心处理重复元素。这时候更稳妥的方案是先排序,再用双指针,并且跳过重复的元素。因为哈希表一旦遇到多个相同值,key 就会覆盖,丢失“哪些下标”的信息。
这个变体在真实业务里更常见:比如找出一组订单里能凑成某个金额的所有组合。工程问题上,唯一答案的假设往往是理想化的,能处理重复和枚举全部组合才是常态。
5.4 变体四:BST 版本的两数之和
LeetCode 上还有一道题:给定一棵二叉搜索树和一个目标值,判断树中是否存在两个不同节点之和等于目标值。解法通常是哈希集合加递归遍历,或者双指针中序遍历。它考察的是数据结构的底层遍历知识,也是两数之和思路向其他数据结构迁移的样板。
5.5 变体五:最多一次交易的股票问题
另一个从“两数之和”思路迁移过来的经典题是“买卖股票的最佳时机”:给定股价数组,选择某一天买入,之后某一天卖出,求最大利润。它本质上是找max(nums[j] - nums[i]),其中 j > i,这跟两数之和一样都是“一前一后配对”的问题,只不过条件从“加和为 target”变成了“差最大”。
这类“配对型问题”是面试题库里的常客。你掌握了“遍历时用查找表记录历史信息”的思想后,很多题都能秒破。
6. 刷题路线与这张题单怎么用
6.1 第一题的标准刷法
如果你是刚开始刷 LeetCode,我建议的流程是:先自己尝试写暴力解,提交通过后再想优化方案。不要一上来就看题解,因为“自己思考过一遍”和“直接看答案”的记忆深度完全不一样。这就像学游泳,看一百遍教程不如自己下水扑腾一次。
两个解法都写完以后,对比它们的复杂度,用笔写出过程推导。别嫌麻烦,算法思维的建立就是靠这种“主动产出”而不是“被动吸收”。
6.2 从热门 100 题到周赛的进阶路径
刷完两数之和后,按顺序刷这些题比较顺:三数之和、最接近的三数之和、四数之和、两数之和 II(输入有序数组)、两两交换链表中的节点(配对思路)、和为 K 的子数组(前缀和 + 哈希表)。你会发现搜索和查找表的思想无处不在地出现。
等到能稳定写完这些基础题,就可以开始打周赛了。LeetCode 周赛 430 场之类的新题,经常是两三道基础题的组合变形。基础题的“底子”打不牢,周赛里就会觉得每道题都见过,但都想不出解法。
6.3 面试时这一题要讲多久
两数之和在面试中出现时,面试官通常不会让你五分钟结束,而是会听你的思考路径。我建议的节奏是:30 秒讲暴力解思路,2 分钟写代码,30 秒讲复杂度,2 分钟讲哈希表优化思路,再花 2 分钟写优化代码,最后留 1 分钟讨论边界和延伸题。整体控制在 8 分钟左右,这是最舒服的节奏。
一个常见失误是只甩出最优解,然后闭嘴。面试官很想看到的是“你如何从朴素想法一步步演化到最优解”,哪怕你已经知道最优解,也要假装思考一下,说出你排除了哪些方案以及为什么排除。这不是让你演戏,而是展示真实的工程决策习惯。
7. 我踩过的坑与个人体会
刷题这么多年,我在两数之和上踩过的坑还不少。最开始用 Python 写的时候,我不知道哈希表可以用enumerate同时拿下标和值,而是先range(len(nums))再nums[i],代码啰嗦不说,还容易在长数组里看走眼。后来改用enumerate,清爽很多。
还有一个容易犯的错就是“先存后查”。我第一版写的代码是先hash_map[num] = index再查补数,结果在数组只有一个元素且等于 target 一半的时候,返回了[0, 0],提交直接报错。从那以后我把“先查后存”当作一条铁律,每次都先想清楚这个顺序为什么重要。
另外,我想特别强调一点:刷题不只是为了面试,更是为了建立一套“如何把流程优化得更快”的思维方式。两数之和里的哈希表思路,放到业务代码里,就是常见的“先建索引再查询”;放到日志分析里,就是“用字典聚合再统计”;放到数据处理里,就是“空间换时间”。这种迁移能力,才是刷题真正的收获。
如果你刚开始刷 LeetCode,把两数之和当作一个起点就好,后面的路还很长,但这一题值得你花上半天慢慢咀嚼。能把一道简单题的每个细节都讲透,比囫囵吞枣刷十道难题有价值得多。