打开 LeetCode 的第一道题,大概率就是“两数之和”。很多人觉得它太简单了,闭着眼睛都能写,三分钟 AC,然后马上冲下一题。但我面试过不少候选人,能把这道题真正讲清楚、讲透彻的,其实不多。
一个很现实的情况是:你顺手写出来的解法,和你能在面试官面前讲明白的方案,中间差着一整层思考。今天我就把自己对这道“LeetCode 热题 1”的完整拆解整理出来,包括暴力的坑、哈希表为什么是正解、一遍和两遍哈希表的区别,以及面试官最爱的那些延伸追问。当然后面还会聊到我刷题这几年从它身上延伸出来的几条路线,希望能帮你把这道题的剩余价值榨干。
1. 看完题目别急着写代码:先明白它在考什么
1.1 从题目描述里抽丝剥茧
原题的文字很短,我经常让候选人先念一遍题,再说说有几个隐藏条件需要注意。题目大概是这样的:
给定一个整数数组
nums和一个整数目标值target,请你在该数组中找出和为目标值target的那两个整数,并返回它们的数组下标。你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。你可以按任意顺序返回答案。
这几句话里面有三个关键信息,很多人一眼扫过去就丢了。
第一是“返回它们的数组下标”。这句话决定了我们不能先排序再原样输出,因为排序会打乱下标位置。如果你动手先给数组排了个序,后面还得想办法记录原始下标,绕了一大圈,不如直接用哈希表。
第二是“每种输入只会对应一个答案”。这是一个很强的条件,意味着不用考虑多个答案的情况,也不需要去重。很多变形题里这个条件会被去掉,那时难度会瞬间上升,我们在第 4 节会专门聊。
第三是“同一个元素在答案里不能重复出现”。这句话是在堵一个漏洞:如果你用元素本身去重而不是用下标去重,在nums = [3, 3],target = 6这种输入下就会出问题。两个 3 虽然是重复值,但是它们分属两个不同的下标,完全合法。
做算法题最忌讳的就是上来就写代码。先把这三个条件在脑子里过一遍,你就知道这道题大概需要什么数据结构了:要快速查找,显然哈希表是首选。
1.2 暴力解法为什么只能算“保底”
所有的算法爱好者都经历过从暴力到优化的过程,两数之和恰好是这个过程最好的样板。先说说暴力解。
暴力解的思路非常朴素:枚举数组中的每一个数nums[i],再看它后面的每一个数nums[j],如果nums[i] + nums[j] == target,直接返回[i, j]。这是 O(n²) 的时间复杂度,代码也很短:
def two_sum_brutal(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 = 10000,最坏情况下内层循环要跑大约 5000 万次。虽然现代 CPU 处理起来可能也就几百毫秒,但一旦n来到几十万甚至上百万,O(n²) 就变成灾难了。
我在记忆里很清楚地记得第一次跑 LeetCode 的极端用例时的感受:暴力解在大数组上直接超时,那一刻才真正切身体会到“复杂度”不是纸面上画画曲线就完事的。所以暴力解法在面试里可以作为思考起点,但不能作为最终方案。面试官一般会点头示意你继续说,然后追问一句“能不能优化一下”。
这就是哈希表登场的时候了。
2. 哈希表解法:两数之和真正的考点
2.1 哈希表到底是什么:一个储物柜的故事
哈希表这个名字听起来有点劝退新人,但它的核心思想特别简单,就是一个带编号的储物柜。
你去游泳馆,前台给你一个手环,手环上印着柜子编号。你想存东西的时候,不用一个柜子一个柜子地打开找空位,直接看手环去对应的柜子就行。哈希表就是这样一个结构:通过一个键(key),直接算出它对应的存储位置,然后以 O(1) 的时间去访问。
在“两数之和”这道题里,我们需要的查找是:给定一个数x,快速判断target - x在不在数组里,如果在的话,它的下标是多少。这正是哈希表的强项——我用值做 key,用下标做 value,查找一个值是否存在及其下标,平均只需要 O(1)。
这背后的思想叫“空间换时间”。我们不满足于暴力解法的 O(1) 空间,所以额外开了一张哈希表,把遍历过的信息记下来,换来了 O(n) 时间。
这里有一个非常关键的“逆向思维”:不是拿当前的数去找另一个数,而是“我每走到一个位置,都在问:谁和我凑成 target?我已经见过它了吗?”这种“边遍历边查表”的思维,在后面的前缀和、滑动窗口、以及很多更难的题目里会反复出现。
2.2 两遍哈希表的实现与复杂度分析
我们先写一个最直观的哈希表版本:遍历数组两遍。第一遍把所有的<值, 下标>存入哈希表,第二遍遍历数组,对每个nums[i]去查target - nums[i]是否在表中,如果在且不是同一个下标,直接返回。
def two_sum_two_pass(nums, target): mapping = {} for i, num in enumerate(nums): mapping[num] = i for i, num in enumerate(nums): complement = target - num if complement in mapping and mapping[complement] != i: return [i, mapping[complement]] return []这个解法的时间复杂度是 O(n),空间复杂度 O(n)。为什么需要判断mapping[complement] != i?因为题目不允许同一个元素用两次,如果nums = [1, 3, 3, 5],target = 6,第二遍循环走到第二个 3 的时候,查表能查到第一个 3,下标不同,合法;但如果数组只有一个 3 时,查到的下标就是自己,必须跳过去。
不过,两遍哈希表有一个隐患:当数组中出现重复元素时,第一遍存表会发生后一个下标覆盖前一个下标的情况。比如nums = [3, 3],遍历完mapping = {3: 1}。好在第二遍按数组索引依次遍历,i = 0时查到mapping[3] = 1,返回[0, 1],结果依然正确。这是这道题的条件设计得巧妙:重复值不会导致错误答案,但如果你在别的题里也这样写,可能就被覆盖坑了。
所以更加推荐的,是下面这种“一遍哈希表”的写法。
3. 一遍哈希表:从“查”到“边走边记”
3.1 先查表再存表,顺序不能反
一遍哈希表的核心思路是:只遍历一次数组,每处理一个元素,先查表找答案,如果找到了直接返回;找不到,就把当前这个元素存进表里。
因为当前元素还没存入表,所以在查表时永远不会查到自己。这天然就满足了“同一个元素不能重复使用”的条件,也避免了两遍哈希表的下标覆盖问题。
def two_sum(nums, target): mapping = {} for i, num in enumerate(nums): complement = target - num if complement in mapping: return [mapping[complement], i] mapping[num] = i return []很多初学者最容易犯的一个错误,就是“先存后查”。如果把mapping[num] = i写在if complement in mapping之前,在nums = [3, 3]、target = 6的时候,i = 0存进去,i = 1时查到complement = 3,查到的下标是0,结果还是对的。但换个输入就不同了:nums = [2, 0, 2],target = 4,i = 0先存了 2,i = 2时查 complement = 2,查到下标 0,返回[0, 2],依然正确。
那错误到底错在哪?问题出在另一种场景:nums = [6],target = 12这种单元素无解的情况。如果先存后查,程序会查到自己在表里的记录,返回[0, 0],而正确答案应该是无解。虽然 LeetCode 原题保证了有解,但写工程代码时你不可能依赖这个保证。所以记住这个顺序:先查表,再存表,这是这道题唯一的规范动作。
拿一个具体例子走一遍完整流程。假设nums = [2, 7, 11, 15],target = 9:
i = 0,num = 2,complement = 7,表里没有 7,把 2 存进去,mapping = {2: 0}i = 1,num = 7,complement = 2,表里有 2,且下标是 0,返回[0, 1]
看到了吗?一次循环就解决。后面的元素根本不需要遍历。
3.2 越界、重复值与语言差异:实现时的坑
虽然一遍哈希表代码看起来只有几行,但落到不同语言里,还是有几个细节值得注意。
第一是整型溢出的问题。在 C++ 和 Java 里,target - num可能超出 int 范围,如果题目数据范围很大,建议用long类型去接收。Python 因为支持大整数,完全不需要担心。LeetCode 原题给出的数据范围一般不会真的溢出,但这是一个很好的加分项,面试时主动提一句,面试官会认为你有工程意识。
第二是哈希表的 API 选择。Java 里HashMap<Integer, Integer>的get方法返回Integer,如果 key 不存在会得到null,所以一般这样写:
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]; } }而 Python 里dict用in判断就非常顺手。Go 的map[int]int有一个“取不到值返回零值”的问题,所以需要借助ok模式来判断 key 是否存在,否则遇到 value 为 0 的情况会出错。
下面是 Go 的写法,感受一下差别:
func twoSum(nums []int, target int) []int { m := make(map[int]int) for i, num := range nums { if j, ok := m[target-num]; ok { return []int{j, i} } m[num] = i } return nil }第三是哈希表初始容量的优化。如果你知道数组规模大概是多少,可以在创建 map 的时候指定初始容量,减少扩容带来的开销。Java 的HashMap可以给初始容量,Go 的make(map[int]int, len(nums))也是常规操作。这种优化对这道题可能看不出来,但对于追求极致性能的人来说是一个习惯。
我自己的经验是,这道题用一遍哈希表已经是最优解了:时间 O(n),空间 O(n)。数组在极端情况下都是不排序的,所以不存在 O(n log n) 排序加双指针的最优说法,后面会讲为什么双指针不是第一选择。
4. 面试官真正想问的东西:从这道题延伸出去的追问
4.1 有序数组怎么办:双指针对撞
两数之和如果是基于有序数组,LeetCode 专门开了一道题叫“两数之和 II - 输入有序数组”,题号 167。这种场景下,哈希表已经不算是最优解了,因为数组有序之后,我们有更聪明的办法:双指针。
双指针的思路是:左指针指向数组开头,右指针指向数组结尾,计算当前两个指针位置的和。
如果和比 target 大,说明右边太大了,右指针左移;如果和比 target 小,说明左边太小了,左指针右移;如果相等,直接返回。循环条件是left < right。
def two_sum_sorted(numbers, target): left, right = 0, len(numbers) - 1 while left < right: current_sum = numbers[left] + numbers[right] if current_sum == target: return [left + 1, right + 1] elif current_sum < target: left += 1 else: right -= 1 return []注意这个写法里返回下标时加了 1,因为这道题的下标是从 1 开始的,而 Python 数组是 0 开头的。很多人在这一点上栽过跟头,我也曾经因为惯性直接返回 0 开头的下标,提交后报错才反应过来。
双指针的时间复杂度是 O(n),空间复杂度是 O(1),比哈希表省了额外空间。这就是算法题有趣的地方:同一种问题,约束条件一变化,最优方案就变了。
所以当面试官追问“如果数组有序,你还能优化吗”,你要能立刻切换到双指针模式。这不仅仅是炫技,而是体现了你“根据场景选算法”的能力。
4.2 多个答案与重复元素:两道高频变形题
原题说“每种输入只会对应一个答案”,所以不需要去重。但如果把条件改一改,变成“找出所有不重复的组合”,题目难度就上来了。这里我建议你把三数之和(LeetCode 15)和四数之和(LeetCode 18)一起刷了,因为它们本质上是两数之和的升级版,核心套路都是排序 + 双指针 + 去重。
去重是有讲究的。暴力做法是找到所有组合后用 Set 去重,但这样效率太低,大概率超时。正确做法是在移动指针的时候跳过重复值,比如固定第一个数nums[i]之后,双指针在右边区间找target - nums[i]的两个数时,如果left移动后和移动前值相同,就继续移动,直到值变化为止。
我在刚开始刷三数之和的时候,因为去重逻辑写得不对,一直收获 Wrong Answer。那个卡了我一晚上的 bug 就是:不去重会漏解,去重太狠会把合法解也去掉。具体来说,去重必须发生在找到一个合法组合之后,而不是在找组合之前,否则像[-2, 0, 0, 2, 2]这种用例就会丢解。
还有一道延伸题叫“和为 K 的子数组”,题号 560。它用到了前缀和加哈希表,本质思想也是两数之和的变体:维护一个前缀和变量prefix_sum,每到一个位置都查一下prefix_sum - k在不在哈希表里,如果在,就说明存在一条从之前某个位置到当前位置的子数组和为 K。这个思路当年我想了很久才转过弯,其实就是“两数之和”换了一层皮,但一旦想通,很多子数组问题都能用类似方法解。
如果你是一个正在准备面试的人,我强烈建议把“两数之和 -> 两数之和 II -> 三数之和 -> 四数之和 -> 和为 K 的子数组”这条链子串起来刷,每一道都只比前一道多一点变化。这样刷完,你对这类题的体系化理解会超过大多数人。
5. 刷题路线与个人心得:这道题教会我的那些事
5.1 在 LeetCode 体系里的位置:为什么它总是第一题
做过 LeetCode 热门 100 题的人应该都有印象,“两数之和”几乎出现在每一个热门题单的榜首。剑指 Offer 里有它的变体,LCR 系列里也有它的影子,周赛里偶尔也会出现像是“两数之和”套壳的 T1 签到题。
为什么偏偏是它?我的理解是,这道题把“暴力求解”和“哈希表优化”的对比展现得淋漓尽致。你不需要懂复杂的数学知识,不需要掌握二叉树、图论这些高级内容,只需要明白“空间换时间”这一个点,就能写出最优解。它是一个完美的“数据结构入门课”:
- 从思考路径上,它逼着你从双层循环的惯性里跳出来;
- 从数据结构上,它展示了哈希表最典型的使用场景;
- 从面试角度,它考察了边界条件的敏感程度;
- 从工程角度,它本身就是“查找表 + 互补关系”这个通用思路的源头。
这也是为什么我在带新人刷题时,总要求他们把这道题背得滚瓜烂熟,闭着眼睛能把讲解写出来。因为从它身上延伸出来的思维能力,比记十道题都有用。
5.2 踩过的坑与刷题节奏建议
讲几个我在这道题上真实踩过的坑,以及新手常见的误操作,希望能帮你节省试错时间。
第一是变量名和下标别搞混。返回的是[mapping[complement], i],不是[i, mapping[complement]]。虽然顺序无所谓,但有些读者会根据顺序来理解代码,写反了容易在复盘时把自己绕晕。我建议统一按“先小下标、后大下标”的顺序写,看着更舒服,也方便和别人讨论。
第二是数组可能为空或只有一个元素。LeetCode 原题保证有解,但很多人在写本地测试用例时,会不小心传入空数组导致 TypeError。防御性思维很重要,哪怕是刷题,也尽量处理一下len(nums) < 2的情况。
第三是极端用例:nums = [0, 4, 3, 0],target = 0。这时i = 0时 complement = 0,表里没有 0,存入;i = 3时 complement = 0,能查到下标 0,返回[0, 3]。看似没问题,但如果你把“存表”操作放在循环体最后,而“查表”放在循环体开头,顺序上的逻辑要非常清晰。必要时可以把每一步打印出来,一目了然。
刷题节奏上,我的个人建议是:一道题至少尝试两种解法(暴力 + 优化),并且亲手画一画例子,而不是直接看题解。因为看题解就像看菜谱,看懂容易,动手做又是另一回事。“两数之和”太经典,网上的题解一抓一大把,但它作为你第一道独立完成优化的题目,值得你多花一个小时去反复推导。
最后再分享一个实操小技巧:在本地用多种语言各写一遍这道题。我在 Python、Java、Go、C++ 四种语言里都实现过两数之和,做完之后你会对每种语言的哈希表 API 差异、下标系统、类型转换习惯都有非常直观的体会。这也是从“会刷题”走向“会写工程代码”的第一步。
LeetCode 的第一题,没有理由不好好吃透。它就像算法世界里的“Hello World”,写一遍太浅,写十遍太腻,但真正把它背后的思路消化掉,你会在后面遇到成百上千道题时,感受到这份基础带来的红利。