从力扣第一题说起:为什么“两数之和”必须用Map,以及一遍遍历的真正含义
每个刷力扣的人,基本都从这一题开始:Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target。中文题名叫“两数之和”,力扣编号第1题。我当年第一次做它的时候,压根没看题解,直接两层 for 循环暴力解,提交通过的那一刻还挺得意,觉得自己会写算法了。直到后来去面试,被面试官追问“这题的复杂度能不能低于 O(n²)”“为什么用哈希表能优化到 O(n)”“Map 里 key 和 value 到底存什么,方向存反会怎样”,才发现自己其实并没有真正理解这道题。
这篇文章就把“两数之和”从暴力解法到 Map 解法,从头到尾拆开讲一遍。不光是背代码,而是讲清楚:为什么这道题几乎是为哈希表量身定做的、一遍遍历的 Map 解法每一步在做什么、有哪些看起来没问题但一提交就报错的边界坑,以及从这一题能延伸出的一整类“用 Map 换时间”的题目套路。适合刚开始刷力扣的朋友,也适合准备面试、想把这个高频题讲透的读者。
1. 暴力解法的直觉与它暴露出来的效率瓶颈
1.1 先把我第一次提交的代码摆出来
两数之和的暴力解法,几乎所有入门者都能在五分钟内写出来:
java复制class Solution {
public int[] twoSum(int[] nums, int target) {
for (int i = 0; i < nums.length; i++) {
for (int j = i + 1; j < nums.length; j++) {
if (nums[i] + nums[j] == target) {
return new int[]{i, j};
}
}
}
return new int[0];
}
}
时间复杂度 O(n²),空间复杂度 O(1)。题目数据量如果很小,比如只有几十个元素,这个代码跑起来毫无压力。但力扣给它的数据范围我没记错的话是 10^4 到 10^5 量级,O(n²) 在最坏情况下就是上亿次比较,跑起来能明显感觉到延迟。更重要的是,这道题用暴力解能过,纯粹是因为题目简单,换成“三数之和”或者“四数之和”的暴力版,就直接超时了。
1.2 暴力解法到底慢在哪里
我们用一个生活化的例子来理解。假设你在一个聚会上想找一个特定的朋友,你不知道他在哪,只能挨个人问“你是某某吗”,走完全场才问到人。如果聚会里有 n 个人,你要找 m 个人,那总共要问 n×m 次。暴力解法就是这种“全场逐个找人”的模式。
具体到两数之和,问题可以重构成:对于数组里的每一个元素 nums[i],我都要在剩余数组里寻找一个“搭档”,这个搭档的值必须是 target - nums[i]。注意,这里真正的操作是“查找”——在数组里查找一个目标数值是否存在,并且还要拿到它的下标。
而线性数组的查找效率是 O(n),因为没人告诉你某个值存放在哪个位置,你只能从头扫到尾。于是每一轮外层循环都要付出 O(n) 的查找成本,外层有 n 个元素,总成本就成了 O(n²)。暴力解法慢,本质上是慢在“查找”这个动作上。
所以,这道题从暴力解法到最优解法的进化,核心思路就一条:把“在数组里查找补数”这个动作,从 O(n) 降成 O(1)。谁能做到 O(1) 查找?哈希表。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么是Map:把“满场找人”变成“按名字查通讯录”
2.1 三种查找方案的对比
想在一个集合里快速找到一个数,候选方案其实有三个:继续用数组线性查找、先排序再二分查找、用哈希表。我在学习的时候专门把这三个方案的复杂度拉了一个表:
| 方案 | 预处理成本 | 单次查找成本 | n次查找总成本 | 额外约束 |
|---|---|---|---|---|
| 数组线性扫描 | O(1) | O(n) | O(n²) | 无 |
| 排序 + 二分查找 | O(n log n) | O(log n) | O(n log n) | 需要排完序后保留原下标 |
| 哈希表 | O(n) 建表 | O(1) 平均 | O(n) | 需要额外 O(n) 空间 |
如果只追求时间最优,哈希表完胜。排序加二分虽然也能把总复杂度压到 O(n log n),但它有两个麻烦:一是二分查找要找的是补数的下标,排序会把原始下标打乱,你还得额外存一份“值 -> 下标列表”的映射;二是它比哈希表多一个排序步骤,实现起来啰嗦得多,在面试里也不是最优解。
2.2 Map 到底是怎样解决“查搭档”问题的
很多人学这一题时,第一步就卡在:为什么哈希表查找是 O(1)?这里做个最简化的解释:哈希表底层是一个数组,当你把一个键传进去时,它会用一个哈希函数算出这个键应该落在数组的哪个桶里。理想情况下,每个键对应一个固定桶位,查找时直接算出桶位、取出来就行,不需要遍历。
当然真实情况里会有哈希冲突,Java 的 HashMap 在冲突时会用链表或红黑树处理,最坏情况下查找会退化到 O(log n) 甚至 O(n)。不过工程实现里哈希函数设计得足够均匀,平均复杂度仍然接近 O(1),所以在算法题分析中我们默认哈希查找为 O(1)。
回到两数之和。我们需要一个容器,能帮我们把已经见过的数组元素存起来,供后续元素来“查询补数”。这个容器要支持两个操作:第一,把当前元素的值和下标存进去;第二,给定一个补数,能立刻告诉我这个补数是否存在,以及如果存在,它的下标是多少。
Java 里的 Map 接口正好完美对应这个需求:key 存数组元素的值,value 存这个值对应的下标。为什么把值当 key、下标当 value?因为查询的输入是“值”,我要查的是“target - nums[i] 这个值之前有没有出现过”,而查询的结果要的是“下标”。如果你把方向反了,用下标当 key、值当 value,那查询时只能遍历整个 Map 去搜值,又变回 O(n) 了。
2.3 理解“补数”这个概念是破题关键
题目给的是 target,对于元素 nums[i],它的补数就是 target - nums[i]。举个例子:数组是 [2, 7, 11, 15],target 是 9。遍历到 2 时,补数是 7;遍历到 7 时,补数是 2。很明显,当遍历到 7 这一轮时,发现 2 已经在哈希表里了,于是直接返回 2 的下标和 7 的下标。
这个“已见过的元素放进 Map,拿当前元素去 Map 里找补数”的思路,就是整道题的核心。它把“两层循环的两两配对”巧妙地变成了“只关心当前元素和它之前出现过的元素”,每个元素只需要和过去比较,不需要和未来比较,因为未来遍历到那个元素时,自然会把当前元素当成“过去”再查一遍。
3. 一遍遍历解法的完整推演与多语言实现
3.1 为什么可以边遍历边存
两数之和的 Map 解法有一个经典版本需要两次遍历:第一次把所有元素都放进 Map,第二次再逐个查补数。两次遍历版本也是正确的,但它有一个需要非常小心的细节:如果数组里正好存在某个元素,它的补数就是它自身,比如 nums = [3, 3],target = 6,Map 里存的 3 的下标是最后一个 3(因为第二次赋值覆盖了第一次),查补数时直接命中自己,返回值没问题;但如果数组是 [3, 2, 4],target = 6,遍历到第一个 3 时补数也是 3,Map 里存了 3 的下标 0,containsKey(3) 返回 true,然后 map.get(3) 返回 0,结果变成了 [0, 0],这是错的——同一个元素不能使用两次。
要解决这个问题,两次遍历版本必须在查到补数后额外判断“补数的下标不能等于当前遍历的下标”。这判断加进去倒不难,但容易忘。
而一遍遍历版本天生免疫这个问题:每次处理 nums[i] 时,当前元素还没有被放进 Map,Map 里只包含下标小于 i 的历史元素,所以查到的补数下标一定不等于 i。这就是“边查边存”这个写法的精髓——用操作顺序天然规避了“自己加自己”的边界问题。
3.2 手工推演一遍完整的执行过程
拿力扣示例来推演:nums = [2, 7, 11, 15],target = 9。
初始化一个空 HashMap。
i = 0,当前数 nums[0] = 2,补数 = 9 - 2 = 7。Map 为空,查不到 7。把 2 存进去,Map 变成 {2: 0}。
i = 1,当前数 nums[1] = 7,补数 = 9 - 7 = 2。在 Map 里查 2,命中,下标是 0。于是返回 [map.get(2), 1] = [0, 1]。
整个遍历只走了两步就返回答案,连后面的 11 和 15 都不用看了。如果 target 对应的两个数离得很远,比如第一个元素在 i = 0,搭档在最后一个位置 i = n-1,那遍历会走到最后一轮才返回,时间复杂度是 O(n),但仍然是线性的,不会出现 O(n²)。
3.3 Java 版本实现与细节
这是我最终常用的版本:
java复制class Solution {
public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> map = new HashMap<>(nums.length);
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];
}
}
几个细节值得提一下:
new HashMap<>(nums.length):我习惯预设容量大小。因为 HashMap 扩容发生在元素个数超过容量乘以负载因子时,预设容量能减少扩容次数。对算法题来说,减少扩容能省下一点时间,虽然对最终复杂度没本质影响,但这是一道讲究细节的题,面试官看到这个写法会加印象分。map.containsKey(complement)和map.put(nums[i], i)的顺序不能写反。有一个经典错误是先put(nums[i], i)再containsKey(complement),这样当前元素也被放进了 Map,如果 target 正好是当前元素的两倍,补数就会命中自己,返回 [i, i],直接判错。- 返回时索引顺序是
{map.get(complement), i},也就是先返回补数在原数组中的下标,再返回当前下标。因为补数是之前出现过的元素,它的下标一定小于 i,所以这样返回天然满足题目要求的“索引小的在前”。
3.4 C++ 和 Python 的对应写法
用 C++ 刷题的朋友,核心写法一样,用的是 unordered_map:
cpp复制class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> map;
for (int i = 0; i < nums.size(); i++) {
int complement = target - nums[i];
if (map.find(complement) != map.end()) {
return {map[complement], i};
}
map[nums[i]] = i;
}
return {};
}
};
注意 C++ 里 map 是红黑树实现,查找是 O(log n),不是 O(1),所以要实现 O(n) 的时间复杂度必须用 unordered_map,它的底层才是哈希表。这个细节经常有人搞混,面试里如果选了 std::map,复杂度分析就要跟着改成 O(n log n) 了。
Python 版本则是用字典:
python复制class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
seen = {}
for i, num in enumerate(nums):
complement = target - num
if complement in seen:
return [seen[complement], i]
seen[num] = i
return []
Python 的字典本身就是哈希表实现,complement in seen 就是 O(1) 平均复杂度的查找。这里要注意:不能用 nums.index(complement) 代替字典查下标,因为 index 方法是线性扫描,会把整个解法拖回 O(n²)。我第一次用 Python 写的时候以为 index 快,结果一分析复杂度才发现掉进了同样的坑。
4. 那些一提交就出问题的边界条件,我都替你踩过了
4.1 重复元素的坑:值相同不代表下标可以混用
题目描述里说“你可以假设每个输入只对应一种答案”,很多人看到这句话就放松警惕了,但数组里完全可能有两个相同值,比如 nums = [3, 3],target = 6。正确答案是 [0, 1]。
用一遍遍历写,执行过程是这样的:i = 0,当前数 3,补数 3,Map 为空所以查不到,把 3 放进去 {3: 0};i = 1,当前数 3,补数 3,Map 里查到 3 的下标是 0,返回 [0, 1]。没问题。
但如果把代码改成先全量 put 再二次遍历的版本,处理 [3, 3] 时就容易出现一个隐蔽问题。全量 put 后 Map 里存的 3 是被第二次覆盖后的下标 1,二次遍历到 i = 0 时补数 3 命中,get(3) = 1,返回 [0, 1],居然还是对的,因为第一个 3 的下标 0 是遍历变量提供的。但假如全量 put 后你写的是“遍历 i 时用 map.get(nums[i]) == i 来跳过自身”,那 i = 0 时 get(3) = 1 不等于 0,跳过;i = 1 时 get(3) = 1 等于 1,也跳过了,直接返回空数组。这就会漏掉正确答案。所以强行用一次遍历的边查边存写法,能省掉这一整类麻烦。
4.2 补数恰好是负数或零的情况
补数不一定是正数。比如 nums = [-1, -2, -3, -4, -5],target = -8,遍历到 -3 时,补数 = -8 - (-3) = -5,刚好是数组中存在的值。计算过程没有任何问题,因为补数逻辑对负数天然适用。
真正要注意的是“补数等于当前数但 Map 里存的不是当前数”的情况,比如 nums = [0, 4, 3, 0],target = 0。正确结果是 [0, 3],两个 0。一遍遍历执行到 i = 0,补数 0 查不到,存 {0: 0};i = 1 补数 -4 查不到,存 {4: 1};i = 2 补数 -3 查不到,存 {3: 2};i = 3,当前 0,补数 0,Map 里查到下标 0,返回 [0, 3]。正确。从这里可以看到,哈希表存 key 为 0 并不影响查询,containsKey 判断的是 key 是否存在,不是 value 是否为 null,别在 0 值上做文章。
4.3 找不到解时的返回约定
题目保证有且只有一个答案,所以代码里不会真的走进“找不到解”的分支。但力扣要求所有方法都必须有返回值,我的代码里最后写了 return new int[0],这只是一个让编译器通过的形式。实际面试时如果题目改成“找不到就返回 null”,那就直接返回 null 并和面试官确认约定。
还有一个容易被忽略的问题:力扣的编译环境对返回类型是严格检查的,Java 里 return {} 这种写法在部分版本里不行,必须 return new int[0]。这种语言层面的细节,刷题时顺手留意一下就好。
4.4 数组很大的时候,int 相加会不会溢出
两数之和的 target 和 nums 都是 int 范围,理论上 target - nums[i] 的值也一定在 int 范围内,所以 Java 里直接用 int 做减法不会溢出。但假如题目改版成“数值很大的整数”,或者 target 是 long 类型,就要考虑把补数算成 long,否则截断会导致查找错误。我刷题时习惯先把数据范围扫一眼再决定变量类型,这个习惯帮我避免过很多隐形 bug。力扣很多中等题的面经里都出现过这类“溢出才是真正考点”的情况,两数之和虽然不考,但保持警觉是对的。
5. 从第一题延伸出去的Map解题范式:两数之和不是孤立的一道题
5.1 两数之和 II、三数之和、四数之和:什么情况用Map,什么情况该用排序双指针
两数之和发散的系列题非常多,我把它们拉了一个表对比:
| 题目 | 数据结构前提 | 最优思路 | 时间复杂度 |
|---|---|---|---|
| 1. 两数之和 | 无序数组,需要下标 | 哈希表一遍遍历 | O(n) |
| 167. 两数之和 II - 输入有序数组 | 数组已经是升序 | 双指针 | O(n),O(1) 空间 |
| 15. 三数之和 | 无序数组 | 排序 + 双指针 | O(n²) |
| 18. 四数之和 | 无序数组 | 排序 + 双指针 + 剪枝 | O(n³) |
| 560. 和为 K 的子数组 | 无序数组 | 前缀和 + 哈希表 | O(n) |
从这张表里能读出一个规律:如果要返回的是下标,而且存在重复值、顺序被打乱过,哈希表是首选;如果题目允许排序且不要求返回原始下标,或者数组本身有序,那双指针通常能省掉哈希表的额外空间。
三数之和如果硬用 Map 思路,会非常别扭:先固定一个数,剩下的两个数用哈希表找,去重逻辑会写到你怀疑人生。而排序加双指针通过移动左右指针自然避开了重复组合。所以学两数之和不能只背一个 Map 解法,要把“什么时候该用 Map、什么时候该换双指针”一起搞清楚。
5.2 前缀和 + Map:两数之和的隐藏进阶玩法
力扣 560 题“和为 K 的子数组”是很多人在完成两数之和后遇到的第一个“看起来完全不像,但内核高度相似”的题目。它要求统计数组中和为 k 的连续子数组个数。暴力解需要枚举所有子数组的起止位置,O(n²)。而优化思路是把“连续子数组的和”转换为“前缀和之差”:如果两个不同位置的前缀和之差等于 k,那么这两个位置之间就存在一个和为 k 的子数组。
于是问题就变成了:对于当前前缀和 currentSum,是否存在一个历史前缀和等于 currentSum - k。这跟两数之和里“是否存在一个历史值等于 target - nums[i]”完全同构。区别只在于 Map 从“存数值 -> 下标”变成了“存前缀和 -> 出现次数”。
我第一次做 560 时,用“两数之和的 Map 心法”套了上去,十分钟就写出来了。这就是第一题真正要学到的东西——不是记住那个解法,而是建立起“当你需要快速判断某个数之前是否出现过时,就想到哈希表”的反射。
5.3 其他高频 Map 应用场景快速梳理
两数之和延伸出的 Map 题还有不少,比如:
- 字母异位词分组(力扣 49):把每个字符串的字符计数数组转成 key,或者直接把排序后的字符串作为 key,Map 的 key 设计在这里成了核心考点。
- 最长和谐子序列(力扣 594):用 Map 统计每个数字出现的次数,然后检查相邻数字出现的次数和。
- 多数元素(力扣 169):可以用 Map 统计每个元素出现次数,也可以摩尔投票,两种思路各有优劣。
这些题说到底是同一个思维模型:Map 的价值不只是存储,而是为“按值查询”“按值聚合”提供 O(1) 能力。 两数之和是这个思维最朴素、最经典的展示舞台。
6. 面试追问与工程视角:把这题讲深,才算真正会用 Map
6.1 面试官常问的连环追问
两数之和在面试里出现频率极高。很多候选人能默写代码,但面试官一问深就露馅。我整理几个真实的追问方向:
- 如果数组是递增有序的,能不能优化到 O(1) 空间?可以,用双指针,左指针指向头部、右指针指向尾部,如果两数之和小于 target,左指针右移;如果大于 target,右指针左移。这样空间复杂度直接变成 O(1)。这类题就是力扣 167。
- 为什么 HashMap 的查找是 O(1)?底层怎么解决哈希冲突?需要说出哈希函数、桶数组、链表/红黑树这些关键词,并说明平均情况与最坏情况的区别。
- 如果内存非常小,数组巨大,无法一次性把 Map 放进内存,怎么办?这是工程场景下的开放性追问。可以回答先外部排序或分块处理,再通过多轮的哈希分片解决,核心思想是分布式/外部化哈希,而不是直接在内存里建一个大 Map。
- 如果返回值要求不是下标,而是所有满足条件的数对、且要去重?那思路就变了,通常需要排序加双指针,配合跳过重复元素的逻辑。
我建议任何一个想认真准备面试的人都把这四个追问自己先过一遍,能不看资料说清楚,比多刷十道简单题都管用。
6.2 初始化容量背后的 HashMap 扩容机制
我在第 3 节的代码里写了 new HashMap<>(nums.length),面试时如果被问“为什么要预分配容量”,就得解释 HashMap 扩容机制。简化来说,HashMap 有两个关键参数:初始容量和负载因子,默认负载因子是 0.75。当元素数量超过容量 × 0.75 时会触发扩容,扩容要重新计算所有已有元素的哈希值并把它们搬进新的桶数组,这个过程的时间成本是 O(n)。如果预先知道要放 n 个元素,直接指定容量为 n 或更大,就能避免中途扩容,让建表过程接近 O(n)。
这个优化对算法题本身不关键——因为反正总体是 O(n)——但它体现了对底层数据结构工作原理的理解。我刷题时见过不少人写两数之和不设容量,也不会被判错,但面试场景下一句“我初始化容量是为了避免扩容”能明显拉开差距。
6.3 空间换时间不是免费的午餐
Map 解法把时间复杂度从 O(n²) 降到 O(n),代价是空间复杂度从 O(1) 变成 O(n)。在算法题里这通常值得,因为现代机器的内存相对宽裕,但工程上不总是如此。
举个实际例子:如果你在一个高并发的服务里处理请求参数配对,每个请求都创建一个很大的 HashMap 再丢弃,GC 压力和内存水位会明显上升。这时候可能更适合先排序再用双指针,牺牲一点时间换空间,或者控制单个 Map 的大小、用 LRU 之类的策略限制容器增长。这就是为什么不能所有场景都无脑上 Map——两数之和的 Map 解法是一道好题教会你的“第一层思维”,但真正的工程师思维是知道何时使用这层思维、何时该放弃它。
我在团队里带新人的时候,第一周不会让他们直接上手业务代码,而是让他们把力扣第 1 题用三种方式各写一遍:暴力、哈希、有序数组下的双指针。写完再问自己一个问题:如果数据变成十万、百万、千万级别,分别会怎样?能把这道题分析到这个程度,后续再刷哈希表相关的题目都会轻松很多。这也是我把两数之和反复讲给别人的原因——它表面上只是一道简单题,但里面藏着的复杂度分析能力、数据结构选型能力和边界条件意识,是算法功底真正的起点。
