1. 先把这个题目的真实需求拆开看
看到“两数之和≠两数相加”这句话时,我的第一反应是愣了一下:两数之和怎么会不等于两数相加?可如果你把“两数之和”理解为LeetCode第1题Two Sum,那它还真的不等于。
题目名字确实叫“两数之和”,但它从输入到输出,都和“输入两个数字然后算出和”是两码事。很多刚接触算法的人,一看到这个标题,下意识就以为这是一道加法题,甚至有人真在编辑器里写了个 return a + b,然后就不知道怎么继续了。这个段子听起来离谱,实际在刷题群里却经常出现。这道题真正考的不是“求和”,而是在一个数组里找到两个数,让它们的和等于给定的目标值,再把它们的数组下标返回出来。也就是说,目标值反而是条件,你要交出的答案是“位置”。
一道算法题能不能快速做出来,很大程度取决于有没有把题目需求拆干净。我这里先做一次完整的字段级解析,你看完就知道为什么“两数之和≠两数相加”不是句废话。
1.1 原题到底让你返回什么
原题描述我直接放到这儿:
给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出和为目标值 target 的那两个整数,并返回它们的数组下标。
假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
样例是这样的:
- 输入:nums = [2,7,11,15], target = 9
- 输出:[0,1]
- 解释:因为 nums[0] + nums[1] == 9,所以返回 0 和 1。
如果你把它看作“两数相加”,输入应该是 2 和 7,输出应该是 9。但原题输入是一整个数组加一个目标值,输出是一个包含两个下标的数组。输入输出完全对不上,所以它当然不是普通加法。
真正见过这道题的人会知道,许多平台把函数签名已经定死了,比如在Java里是:
java复制public int[] twoSum(int[] nums, int target)
这个方法接收的可不是两个数字,而是“一个数组”和“一个目标值”。从接口这一层就能看出来,代码的任务是到数组里去查下标,而不是算结果。可惜不少新人刚做题时不看方法签名,只盯着题目名,结果第一步就走偏了。
1.2 返回下标这件事,为什么这么关键
返回下标,意味着我们关心的不是“那两个数有多大”,而是“那两个数长在哪个位置”。这在真实开发里太常见了,绝大多数时候你并不需要再算一遍金额总和,你想知道的是:哪些订单、哪些记录、哪些商品的金额能凑成一个目标数。系统返回的是主键ID、行号、唯一标识,而不是拼好的sum。
所以这道题设计的本质是“检索配对”而不是“计算结果”。你从头到尾要维护的是位置映射,不是数值累加。这个点想通了,你自然会理解为什么最优解要用哈希表存“数值到下标”的映射,而不是只存“出现过哪些数字”。因为如果你只需要确认“二加七等于九”,那用一个Set记录已经出现的数字就行;可题目要下标,你就必须把“数字最后一次出现在哪个位置”也一起记下来。
1.3 “唯一答案”和“同一元素不能重复使用”是什么意思
这道题提前告诉你:每种输入只会对应一个答案。这是好事,省去了处理多组答案的麻烦。你可以放心地“找到一组就直接返回”,不需要再把所有可能组合都搜出来。
容易懵的是后半句:“数组中同一个元素在答案里不能重复出现”。这个约束很关键,举例最好理解。假设 nums = [3, 3],target = 6,答案应该返回 [0, 1],因为数组里有两个“3”,它们各用一次,是合理的。但假设 nums = [3],target = 6,你不可能把唯一的3用两次,所以不能返回 [0, 0]。
后面写代码时,这个约束直接决定了一个细节:如果当前元素等于补数,你得确保补数是“之前出现过的另一个位置”,而不是当前元素自己。这也是很多人刷这道题时最容易踩的坑,我在后面会专门展开讲。
把题目字段拆成这样之后,你就可以开始设计解法了。如果只盯着“两数相加”这四个字,你连正确入口都找不到;但只要你把“数组、目标、下标、唯一解、不可重复使用”这几个条件摆出来,整道题的路径就非常清楚了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法:两两配对背后的复杂度账本
很多教程一上来就讲哈希表,搞得好像暴力解法毫无价值。我不这么看。暴力解法是算法题里最重要的“基准答案”,它虽然慢,但逻辑最简单,很难写错,还可以用来验证优化解法是不是正确。完全跳过暴力解直接背最优解的人,遇到稍有不同的变题反而容易翻车。
我们先从暴力解法出发,把复杂度这笔账算清楚,你就知道为什么要优化,以及优化到底优化在了哪里。
2.1 最简单的做法:枚举所有数对
既然要在数组里找两个数,让它们的和等于 target,那最笨也最直接的办法就是枚举所有“两个下标”的组合,看哪个组合加起来的和正好等于 target。
Python版本可以这样写:
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 []
注意内层循环从 i + 1 开始,不是从 0 开始。原因有两个:一是避免同一个元素自己加自己,比如返回 [0, 0] 这种非法结果;二是避免重复检查同一对数,比如 [0, 1] 和 [1, 0] 实际上是同一个组合,没必要查两遍。
这段代码谁都能看懂,运行结果也正确。如果数组长度为3,它最多比较3次;长度为10,最多比较45次;看起来好像挺快的。可一旦数组长度上升到十万级别,事情就失控了。
2.2 10万个数要比较多少次:50亿次
我们前面用了一个组合数:从 n 个数里选 2 个,一共有
C(n, 2) = n * (n - 1) / 2
种组合。代码里的两层循环,最坏情况下就是把所有组合都检查一遍,所以比较次数就是这个数。
不同数据规模下的感受差异,可以看这张表:
| 数组长度 n | 比较次数 n(n-1)/2 | 直观感受 |
|---|---|---|
| 100 | 4950 | 忽略不计 |
| 1000 | 约 50 万 | 还可以接受 |
| 10000 | 约 5000 万 | 开始有明显耗时 |
| 100000 | 约 50 亿 | 多数在线评测直接超时 |
在常见在线评测系统里,一秒大概能执行的简单操作上限通常是 10^8 量级或更低。50亿次比较显然已经远远超过了。也就是说,暴力解法在 n 稍微大一点时,跑完整个测试用例可能要几十秒。而算法题的设计标准往往要求你在更短的时间里通过全部隐藏用例,所以它绝对过不了。
但暴力解法的空间复杂度是 O(1),也就是除了原数组以外,几乎不占额外内存。这个特征在特定场景下是优点。比如数组非常短、或者只需要离线跑一次,直接用暴力法往往比构建一张哈希表更省事。
2.3 先排序再用双指针,为什么绕远路
很多人学了两个指针之后,会觉得“我可以先排序,然后用左右指针夹逼”,于是写出这样的思路:
- 拷贝一份带原始下标的数组,或者把下标和数据装进对象;
- 按值排序;
- 左指针指向最左,右指针指向最右,根据和与 target 的大小关系移动指针,直到找到答案。
如果题目只问“存不存在两个数,它们的和等于 target”,这个思路确实很好,时间复杂度 O(n log n),空间复杂度 O(1)。可这里还有一个硬性要求:返回原始下标。
一旦排序,原始下标就被打乱了。为了最终能返回下标,你必须在排序前把下标和数据绑定在一起,额外存一份“数据 + 原始下标”的结构。排序本身又要付出额外的内存和比较成本。这样折腾一圈,代码变长,复杂度也不是最优,哈希表方案一下就清爽得多。
这不是说排序双指针不对,而是说它在这道题里属于“能解但不够贴题”的方案。算法题选型,讲究的是根据题目约束选最顺手的工具。哈希表用 O(n) 空间换来 O(n) 时间,对这道题来说才是性价比最高的路线。
2.4 暴力解法真正的价值:当基准验证
我在自己刷题的时候,经常会先写一个暴力解法,再去写优化解法。写好优化解法后,不急着提交,先用手写的小数组把两种解法跑一遍,对比结果。只要暴力解和优化解在小样例上输出一致,再提交时心态就稳很多。
所以千万别觉得暴力解是“丢人”的答案。面试时如果你能先说清楚暴力解法,再提出“每次都要回头扫描已走过的元素,存在重复工作”,然后引出哈希表,这反而是很漂亮的解题过程。面试官想看到的就是这种从坏到好、逐步分析优化的思路。
3. 哈希表的出现:用空间换一次O(n)遍历
暴力解法慢在哪里?慢在“重复扫描”上。假设当前检查的是第 i 个元素,为了确认前面有没有一个数能和它凑成 target,你得在它前面重新遍历一遍。每一次都从头看起,每一次都在重复劳动。如果能把“我前面已经遇到过哪些数”记录下来,那“检查”这一步就能从“扫描”变成“查询”,时间成本大幅下降。
哈希表就是干这个的。它用一个键值对结构,把“数值”映射成“下标”,让我们在遍历数组的同时,用一次 O(1) 级别的查找,判断当前元素需要的“另一半”是否已经出现过。
3.1 把“向后猜”改成“向前查”
我们对当前数字 nums[i] 定义一个变量:
complement = target - nums[i]
也就是补数。我们要找的东西其实很明确:前面有没有一个数字等于 complement。如果有,那么 nums[i] 和那个数字就是一对答案。
暴力法的做法是:拿着 complement 回到已经走过的区域里,逐个比对。哈希表的做法是:每次走过一个数字,就把它登记到“账本”上;当前数字想知道 complement 出没出现过,只要翻一翻账本即可。
我们可以把哈希表想象成“门牌号登记册”。你手里拿着一张纸条,上面写着要找一个门牌号对应的主人。你不需要从街头挨家挨户敲门问,只需要到门卫那里查一下登记册,册子上早就记录了每个门牌号住的是谁。哈希表就是这本册子,它把“查找”从全量遍历变成了查目录。
3.2 哈希表为什么能快到 O(1)
哈希表内部并不像数组那样按下标连续排列,它是把 key 通过哈希函数换算成一个存储位置。哈希函数算出来的位置,和 key 本身有关,所以查找时不需要逐个比较,直接定位到目标区域。发生哈希碰撞时,底层会通过链表或红黑树处理,极端情况下会退化成 O(n),但工程实现的哈希函数都经过精心设计,平均复杂度就是 O(1)。
这也是为什么“数组里找两个数”这类问题,特别适合用哈希表来优化。它把最耗时的“线性查找”变成了近乎常数的“直接寻址”,代价是额外多占一份内存。这叫空间换时间,在算法题里是非常典型且划算的交换。
3.3 核心代码:先查再存,能避开最大的坑
下面我给出最适合作为标准答案的写法。
Python:
python复制def two_sum(nums, target):
seen = {}
for i, num in enumerate(nums):
complement = target - num
if complement in seen:
return [seen[complement], i]
seen[num] = i
return []
Java:
java复制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];
}
很多初学版本会把 seen[num] = i 写在判断前面,也就是“先把当前位置存进去,再查补数”。比如这样:
python复制seen = {}
for i, num in enumerate(nums):
seen[num] = i
if target - num in seen:
return [seen[target - num], i]
这个顺序在绝大多数情况下也能通过测试,但有一个经典反例:
- nums = [3, 2, 4]
- target = 6
正确答案是 [1, 2],因为 nums[1] + nums[2] = 2 + 4 = 6。但如果先把 3 存进去,那么循环到下标 0 时,complement = 6 - 3 = 3,而 3 刚刚已经被存进哈希表了,于是代码会认为“前面已经存在一个 3”,返回 [0, 0]。
这就掉进了“同一个元素不能被重复使用”的陷阱。正确的做法必须是:先查补数,查不到,再把当前数字存进去。这样当前元素永远不会成为自己的补数,因为它在查询的那一刻还不存在于哈希表中。这个细节虽然只有一行代码的位置差别,却直接影响答案是否合法。
3.4 两个重复数字的情况会自动处理吗
再看一个例子:nums = [3, 3],target = 6,预期答案是 [0, 1]。
用上面的正确代码模拟一遍:
- i = 0,num = 3,complement = 3,哈希表为空,查不到;把 3 存入,seen 变成
- i = 1,num = 3,complement = 3,哈希表里已经有 key 为 3 的记录,值为 0;查到了,返回 [0, 1]
注意,这里返回的不是 [1, 1],因为第一次遇到 3 时并没有把它自己存成自己的匹配对象。第二次遇到 3 时,哈希表里存的是第一次出现在下标 0 的那个 3,所以它代表的是“另一个元素”,不是当前元素自己。
有些读者可能会想,如果数组是 [3, 3, 3],答案会不会错?题目假设了只有一种答案,实际测试不会给你制造这种多答案的混乱局面。但即便遇到多个重复,正确代码返回的一定是“先遇到的两个互补位置之一”,仍然合法。
这也解释了为什么哈希表的 value 要存下标而不是只存布尔值:如果只用一个 Set 记录“见没见过”,两个 3 的情况你确实能判断“碰到过 3”,但无法拿到第一次出现的下标,题目就答不完整。
4. 把两数之和的思维搬到工程里:一次遍历建索引
算法题写多了,最怕的就是变成做题机器:题会解,但不知道它和日常工作有什么关系。其实两数之和的思想,几乎每天都能在业务代码里见到。它本质上是在做一件事:把重复查找的数据提前放到一个可以根据 key 直接定位的结构里,用一次预处理减少后续查询的复杂度。
4.1 一个最典型的业务场景:订单匹配商品
假设你在做电商后台的导出功能。导出的订单列表里每行都有一个 productId,但订单表里不存商品名称,商品名称在另一张商品表里。你的任务是给每一笔订单填上商品名称。
低效写法很容易长成下面这样:
python复制for order in orders:
for product in products:
if order["product_id"] == product["id"]:
order["product_name"] = product["name"]
订单有 1 万条,商品有 10 万条,最坏情况下要比较 10 亿次。这个代码一旦上线,接口必然慢得让人抓狂。
换个写法,先把商品列表转换成一个以 id 为 key、商品信息为 value 的字典:
python复制product_map = {p["id"]: p for p in products}
for order in orders:
product = product_map.get(order["product_id"])
if product:
order["product_name"] = product["name"]
第二个版本只需要一次商品列表遍历建立哈希表,再遍历订单时每次按 id 查询都是 O(1),整体复杂度从 O(n*m) 降到了 O(n+m)。如果你看到这里觉得眼熟,那就对了——这和两数之和的哈希表解法几乎是同一个
