刷 LeetCode 最头疼的不是题目难,而是不知道从哪里开始。热题100的第一题“两数之和”,我前前后后刷了不下五遍,每次回过头看都有新的收获。这道题看似基础,但它牵出的哈希表思想、边界条件处理、复杂度分析方法,几乎能覆盖你刷后面一百道题需要的一大半底层能力。这篇文章我就以“两数之和”为起点,从读题、暴力解法、哈希优化到实际提交中的坑,把整条思路完整走一遍,适合刚接触算法题的同学,也适合刷过但想系统梳理一遍的老手。
1. 先读懂题目:两数之和到底在问什么
1.1 题目讲了个什么事
题目要求很简短:给定一个整数数组 nums 和一个目标值 target,从数组里找出两个数,让它们的和等于 target,返回这两个数的下标。
举个例子,nums = [2, 7, 11, 15],target = 9,因为 2 + 7 = 9,所以返回 [0, 1]。题目明确说了每种输入只会对应一个答案,而且同一个元素不能重复使用。很多人看到这里觉得这不就是两层循环嘛,确实,暴力解法是能过的,但一旦数据量上来,就不是过不过的问题,而是性能完全跑不动。
有一个细节值得先想清楚:“同一个元素不能重复使用”的意思是,你不能拿 nums[0] 既当第一个数又当第二个数。比如 nums = [3, 3],target = 6,两个 3 是不同位置的两个元素,答案应该是 [0, 1],但如果你不小心,很容易把同一个下标返回两次。
1.2 为什么它总被排在热题100第一位
热题100的第一题之所以是它,不是因为它难,而是因为它把算法题里最常用的几个核心概念全串起来了:暴力遍历、空间换时间、哈希表查值、边界条件分析。你可以用最差的 way 写出来,也可以一步步优化到最佳,这个过程本身就是面试官最想看到的思考路径。
这道题在面试中出现频率极高,而且经常被当作“热身题”。面试官不一定指望你秒写最优解,他们会观察你拿到题目之后先说什么、再说什么、有没有主动讨论复杂度、能不能把边界情况想全。很多人一上来就写哈希表,反而让面试官没法观察你的思考过程。好的做法是:先提暴力解法,再说优化思路,最后再落代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法一:暴力枚举,先把流程跑通
2.1 双层循环的实现逻辑
暴力解法最直白:我固定一个数 nums[i],再往后遍历所有 j,看 nums[i] + nums[j] 是否等于 target。等于就返回 [i, j],不等于就继续。
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[]{};
}
}
注意内层循环从 j = i + 1 开始,而不是从 0 开始。这样有两个好处:一是避免重复比较同一对元素,(0,1) 和 (1,0) 是同一回事;二是天然避免 i == j 也就是“同一个元素用了两次”的情况。
2.2 暴力法的复杂度算一笔账
外层循环跑 n 次,内层循环平均跑约 n/2 次,总操作次数大约是 n²/2。按题目给出的数据范围,n 最大能到 10⁴,那最坏情况下要做大约 5000 万到 1 亿次比较。
用 C++ 写 1 亿次简单比较也许能在一秒左右勉强跑完,但用 Java 或 Python 大概率会超时。LeetCode 的评测机虽然性能还可以,但时间限制通常就在一秒左右,暴力解法相当危险。更关键的是,这道题以后还会被改编成三数之和、四数之和,如果每次都套多层循环,n 稍微大一点就彻底没法用。
所以暴力解法适合用来确认思路、写测试用例、验证小规模数据,不适合作为最终提交方案。
3. 解法二:哈希表,为什么能砍掉一层循环
3.1 空间换时间的本质
暴力解法慢在哪?慢在“找补数”这一步。外层固定一个数 a,我要在剩下的数里找 b = target - a,这个“找”的过程需要遍历整个数组,代价是 O(n)。那能不能把“找”的代价降下来?
这时候哈希表就派上用场了。哈希表的核心能力是:给你一个 key,平均 O(1) 时间就能知道它存不存在、它对应的 value 是什么。你只需要把所有已经见过的数扔进哈希表,key 存数值,value 存下标,然后每遇到一个新数,就查一下它的补数在不在表里。
这种思路叫“空间换时间”:我额外用 O(n) 的内存建一张表,把查找时间从 O(n) 降到 O(1),整体时间从 O(n²) 降到 O(n)。很多人第一次接触会觉得“这也太作弊了”,但工程里这种 tradeoff 太常见了。缓存系统、数据库索引、Redis 的 key-value 存储,本质都是预先建好索引,换取查询时的效率。
3.2 先查后存:一遍哈希的正确姿势
哈希表解法看起来很简单,但有一个关键细节:你要先查补数,再把当前数存进表里,顺序不能反。
如果先把当前数存进去,再查补数,会遇到一个经典的坑。假设 nums = [3, 2, 4],target = 6。如果你先把 3 -> 0 存进去,然后遍历到下标 1 的 2,查 target - 2 = 4,不在表里;接着把 2 -> 1 存进去;遍历到下标 2 的 4,查 target - 4 = 2,在表里,返回 [1, 2],这是对的。但换个例子:nums = [3, 3],target = 6。如果先把 3 -> 0 存进去,遍历到下标 1 的 3,查 target - 3 = 3,在表里,返回 [0, 1],这也是对的。
那问题出在哪?问题出在 nums = [3, 2, 4],target = 6 时,如果你先存数再查数,遍历到下标 0 的 3 时,表里已经有 3 -> 0,查 target - 3 = 3,发现表里有,返回 [0, 0]。这就错了,等于把同一个元素用了两次。
所以正确的顺序一定是:先查补数,查不到再把自己存进去。这样查的时候,表里存的都是当前下标之前的元素,当前元素永远不会“自己匹配自己”。
3.3 关键代码逐行走一遍
我用 Java 写一遍标准答案,注释标清楚每一行在干什么:
java复制class Solution {
public int[] twoSum(int[] nums, int target) {
// key 存数组元素的值,value 存这个元素的下标
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
int need = target - nums[i]; // 当前元素需要匹配的补数
if (map.containsKey(need)) { // 补数在之前出现过吗?
return new int[]{map.get(need), i}; // 出现过,直接返回两个下标
}
map.put(nums[i], i); // 没出现过,把当前元素放进去
}
return new int[]{}; // 题目保证有解,这里只是兜底
}
}
Python 版本同样简洁:
python复制class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
seen = {}
for i, num in enumerate(nums):
need = target - num
if need in seen:
return [seen[need], i]
seen[num] = i
return []
有一点值得注意:因为我们是先查表后存数,所以 map.get(need) 得到的下标一定小于 i,返回的 [map.get(need), i] 天然就是按升序排列的。LeetCode 本身对返回顺序没有严格要求,但很多笔试平台或者面试官会期待升序输出,养成这个习惯能少踩一个坑。
4. 常见提交错误与排查技巧
4.1 “答案错误:我明明算出了 0 和 0”
这是我见过最多的错误写法。很多人知道要用哈希表,但顺序搞反了,先 map.put(nums[i], i) 再查 need,导致当 nums[i] * 2 == target 时,返回 [i, i]。
比如 nums = [3, 2, 4],target = 6,遍历到下标 0 的 3 时,need = 3,而表里此时已经有 3 -> 0,于是返回 [0, 0]。题目要求必须是两个不同的元素,这个答案必然是错的。
解决方法就是时刻记住:查补数必须发生在存自己之前。你可以在代码里加一行注释提醒自己,也可以把操作顺序写成固定模式“先查、后存”,形成肌肉记忆。
4.2 两遍哈希为什么容易把下标覆盖掉
有些教程会先介绍“两遍哈希表”的思路:第一遍把所有元素的值和下标都存进哈希表,第二遍再逐个查补数。这个方案理论上没错,但实现时踩坑概率比一遍哈希高很多。
原因在于,当数组里有重复元素时,后存进去的会把先存进去的下标覆盖掉。比如 nums = [3, 3],第一遍存储后 map.get(3) 的值是 1,因为下标 1 的 3 覆盖了下标 0 的 3。第二遍遍历到下标 0 时,查 need = 3,发现存在,返回 [map.get(3), 0],也就是 [1, 0]。虽然这也能 AC,但逻辑上绕了一圈,还容易在复杂变体里出错。
我的建议是只记一遍哈希的写法,又短又不容易出问题。两遍哈希了解思路就行,实际写代码别用它。
4.3 负数、大数组和“无解”的兜底处理
还有一个常见的错误剪枝:有人会写 if (nums[i] > target) continue,觉得当前数比 target 大就一定不是答案。这个写法在全是正数的时候碰巧能用,但一旦数组里有负数,比如 nums = [-3, 4, 3, 90],target = 0,-3 比 0 小,3 也比 0 大,但 -3 + 3 = 0 才是正确答案。加了这种剪枝反而把正确答案砍掉了。
哈希表写法的好处是根本不需要这种剪枝,你只需要关心“补数在不在表里”,正负数统统交给哈希表判断。数组长度很大的时候,哈希表 O(n) 的复杂度也能稳稳跑完,这也是它成为标准答案的根本原因。
至于无解的情况,题目本身保证了“每种输入只对应一个答案”,所以你不处理也没问题。但规范起见,我会在循环结束后返回一个空数组,避免编译报错,也方便以后代码复用。
下面把这个常见问题整理成表格,方便你对照自查:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
返回 [0, 0] 这样的相同下标 |
先存数再查补数,自己匹配自己 | 改成先查补数再存当前元素 |
| 重复元素下标被覆盖 | 两遍哈希表后写入覆盖先写入 | 用一遍哈希,边查边存 |
加了 nums[i] > target 剪枝后答案错误 |
数组包含负数,剪枝不成立 | 删除剪枝逻辑,让哈希表处理 |
| 返回顺序不确定 | 遍历顺序导致的输出顺序随机 | 利用“先查后存”天然升序返回 |
| 极端大数组超时 | 暴力枚举 O(n²) 太慢 | 改用哈希表 O(n) 方案 |
5. 从两数之和出发,掌握一类送分题
5.1 两数之和II:有序数组换双指针
热题100里有一道几乎同名的题目“两数之和 II - 输入有序数组”,区别在于输入数组已经排好序了。这时候哈希表当然还能用,但不是最优解,因为有序性可以用更省空间的方案:双指针。
一个指针指向数组开头,一个指向末尾。如果两数之和大于 target,说明大的那个数太大了,右指针左移;如果小于 target,说明小的那个数太小了,左指针右移。整个过程只遍历一遍,时间 O(n),空间 O(1)。
这道题告诉我一个道理:同样的“两数之和”需求,在不同前提条件下,最优解完全不一样。无序数组用哈希表,有序数组用双指针,体现的是“根据数据特性选择算法”的思路。
5.2 三数之和、四数之和:固定一个再降维
热题100往后刷,很快会遇到“三数之和”和“四数之和”。很多人觉得难,其实就是两数之和的扩展:三数之和可以固定第一个数,剩下两个数就变成“两数之和”;四数之和可以固定前两个数,剩下两个数又变成“两数之和”。
区别在于,这类题目要求返回不重复的三元组,而不是下标。所以哈希表的下标映射不好使了,通常的做法是先排序,再用双指针收缩区间,顺便去重。这也是为什么我把“双指针”和“哈希表”放在一起讲,这两套手法在热题100里是反复出现的黄金搭档。
另外,像“和为 K 的子数组”“目标和”这类题目,核心思路同样离不开“用哈希表记录累计状态”,它们的通用套路是在遍历时维护一个 map,记录“某个值是否出现过”以及“出现了几次”,用空间换时间,把所有前缀状态存下来。能看懂两数之和,再看这些题目会轻松很多。
5.3 哈希表这套路什么时候不灵
哈希表不是万能的。如果题目要求返回所有不重复的组合而不是下标,哈希表去重会很麻烦,这时候排序 + 双指针更顺手。如果数据范围特别小,比如数组元素只有 0 到 100,直接用数组做计数映射比哈希表更快更省内存。如果要求原地操作、不能用额外空间,那哈希表直接出局,必须思考其他办法。
这些边界意识是在不断刷题过程中练出来的。两数之和只是第一道,但通过它建立“先分析约束、再选择工具”的习惯,比刷完一百道题更有价值。
5.4 一个我常用的调试小技巧
最后分享一个实战技巧:在本地调试时,不要只跑题目给的示例,自己再补几组边界用例。我最常用的几个测试用例是:nums = [3, 3], target = 6(重复元素)、nums = [3, 2, 4], target = 6(避免自匹配)、nums = [-1, -2, -3, -4], target = -8(负数和负数相加)。
这三组用例覆盖了 90% 的坑,跑通了再去 LeetCode 提交,基本都是一次过。刷题不能只看 AC,把每个用例为什么能测出问题搞清楚,比多刷十道题都管用。这道题本身不难,但它是你在热题100里建立信心的第一步,把每一步为什么这么做想明白,后面九十九题你会走得更稳。
