说起 LeetCode 热题100,绝大多数人的刷题生涯都是从这本“册子”开始,而它的第一题,就是 1. 两数之和。这道题的知名度,基本等同于编程界的 Hello World:几乎所有准备算法面试的人都会刷到它,甚至很多不刷题的人也在面试里被问过。但你别看它标着“简单”难度,这题背后的门道一点不少——暴力的写法两分钟能写完,但真正让面试官眼前一亮、让后续刷题越来越顺的核心,恰恰是这道题第一次逼你做出“用空间换时间”的思维转变。今天我就把这题彻底拆开,从最直白的暴力解,到最优的 Hash 解,再到面试官常见的追问和变体,一次性讲清楚。
如果你是刚开始刷 LeetCode 的新手,这篇能帮你把第一题吃透;如果你是准备面试、想从头理清热题100路线的同学,这题也是你建立“哈希表查补数”思路的起点。文章里我会用 C++ 做主要演示,同时给出 Python 和 Java 的关键写法,并把我自己踩过的坑、实测过的性能差异都写出来。
1. 题目到底在说什么:先读懂约束,再动手写代码
1.1 题面逐句拆解
题目原文很短:给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出和为目标值 target 的那两个整数,并返回它们的数组下标。
注意几个容易被忽略的信息:
- 输入是“整数数组”和“一个整数 target”,没有说数组有序,也没有说全是正数。
- 输出是“两个整数的下标”,不是数值本身。这点很重要,很多解法上的取舍都源于“要下标”这个约束。
- 题目通常假设“每种输入只会对应一个答案”,也就是结果唯一,不需要处理多个解。
- “数组中同一个元素在答案里不能重复出现”,意思是不能拿同一个下标自己加自己。
以示例来看:
text复制输入:nums = [2,7,11,15], target = 9
输出:[0,1]
解释:因为 nums[0] + nums[1] == 9,返回 [0, 1]
这里返回顺序没有要求,[1,0] 也算正确。
1.2 先理清这五个关键信息
我把这道题的解题前提整理成一张表,刷题时养成先列信息再动手的习惯:
| 信息项 | 内容 | 对解法的约束 |
|---|---|---|
| 数组是否有序 | 未说明 | 不能用直接对撞双指针(除非先排序) |
| 返回内容 | 下标 | 排序可能破坏下标,需额外保存 |
| 解是否唯一 | 唯一 | 命中即可返回,不用收集所有组合 |
| 元素是否可以为负 | 可以 | 不能用数值大小剪枝 |
| 数据规模 | 未明确给出 | 暴力可能在超大用例下超时 |
我自己的习惯是:拿到任何算法题,先花 30 秒在草稿纸上把这几个信息列出来,再开始想解法。很多人一上来就写代码,写到一半发现返回类型理解错了,或者没考虑负数,反而浪费时间。
1.3 不看数据范围就写代码,是要吃大亏的
LeetCode 原题在老版本里没有明确给出 nums 长度范围,现在补充了 2 <= nums.length <= 10^4 左右(具体以提交页为准)。这是什么概念?如果数组长度是 1 万,暴力两层循环差不多要执行 5000 万次加法比较,Java/C++ 勉强能扛,Python 就明显吃力了;如果长度再往上到 10 万,暴力基本就废了。
所以这题虽然标着“简单”,但如果你只会暴力解,在面试里大概率是过不了关的。面试官要看的不是你能不能做出来,而是你有没有“当数据变大时,算法是否依然成立”的工程敏感度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 思路演进:从暴力到哈希,为什么这是核心考点
2.1 暴力法:最直白,但只能作为兜底
暴力法的思路非常简单:固定第一个数,遍历它后面的所有数,看看两数之和是否等于 target。
cpp复制class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
int n = nums.size();
for (int i = 0; i < n; ++i) {
for (int j = i + 1; j < n; ++j) {
if (nums[i] + nums[j] == target) {
return {i, j};
}
}
}
return {};
}
};
这里有个小细节:内层循环 j 从 i + 1 开始,而不是从 0 开始,既避免了下标重复,又减少了一半计算量。如果你写 j = 0,那拿同一个元素自己加自己,是有可能误判的,比如 nums = [3,3], target = 6,[0,0] 会被当成答案,但实际上人家要的是 [0,1]。
时间复杂度是 O(n²),空间复杂度是 O(1)。这个解法能过题,但在“热题100”里属于最低配版本。它存在的意义就是让你明白:如果不做任何信息存储,就只能反复扫描,而反复扫描就是慢的根源。
2.2 两遍哈希:先存起来,再回头查找
暴力慢在哪里?慢在每次都要从头扫一遍去找“补数”。那很自然的想法就是:我第一次遍历的时候,把这些数都记下来,下次要查补数时直接取。
这里引入一个关键概念:补数。所谓补数,就是 target - nums[i]。题目本质上是在问:对于每一个元素 nums[i],数组里是否存在它的补数。
用哈希表存“数值 -> 下标”的映射,然后分两遍走:
cpp复制class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> hash;
int n = nums.size();
for (int i = 0; i < n; ++i) {
hash[nums[i]] = i;
}
for (int i = 0; i < n; ++i) {
int complement = target - nums[i];
if (hash.count(complement) && hash[complement] != i) {
return {i, hash[complement]};
}
}
return {};
}
};
注意这里有个容易踩的坑:第二次遍历时,必须判断 hash[complement] != i。为什么?因为 nums = [3,3], target = 6 时,第一遍哈希表里 hash[3] 会被后面那个 3 覆盖成下标 1。然后第二遍遍历,i = 0 时查补数 3,发现 hash[3] = 1,下标不相等,正确返回 [0,1]。但如果数组里只有一个 3,i = 0 时 hash[3] == 0,此时如果不加判断,就会错误返回 [0,0]——同一个元素被用了两次。
时间复杂度 O(n),空间复杂度 O(n)。这就是经典的“空间换时间”。
2.3 一遍哈希:边查边存,代码更优雅
两遍哈希已经很不错了,但还能优化:能不能一遍遍历就搞定?可以。我们不需要等所有数据都进哈希表再查找,而是每遍历到一个元素,就先看看它的补数在不在已处理过的元素里;如果在,直接返回;如果不在,把当前元素存进哈希表,继续往后走。
cpp复制class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> hash;
for (int i = 0; i < nums.size(); ++i) {
int complement = target - nums[i];
if (hash.find(complement) != hash.end()) {
return {hash[complement], i};
}
hash[nums[i]] = i;
}
return {};
}
};
这个写法最大的好处是可以少一次遍历,而且逻辑上天然规避了“自己配自己”的问题。因为我们是先查找再插入,当你遍历到下标 i 时,哈希表里存的都是下标小于 i 的元素,不可能包含当前元素本身。
时间复杂度同样是 O(n),但实际执行时会比两遍哈希少跑一轮,leetcode 实测大概能快 10%~20%。空间复杂度 O(n)。
2.4 三种解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 | 评价 |
|---|---|---|---|---|
| 暴力枚举 | O(n²) | O(1) | 数据量极小 | 思路最直白,面试兜底 |
| 两遍哈希 | O(n) | O(n) | 任意数据 | 容易理解,注意下标判断 |
| 一遍哈希 | O(n) | O(n) | 任意数据 | 推荐写法,综合最优 |
我个人的刷题标准是:能写出一遍哈希,才算真正掌握了这题的考点。暴力解只是“会做”,哈希解才是“理解”。
3. 代码实现与细节打磨:从能跑到跑得快
3.1 C++ 题解完整演示
很多人刷题喜欢直接背模板,我不太建议。最好是自己手动跑一遍数据流,确认每一步在做什么。以 nums = [2,7,11,15], target = 9 为例,一遍哈希的执行过程如下:
text复制i = 0,nums[i] = 2,补数 = 9 - 2 = 7,哈希表为空,未命中,存入 {2:0}
i = 1,nums[i] = 7,补数 = 9 - 7 = 2,哈希表中找到了 key=2,value=0,返回 [0,1]
如果示例换成 nums = [3,2,4], target = 6:
text复制i = 0,nums[i] = 3,补数 = 3,哈希表为空,未命中,存入 {3:0}
i = 1,nums[i] = 2,补数 = 4,哈希表没有 4,存入 {2:1}
i = 2,nums[i] = 4,补数 = 2,哈希表中找到了 key=2,value=1,返回 [1,2]
这个手推过程一定要自己走一遍,比看十遍代码都有用。
3.2 为什么选择 unordered_map 而不是 map
在 C++ 里,哈希表对应两个容器:map 和 unordered_map。很多新手会混淆。
map 底层是红黑树,元素按键有序排列,插入和查找的时间复杂度是 O(log n);unordered_map 底层是哈希表,平均 O(1) 查找。对于这道题,我们不需要有序性,只需要快速按键找值,所以 unordered_map 是更合适的选择。
但注意一个工程细节:unordered_map 的 O(1) 是“平均”复杂度。一旦发生大量哈希冲突,查找效率可能退化到 O(n)。LeetCode 常规测试数据不会专门构造冲突来卡你,但在生产环境里,如果要处理海量 key,需要考虑自定义更好的哈希函数,或者改用更稳定的数据结构。
3.3 一个容易被忽略的优化:提前 reserve 容量
unordered_map 在插入元素时,如果元素数量超过负载因子(默认 1.0)就会触发 rehash,也就是重新分配桶数组并重新计算所有已有元素的哈希值。这个操作是 O(n) 的,在数据量大时会拖慢整体速度。
我们可以提前预估元素数量并预留空间:
cpp复制class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> hash;
hash.reserve(nums.size() * 2);
// 剩下逻辑不变
}
};
为什么乘 2?因为 unordered_map 的 bucket 数量通常会大于元素数量,预留两倍空间可以减少负载因子逼近阈值时造成的 rehash。我实测过:在数组长度 10 万级别的数据上,加不加 reserve 的性能差距可以达到 30% 以上。刷题时数据量不大可能感知不强,但这个习惯建议养成。
3.4 Python 和 Java 的对应写法
Python 版本,用字典:
python复制class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
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 []
Java 版本:
java复制class Solution {
public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> hashMap = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
int complement = target - nums[i];
if (hashMap.containsKey(complement)) {
return new int[] {hashMap.get(complement), i};
}
hashMap.put(nums[i], i);
}
return new int[] {};
}
}
三种语言的核心逻辑完全一致:先查补数,再存入当前元素。换成哪种语言都一样,理解了思路,语法只是皮囊。
4. 最容易踩的坑:这些细节面试官一眼就能看出来
4.1 重复元素与覆盖问题
最经典的例子是 nums = [3,3], target = 6。如果你用的是两遍哈希,第一遍存入时后一个 3 会覆盖前一个 3 的下标,哈希表最终变成 {3:1}。第二次遍历到 i = 0 时,查补数 3,得到下标 1,不等于 0,所以正确返回 [0,1]。
这里要理解“覆盖不影响答案”背后的原因:题目保证解唯一,而且一组解里的两个元素是不同的下标。两个相同的 3 都能配对成功,说明数组中至少有两个 3;覆盖掉第一个下标,第二个下标依然能和第一个元素配对。但如果数组中只有一个 3,那补数 3 就不会命中,逻辑不会误报。
如果你用一遍哈希,这个问题更简单:遍历到第二个 3 时,哈希表里已经存了第一个 3,查补数直接命中,根本轮不到覆盖。这也是我推荐一遍哈希的原因之一。
4.2 返回顺序不匹配
有的写法返回 {i, hash[complement]},有的返回 {hash[complement], i}。LeetCode 不要求顺序,但面笔试时你要跟面试官确认,或者保持一致。比如一遍哈希里,hash[complement] 是之前存的下标,一定小于当前 i,所以通常写成 {hash[complement], i} 更符合“先出现先返回”的直觉。
4.3 空数组、单元素数组和无解情况
题目限制 nums 至少有两个元素,但实际工程中还是要考虑极端情况。我一般会在函数末尾 return {} 或返回空数组作为兜底。有些语言不允许返回 null,所以这里的兜底逻辑也算约定俗成。
4.4 大整数溢出问题
target - nums[i] 理论上可能溢出吗?在 C++ 中,int 的范围是 -2147483648 到 2147483647,如果 target 是 2147483647,而 nums[i] 是 -1000000000,那补数是 3147483647,超过了 int 最大值。这时候用 long long 计算更稳妥。LeetCode 出题时通常会避开这种边界,但面试官可能主动问,提前想到会加分。
4.5 不要用数组值当 key 的时候丢了重复值
还有一类错误写法是先把所有元素放进哈希表,然后用“如果 hash[complement] 存在就返回”。这个写法的问题是当重复元素参与配对时下标覆盖可能导致错误。处理办法就是前面两遍哈希里的 hash[complement] != i 判断。总之,一遍哈希从根源上避免了这类问题,我的经验是能写一遍就写一遍。
5. 面试官想听什么:这道题背后的追问与变体
5.1 如果数组是有序的
面试官最喜欢追问的第一句话是:“如果输入数组是有序的,你能优化吗?”这时候哈希表反而成了次优解,因为有序数组可以用双指针:
cpp复制class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
while (left < right) {
int sum = nums[left] + nums[right];
if (sum == target) {
return {left, right};
} else if (sum < target) {
++left;
} else {
--right;
}
}
return {};
}
};
时间复杂度 O(n),空间复杂度 O(1)。比哈希表省了空间,而且思路更直接。如果你能把暴力、哈希、双指针三种方案都讲出来,面试官会认为你是真懂,而不是背题。
5.2 如果要求返回数值而不是下标
很多变体题只要求判断“是否存在两个数之和等于 target”,不要求下标。这时候可以先把数组排序,再用双指针。即使不能用双指针,也可以直接排序后二分查找补数。如果要保留下标信息,就构造“(值, 下标)”的 pair 数组再排序。
cpp复制class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
vector<pair<int, int>> numIdx;
for (int i = 0; i < nums.size(); ++i) {
numIdx.push_back({nums[i], i});
}
sort(numIdx.begin(), numIdx.end());
int left = 0, right = numIdx.size() - 1;
while (left < right) {
int sum = numIdx[left].first + numIdx[right].first;
if (sum == target) {
return {numIdx[left].second, numIdx[right].second};
} else if (sum < target) {
++left;
} else {
--right;
}
}
return {};
}
};
排序的时间复杂度 O(n log n),空间复杂度 O(n)。在不能使用额外线性空间的场景,这是一种折中方案。
5.3 如果有多个答案,怎么办
原题保证了唯一答案,但变体题可能要求“输出所有不重复的组合”。这时候哈希表依然可以做,但要去重。一般套路是先排序,然后用双指针固定首元素,再去重跳过相同值。这个思路就是 LeetCode 15 题“三数之和”的前置基础。
5.4 设计一个支持频繁调用的两数之和类
如果只会一次性查询,思路很简单。但如果面试题变成“设计一个类,支持 add 和 find 两个操作,find 判断当前集合里是否存在两个数的和为 target”,那就要重新设计了。简单做法是维护一个哈希表记录每个数出现的次数:
add(num):O(1) 将count[num]++find(target):遍历哈希表的 key,判断target - key是否存在,且当target - key == key时,要确认出现次数是否大于等于 2
这类变体在面试中很常见,尤其是偏系统设计的公司题库里。
5.5 从两数之和到更复杂的问题
热题100里和这题思路直接相关的还有 15. 三数之和、18. 四数之和、167. 两数之和 II。你刷完这题后,可以顺路把 167 题做了,因为它的最优解就是双指针;接着做 15 题,会让你对“排序 + 双指针 + 去重”组合有更深的体感。哈希的应用也不止“查补数”,遇到 128. 最长连续序列、242. 有效的字母异位词这类题,哈希也都是核心工具。
6. 刷完这题之后:复盘与后续刷题建议
6.1 这道题真正值得带走的是什么
我的答案是两点:一是“枚举所有组合”和“记录信息避免重复扫描”的本质差别;二是“查补数”的哈希思想。
暴力解 O(n²) 的本质是:每处理一个数,都要重新遍历数组。而哈希解 O(n) 的本质是:把“遍历查找”变成了“哈希表查询”。这个思想的迁移范围极广:两数之和、三数之和、最长连续序列、数组中重复的数字、字母异位词分组……本质上都是“把遍历过的信息存起来,避免重复计算”。
6.2 我自己刷这道题的一些经验
当初我第一次刷这题时,也是先写了暴力,然后去讨论区看到一行哈希解,心里想:“还能这样?”这个“还能这样”的瞬间,就是算法水平开始提升的关键节点。所以我建议你在做每道题时,写完后去题解区至少看两三种不同思路,不要满足于 AC 本身。
平时练习时,我还会做一件事:把题目数据范围在脑内放大,比如把 nums 长度改成 100 万,再去想我的解法还能不能跑。如果不行,说明我可能只掌握了“能过题”的写法,没掌握“抗住大数据”的写法。
6.3 热题100刷题路线怎么规划
热题100是几乎所有人推荐的刷题清单,但直接从头到尾刷很容易卡死。我的建议是按专题切分:数组与哈希(1. 两数之和、49. 字母异位词分组、128. 最长连续序列)→ 双指针(11. 盛最多水的容器、15. 三数之和)→ 滑动窗口(3. 无重复字符的最长子串、438. 找到字符串中所有字母异位词)→ 链表、二叉树、回溯、动态规划,逐类攻克。这样知识关联性强,每道新题都是旧题思路的延伸,刷起来不会太痛苦。
最后再分享一个我自己坚持了很久的习惯:刷题不追求数量,追求复盘。每道题 AC 之后,我会在代码边上写两句话——一句是“这题的考点是什么”,另一句是“如果数据范围放大十倍,该换什么解法”。这两句话看着简单,但坚持几十道题之后,你会明显感觉到自己分析问题的方式变了。这道两数之和,就是最适合开始这个习惯的地方。
