打开LeetCode的热门100题榜单,排在第一位的永远是同一道题:两数之和。这道题在算法圈子里几乎是“Hello World”级别的存在,但有意思的是,我见过太多人在这道题上栽跟头——不是不会做,而是没想明白背后考察的东西。如果你是个刚准备刷算法题的新手,或者已经刷了几道题但总觉得在“背答案”,这篇博文就是为你准备的。
我会用带点踩坑气息的口吻,把这道题从暴力解法到哈希表优化、从代码实现到面试延伸全部拆开讲清楚。不仅告诉你代码怎么写,还会告诉你每一步为什么这么写。争取你看完之后,不只是会做这一道题,而是能理解“找数”这类问题的通用思路。
1. 先看懂题目到底在问什么
1.1 题目描述与输入输出拆解
先把原题摆出来:给定一个整数数组nums和一个整数目标值target,请你在该数组中找出和为目标值target的那两个整数,并返回它们的数组下标。
举个例子:nums = [2, 7, 11, 15],target = 9,因为2 + 7 = 9,所以返回[0, 1]。
看清楚,这里有几个关键信息:
- 输入是一个数组和一个目标值
- 要找的是两个数,它们的和恰好等于目标值
- 返回的是这两个数在数组中的下标,而不是数值本身
很多人第一次做的时候会在这一步犯错:返回了[2, 7]而不是[0, 1]。题目要的是下标,不是元素值。这个区别看起来很小,但在面试里如果没看清就直接写代码,印象分会大打折扣。
再看一遍示例,2在第0个位置,7在第1个位置,所以答案是[0, 1]。这是标准的从0开始的下标计数方式,几乎所有编程语言都遵循这个规则。新手如果之前用的是一些下标从1开始的工具或语言,在这里需要尽快切换思维。
这道题在Hot100里的位置决定了它的特殊性:它是整个题库的起点,也是大多数人算法之路的第一站。它考察的知识点非常纯粹——数组遍历和哈希表基础应用,没有复杂的递归、动态规划、二叉树,所以特别适合作为热身题。但同时它又足够典型,后续很多题(三数之和、四数之和、和为某个值的连续子数组)都是从这题的思路发芽的。
1.2 新手最容易忽略的隐藏条件
原题里其实还藏着几个条件,虽然题目没说特别强调,但读题时就要把它们当硬性约束来对待。
第一,数组是未排序的。有人会想:我先排个序,再用双指针往中间夹逼,时间复杂度还能降到O(n)。想法没错,但排序会改变元素下标,而这题最终要求的是下标,如果你排序了,原下标就丢了。如果只返回数值,排序后确实可行,但返回下标的话,排序这个操作就带来麻烦。所以这道题最简单直接的方式不是排序,而是哈希表或暴力循环。
第二,每个输入恰好只有一个答案。这个条件非常关键,它意味着你不需要考虑多个解的情况。一旦找到一组答案,就可以直接返回。如果题目没有这个约束,你就要小心处理重复组合的问题,代码会复杂不少。新手可能觉得“只有一个答案”是废话,但实际刷题时,有没有这个条件直接决定了返回时机的写法。
第三,同一个元素不能重复使用。也就是说,你不能拿同一个下标的数加两次。比如nums = [3, 3],target = 6,答案是[0, 1],而不是[0, 0]。这个条件在暴力解中很好避开,因为内层循环从i+1开始;但在哈希表解法中,如果不注意遍历和更新哈希表的顺序,就可能拿到同一个元素用两次的错误结果。这个坑后面我会单独说。
我在带一些新手做这道题时,习惯用“合同条款”来打比方:题目描述是一份合同,每个约束条件都是条款,你不能只关注核心条款(找两个数和为target),还要仔细审其他条款(数组是否有序、是否有唯一解、每个元素能否重复用)。这些条款决定了你的技术选型。很多人算法学得不错,但读题不仔细,上了考场才发现自己漏条件,这是最冤的丢分方式。
2. 暴力解:先用最笨的办法解决问题
2.1 双层循环的思路与实现
在学习哈希表之前,几乎所有人脑子里冒出来的第一个思路就是暴力枚举:把数组里的每个数都拿起来,和其他所有数加一遍,看看谁的组合等于target。用代码实现就是双层循环,外层循环固定第一个数,内层循环遍历第二个数。
Python写法:
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 []Java写法:
public int[] twoSum(int[] nums, int target) { int n = nums.length; for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { if (nums[i] + nums[j] == target) { return new int[]{i, j}; } } } return new int[]{}; }注意这里内层循环的起点是i + 1,不是0。两个原因:一是避免同一个数自己加自己(组合(i, i)是无效的),二是避免重复的组合((i, j)和(j, i)本质是同一个组合)。如果你从0开始写内层循环,代码也能跑,但多了一倍的无用计算,还会直接违反“每个元素只能用一次”的约束。
这个版本虽然笨,但它有不可替代的价值:写起来简单、不容易出错、作为兜底方案非常可靠。很多高级算法都是在暴力解的基础上做优化,你脑子里先有一个能跑的版本,再去想怎么优化,思路会更清晰。
2.2 时间复杂度分析:为什么暴力解不够好
暴力解的时间复杂度是O(n²)。怎么算出来的?外层循环跑了n次,内层循环平均也跑了接近n次,所以总操作数大概是 n²/2 次,忽略常数系数就是O(n²)。
我习惯用一个具体数字来让新手感受这个复杂度。假设数组长度是1000,暴力解大约要执行50万次加法;如果长度是10000,那就要约5000万次。这还只是加法,如果数组再长一点,比如百万级别,那计算量就变成了天文数字。虽然两数之和这道题在LeetCode上给的数据规模不算大,暴力解通常也能通过,但面试官不会止步于“能通过”。
时间复杂度这个概念,新手第一次听到可能觉得抽象,我用一个生活化的例子来理解:你有一抽屉的袜子,暴力解就是每次拿起一只袜子,再挨个翻开其他袜子去配对。如果有10只袜子,你要翻45次;如果有100只袜子,你要翻4950次。翻了太多遍之后,手臂酸不酸另说,时间是真的耗不起。
暴力解最大的问题不在于“笨”,而在于它的耗时随数据规模呈平方级增长。这就是为什么我们要找更聪明的算法。需要注意的是,并不是暴力解就一无是处,在很多小数据量的实际场景里,暴力解是首选,因为简单、可读性强、不容易写出bug。优化是分场景的,不要一上来就过度设计。
3. 哈希表解法:一篇文章讲透“空间换时间”
3.1 从生活场景理解哈希表的查找优势
暴力解的痛点在于:为了找到“另一半”(即 target - 当前数),我们不得不遍历整个数组。那么有没有一种办法,可以让我们在O(1)的时间里直接确认某个数是否存在?
有,这就是哈希表。哈希表的底层原理是通过哈希函数把键映射到一个存储位置,这样查询的时候直接根据键算出位置,不需要逐个扫描。它的查找时间复杂度平均是O(1),非常接近“瞬间找到”。代价是什么?需要额外开辟一块内存来存储这些键值对,这就是经典的“空间换时间”。
我来打个比方。暴力解就像在一个没有目录的图书馆里找一本书,你得挨个书架挨个书架翻;哈希表解法就像给每本书先登记一个编号,找的时候按编号直接锁定位于哪一排哪一个格子。
具体到代码层面,Python里的哈希表就是字典(dict),Java里是HashMap。它们都支持“根据键快速查询对应的值”这个操作。在两数之和这道题里,我们需要的操作是:把数组元素的值作为键,下标作为值存入哈希表,然后对每个元素查一下target - nums[i]这个值是否已经在表里。
这里有个值得注意的细节:哈希表的键是唯一的,如果数组里有重复元素,后存入的会把先存入的覆盖掉。但在两数之和的常规场景下,我们只需返回任意一组下标,所以覆盖通常不影响最终结果。后面讲到“返回所有组合”时,就需要更小心地处理重复元素了。
3.2 哈希表解法的两种写法:两次遍历 vs 一次遍历
哈希表解法有两种常见的实现方式,我按新手理解的难度分开讲。
第一种:先遍历一遍,把所有元素的下标信息存入哈希表,再遍历一遍数组,对每个元素查哈希表里有没有它的另一半。
def two_sum(nums, target): hashtable = {} for i in range(len(nums)): hashtable[nums[i]] = i for i in range(len(nums)): other = target - nums[i] if other in hashtable and hashtable[other] != i: return [i, hashtable[other]] return []这个写法很好理解:第一遍循环完成了“登记”,第二遍循环做“查找”。注意判断条件里必须有hashtable[other] != i,不然可能返回[i, i],也就是同一个元素用两次。刚才说nums = [3, 3],target = 6的情况,第一遍循环结束后哈希表里存储的是{3: 1},因为后一个3覆盖了前一个3。第二遍循环遍历到下标0时,other = 3,查表返回的下标是1,1 != 0,结果正确。
第二种:一遍遍历,边查边存。每遍历到一个元素,先检查target - nums[i]是否已经在表里,如果不在,就把当前元素作为键、下标作为值存进去。
def two_sum(nums, target): hashtable = {} for i in range(len(nums)): other = target - nums[i] if other in hashtable: return [hashtable[other], i] hashtable[nums[i]] = i return []这个方法的关键在于:先查再存。为什么要先查再存?因为我们要避免同一个下标被查到自己。假设target是6,当前元素是3,哈希表里还没有3,所以不需要担心自配对的问题。如果你反过来先存再查,会有一个隐藏bug:当遍历到某个元素时,它自己的值已经被存进去了,查它的另一半时如果另一半恰好等于它自身,就会错误地返回同一个下标。这一点初学者最容易踩到。
Java版本的一遍遍历写法:
public int[] twoSum(int[] nums, int target) { Map<Integer, Integer> hashtable = new HashMap<>(); for (int i = 0; i < nums.length; i++) { int other = target - nums[i]; if (hashtable.containsKey(other)) { return new int[]{hashtable.get(other), i}; } hashtable.put(nums[i], i); } return new int[]{}; }Java的containsKey方法和Python的in操作符功能类似,都是判断键是否存在。返回时,hashtable.get(other)是之前存过的另一半的下标,i是当前元素的下标。注意返回顺序无所谓,题目只需要两个下标。
这两种写法的时间复杂度都是O(n),空间复杂度都是O(n)。第一种更好理解,第二种代码更短、速度上也少一次遍历。我的建议是:新手先用第一种,把思路走通之后,再尝试理解和默写第二种。两种写法在面试中都是可以接受的,关键是能说清楚为什么要用哈希表、为什么空间换时间值得。
4. 新手实操复盘:重点和容易踩的坑
4.1 写代码的每一步细节
很多新手看题解时觉得很容易,但自己写的时候处处碰壁。我把实操中的关键细节拆开讲一遍。
第一步,确定返回类型。题目要求返回两个下标组成的数组(或列表),所以Python里返回[i, j],Java里返回new int[]{i, j}。
第二步,初始化哈希表。Python直接用{}字面量即可,Java需要new HashMap<>()。这里顺便提一下Java的装箱问题:HashMap不接受原始类型int,只能使用包装类型Integer,所以在写泛型时要用Map<Integer, Integer>。
第三步,遍历目标数组。可以用普通的for循环,也可以用增强for循环配合下标。在需要同时拿到元素值和下标时,普通for循环更直接。
第四步,计算other = target - nums[i]。这一步用的是减法的等量关系:如果a + b = target,那么b = target - a。千万不要写成other = target + nums[i],这个符号错误在不经意间就会出现,一出现就是全盘错误。
第五步,判断并返回。Python里用if other in hashtable,Java里用if (hashtable.containsKey(other))。这里还有一个细节:如果输入的数组为空,或者遍历完整个数组都没有找到匹配项,需要有一个兜底返回值,比如空数组[]或new int[]{}。虽然题目保证了必有解,但工程上养成兜底的习惯没有坏处,这也能让代码在异常情况下不会抛空指针或未定义行为。
还有一点关于变量命名的建议:不要用i、j这种语义为空的名字来命名other,而应该叫complement或other,让人一眼看出这是“当前数的补数”。这不算硬性要求,但代码可读性是面试评分的一项隐性指标。我见过很多新手写出来的代码功能没错,但变量名是a、b、c,面试官读起来很费力,印象分就降了。
4.2 新手的经典报错与排查思路
我把平时带人刷题时最常见的几类问题整理成了一张速查表,新手遇到报错时可以先对照这个表格自查。
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 返回了下标组合,但题目判定错误 | 返回了元素值而不是下标 | 重新读题,确认return的对象是[i, j]而不是[nums[i], nums[j]] |
| 提交显示超时 | 用了两层循环,数据量大时超时 | 改用哈希表解法,时间复杂度从O(n²)降到O(n) |
返回了[i, i] | 没判断另一半的下标是否等于当前下标 | 在命中条件中加上hashtable[other] != i的判断 |
| 结果不通过,且数组里有重复元素 | 哈希表覆盖了相同键的下标 | 确认这是否影响答案;若影响,改用先判断后存入的顺序 |
| Java代码报空指针 | 使用了hashtable.get()但没有先containsKey判断 | 先判断键存在,再调用get |
其中有一个场景我想单独讲讲:当你用“先存再查”的写法时,如果数组是[3, 2, 4],target = 6,结果会是什么?遍历到下标0,先存{3: 0};遍历到下标1,先存{2: 1};遍历到下标2时,other = 2,查表发现有,返回[1, 2],正确。但如果数组是[3, 3],target = 6,遍历到下标0,先存{3: 0};遍历到下标1时,other = 3,查表发现有,并且下标是0,不是当前下标1,于是返回[0, 1],也正确。所以“先存再查”在很多情况下也能碰巧正确,但在某些变体题目里会产生问题。用“先查再存”是一种更稳妥的惯性,建议从开始就养成这个习惯。
另一个我特别想提醒的是调试方式。新手遇到代码不过时,不要去“瞪眼法”找bug,直接在代码里加打印输出,看看哈希表每次插入和查询时到底发生了什么。比如Python里可以print(i, nums[i], other, hashtable)输出每一步的状态。几行打印就能定位到80%的问题,比反复读代码效率高很多。等确认逻辑没问题了,再删掉打印语句。
5. 从这道题延伸出去的解法与面试思路
5.1 双指针解法及其适用前提
除了暴力解和哈希表,还有一个在特定条件下更优的解法:双指针。它的前提是数组必须是有序的。思路是先排序,然后用左右两个指针,初始时左指针指向数组头部,右指针指向尾部,计算当前两个指针指向元素的和。如果和大于target,右指针左移;如果和小于target,左指针右移;如果恰好等于target,返回结果。
为什么有序数组能用双指针?因为排序让数组具备了单调性。向右移动左指针会让和变大,向左移动右指针会让和变小。在这个约束下,指针只需要线性移动,不会漏掉任何可能的组合。时间复杂度是O(n)的遍历,加上排序O(n log n)的开销。
但是注意,两数之和这道题不允许排序后直接使用双指针,因为排序会打乱下标。如果你硬要用双指针,就必须在排序前记录每个元素的原始下标,操作起来相当麻烦,而且如果数组有重复元素,记录下标时还要小心处理。所以常规解法还是哈希表。不过双指针的思想必须掌握,因为后续的三数之和、最接近的三数之和、盛最多水的容器等经典题目,全都建立在双指针思路上。
面试时如果主动提到“如果数组有序,我还会用双指针解法”,这会成为展示你思路开阔的加分项。但前提是你真的理解复杂度对比:哈希表时间是O(n)、空间是O(n);有序数组双指针时间是O(n log n)、空间是O(1)(排序原地进行)。在实际场景中,如果对空间要求苛刻且数组可以排序,双指针就是更好的选择。
5.2 如果题目要求返回所有组合怎么写
原题说“只有唯一解”,所以找到一个答案直接返回就行。但很多面试官会追问:如果没有唯一解约束,要求返回所有不重复的组合,你怎么办?
这个追问的解法会复杂不少。核心问题有两个:第一,哈希表里一个键只有一个下标的限制会失效,因为同一个值可能对应多个下标;第二,要处理组合去重,比如数组是[2, 2, 3, 7],target = 9,组合[0, 3]和[1, 3]对应的数值都是2 + 7,如果问的是“不重复的组合”,就要避免重复输出。
处理思路通常是:先把数组排序,然后遍历每个元素作为第一个数,用哈希表或双指针在其后查找第二个数。排序之后,相同的值会靠在一起,方便跳过重复。如果是返回所有不重复的组合,通常用双指针更合适,因为哈希表在记录重复键方面比较笨拙。
这个进阶版本对新手来说不需要完全掌握,但至少要知道方向:先排序去重,再用双指针或哈希表查找。如果能在面试时说出“排序 + 双指针”这个组合,面试官会认为你有举一反三的能力。
5.3 刷题心态与复盘方法论
回到Hot100本身,两数之和只是第一题,但它的学习价值远超题目难度。
我自己的体会是,刷题不是为了“过题”,而是为了积累“题感”。做每一道题时,都要问自己三个问题:这题属于什么类型(数组、字符串、树、动态规划)?最优解法用到了什么数据结构(哈希表、堆、栈、双指针)?有没有类似的题可以归为一类?
针对两数之和,你至少可以建立两个知识锚点。第一个锚点是“查找类问题用哈希表”:当你需要在遍历中反复确认某个值是否存在时,首先想到哈希表,而不是二分查找或者两层循环。第二个锚点是“空间换时间”的思维方式:很多算法优化的本质是在时间和空间之间做权衡,哈希表用额外内存换取了接近O(1)的查询速度,这个模式在后续的LRU缓存、字母异位词分组等题里都会复现。
对于新手,我不建议一上来就追求所有解法最优解一次性写对。更合理的节奏是这样:先能写出暴力解,确认自己理解了题目的输入输出;然后看一遍哈希表解法的思路,合上书自己写一遍;卡住了就看一眼提示,再合上书重写。整个过程用不了半小时,但比你抄五遍代码都管用。
另外我在实际辅导别人时会发现一个问题:不少新手会把看懂了错当成会写了。看懂和能独立写出来之间有一条巨大的鸿沟。检验标准很简单:关掉所有参考,打开一个空白编辑器,计时10分钟写这道题。如果能一次性跑通,才是真的掌握了。
如果再想深挖一点,你可以尝试给自己加需求:比如数组很大且内存很小怎么办?数组里有负数怎么办?target是负数怎么办?这些“如果”是很好的思维体操,能帮你在面试时应对各种变体。两数之和的变体几乎无穷无尽,但只要掌握了“哈希表解决查找”这个核心思路,万变不离其宗。
个人觉得,这道题最值得你花时间的地方不是背下代码,而是揣摩它出现在Hot100第一位的设计意图。它的位置就已经暗示了:算法面试的基础就是哈希表、数组、复杂度分析这三大件,而这题一个不落,全都覆盖了。吃透它,后面的路会好走很多。