📌 摘要汇总|建议先收藏,再细看
一道 LeetCode 1,看似简单,却是面试官最爱拿来"验货"的开场题。
能背出哈希表代码的人很多,能讲清"为什么先查后存""HashMap 凭什么 O(1)"的人,没几个。
一句话总结:
两数之和 = 用哈希表空间换时间,把"两层枚举"压成"一次遍历";工程上再补三刀:先查后存防自匹配、long 计算防溢出、预分配容量防扩容。
题型标签:
数组 哈希表 HashMap 面试高频 Java 大厂面试 LeetCode 1
难度: 简单(但面试淘汰率不简单)
考察频率: ⭐⭐⭐⭐⭐
为什么这篇值得你花 10 分钟?
这不是一篇普通题解,而是一份面试官视角的细节拆解笔记。我会把这道"简单题"拆到骨头里:
- 为什么不能先
put再查?[3,2,4]怎么返回[0,0]? - 为什么不能先全存再查?
[3,3]怎么变成[1,1]? - HashMap 凭什么 O(1)?JDK 1.8 的数组 + 链表 + 红黑树到底怎么工作?
- 有序数组为什么反而不用哈希表?排序 + 双指针什么时候反杀?
- 数据量大到内存放不下,怎么答才像真写过生产代码?
读完你将带走:
✅ 从 O(n²) 到 O(n) 的完整思维演进
✅ "先查后存"手推执行过程,彻底根治自匹配和重复元素坑
✅ HashMap 底层:扰动函数、位运算定位、拉链法、红黑树树化阈值
✅ HashMap / TreeMap / HashSet 选型对比,一句话说清
✅ 排序 + 双指针的适用边界,以及"丢下标"怎么破
✅ 工程优化四刀:预分配容量、一次 get、long 防溢出、fastutil
✅ 13 个高频坑自查清单 + 7 道面试官追问弹药
✅ 面试节奏五步走 + 可直接套用的收尾话术
适合谁看:
- 正在准备大厂面试,不想只背答案的人
- 刷题能 AC,但一被追问就卡壳的人
- 想真正搞懂 Java HashMap 底层的人
- 想把算法写出"工程味"的人
暴击金句:
能写出哈希表,只是及格;能讲清"先查后存",才是加分项。
文末互动:
你面试算法题时,是习惯先说暴力解再优化,还是直接上最优解?有没有在"先查后存"这种小细节上栽过跟头?评论区聊聊,经典翻车经历我下期帮你复盘。
下一篇预告:
三数之和 / 四数之和进阶链路------排序、双指针、去重,一步一个坑。
👉 如果这篇对你有帮助,点个赞 和在看 ,顺手收藏,关注我,别迷路。
LeetCode 1「两数之和」:面试官想听的不是答案,是藏在代码里的这些细节
题型标签:数组 / 哈希表 | 难度:简单 | 考察频率:⭐⭐⭐⭐⭐
面了这么多人,我最爱拿来开场的题还是两数之和。不是因为它难,而是它太好写、太难讲------大部分候选人都会背哈希表的代码,但能说清"为什么要先查后存""HashMap 凭什么 O(1)"的,没几个。
这篇把我攒了几年的面试笔记一次讲透:思路演进、避坑细节、底层原理、工程优化、追问题库,全在一篇里。正在准备大厂面试的,收藏这一篇就够了。
一、拿到题先别急着写,先问
题目原文(LeetCode 1):
text
给定一个整数数组 nums 和一个整数目标值 target,
请你在该数组中找出和为目标值 target 的那两个整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案,且同一个元素不能重复使用。
示例:
输入:nums = [2, 7, 11, 15], target = 9
输出:[0, 1]
解释:nums[0] + nums[1] == 9
真实面试里,我建议你听完题别马上动笔,先花 60~90 秒问清边界:
- "数组里会有重复元素吗?比如
[3, 3],target = 6,应该返回[0, 1]对吧?" - "保证一定有解吗?如果无解,返回空数组还是抛异常?"
- "数组可能包含负数或零吗?"
💡 为什么要问这几句? 题面里埋着三个容易翻车的隐藏点:返回的是下标 而不是值(意味着不能随便排序)、重复元素不能自配对、数据范围 ±10⁹ 意味着
target - nums[i]理论上可能溢出。主动点破这三点,面试官会认为你有工程思维------先明确需求,再写代码。
二、思路演进:从 O(n²) 到 O(n)
2.1 暴力法:能跑,但面试官会摇头
最直观的想法是两层循环,枚举所有数对:
java
// 面试中不建议首选:O(n²)
public int[] twoSumBrute(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[]{};
}
打个比方:暴力法就像一屋子人挨个互相握手,n 个人就要握 n² 次。数据量到 10⁴ 级别,操作次数直奔亿级,基本就超时了。
面试官看到双层循环,第一反应是:这个人有没有空间换时间的意识? 所以写出双层循环后,一定要停下来主动说瓶颈在哪。
2.2 关键一跃:把"查找"从 O(n) 降到 O(1)
暴力的瓶颈到底在哪?每个数都要回头扫描整个数组,去找它的"另一半"------这个查找是 O(n) 的。
换个问法,局面立刻变了:遍历时不问"数组中还有谁",而是问 "我之前见过 target - nums[i] 吗?"
"查之前见过谁"这件事,HashMap 可以做到 O(1)。于是握手变成了进门登记:
text
暴力法:每个人跟其他人逐一握手 O(n²)
哈希法:进门先查登记册,一秒定位 O(n)
一句话概括:边走边存,先查后存。
2.3 四种解法复杂度对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 | 面试评价 |
|---|---|---|---|---|
| 暴力双循环 | O(n²) | O(1) | 数据量极小、内存极度受限 | ❌ 仅作思路铺垫 |
| 两遍哈希表(先全存再查) | O(n) | O(n) | --- | ⚠️ 写法冗余,还有自匹配的坑 |
| 排序 + 双指针 | O(n log n) | O(n)* | 数组已有序、只返回值不保下标 | ⚠️ 会丢原始下标 |
| 一遍哈希表 | O(n) | O(n) | 无序数组、要下标 | ✅ 面试标准答案 |
* 排序双指针如果想保留原始下标,得额外 O(n) 空间存映射,反而不如直接用哈希表,后面第七节细说。
这个差距有多夸张?n = 10000 时,暴力法大约要 1 亿次操作,哈希表只要 1 万次左右,差了一万倍。
三、手推一遍执行过程,比背代码靠谱
拿示例 nums = [2, 7, 11, 15]、target = 9 走一遍:
| 步骤 i | numsi | 补数 = 9 - numsi | 表内情况 | 动作 |
|---|---|---|---|---|
| 0 | 2 | 7 ❌ | {} 空表 |
没查到,存入 {2→0} |
| 1 | 7 | 2 ✅ | {2→0} |
命中!返回 [0, 1] |
注意一个细节:命中发生在 i=1,后面的 11 和 15 根本不用看------这也是为什么最坏情况下表里也只会存 n-1 个元素。
不过别以为这题就像图里这么简单,第一个坑就在前面等着,大部分人都会栽。
四、核心代码:面试就写这个版本
标准版,白板手写友好,先讲清逻辑再谈优化:
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 complement = target - nums[i];
// 先查:之前的元素里有没有这个补数
if (map.containsKey(complement)) {
return new int[]{map.get(complement), i};
}
// 后存:把当前元素登记进表,留给后面的元素匹配
map.put(nums[i], i);
}
// 题目保证有解;工程上抛异常比返回 null 更规范
throw new IllegalArgumentException("No two sum solution");
}
}
四个关键决策,每个都值得跟面试官解释一句:
| 决策 | 理由 |
|---|---|
| 为什么用 HashMap | 需要 O(1) 查找,且要同时存"值 + 下标"两份信息 |
| key 存什么 | 存元素值------因为查询的入口是值 |
| value 存什么 | 存下标------因为题目要返回下标 |
| 为什么一遍遍历 | 边存边查,比"先全存再查"少一次完整遍历,还天然规避自匹配 |
⚠️ 容器选型提醒 :这里千万别顺手写成
HashSet。Set 只能存值存不了下标,题目要求返回下标,必须用 Map 结构。这是真实面试里我见到频率不低的翻车点。
工程加强版:拉开差距的三个亮点
如果时间充裕,或者面试官追问"还能优化吗",可以掏出这个版本:
java
class Solution {
public int[] twoSum(int[] nums, int target) {
// 亮点①:入参校验,防御非法输入
if (nums == null || nums.length < 2) {
throw new IllegalArgumentException("nums 长度不足");
}
// 亮点②:预分配容量。负载因子 0.75,容量取 n / 0.75 + 1 ≈ n * 4/3 + 1
// 避免遍历过程中反复扩容 + rehash
Map<Integer, Integer> map = new HashMap<>(nums.length * 4 / 3 + 1);
for (int i = 0; i < nums.length; i++) {
// 亮点③:用 long 算补数防溢出
// 当 nums[i] = Integer.MIN_VALUE 时,target - nums[i] 会溢出成负数,可能误命中
long need = (long) target - nums[i];
if (need < Integer.MIN_VALUE || need > Integer.MAX_VALUE) {
map.put(nums[i], i); // 补数超出 int 范围,数组里必然没有,直接跳过
continue;
}
// 亮点④:一次 get 判空,代替 containsKey + get 两次哈希寻址
Integer j = map.get((int) need);
if (j != null) {
return new int[]{j, i}; // j 一定小于 i,返回顺序天然正确
}
map.put(nums[i], i);
}
throw new IllegalArgumentException("No two sum solution");
}
}
⚡ 技术亮点标注:
- 一次
get代替containsKey+get:后者要算两遍哈希寻址,前者一遍搞定。用getOrDefault(complement, -1)一步到位也可以,思路相同。- 返回顺序不用额外判断 :能查到的补数一定是"前辈",
map.get(complement)必然小于i,顺序天然正确。- 预分配容量 :也有更激进的写法直接给
n - 1(理由是有解时表永远装不满),思路没错,但想严格杜绝扩容,n / 0.75 + 1更稳。
五、全题最大的坑:先查后存
这题唯一的、也是致命的坑,就是 containsKey 和 put 的顺序。两种错误写法都会翻车:
错误写法一:先全部存入 Map,再遍历查找
text
nums = [3, 3], target = 6
全部存入 map → {3: 1} // 下标 0 被下标 1 覆盖了!
再遍历查找 → get(3) 返回 1 → 结果是 [1, 1] ❌
同一个元素被用了两次
错误写法二:先 put 再查
text
nums = [3, 2, 4], target = 6
i=0:先存 {3:0},再查补数 3 → 查到了自己 → 返回 [0, 0] ❌
正确姿势只有一个:先查后存。 查询的那一刻,当前元素还没入表,Map 里永远只有"之前的元素",自己匹配自己在物理上就不可能发生。
再看一遍重复元素的正确打开方式,nums = [3, 2, 4]、target = 6:
text
i=0:num=3,查 3 → 空表没有 → 存 {3:0}
i=1:num=2,查 4 → 没有 → 存 {3:0, 2:1}
i=2:num=4,查 2 → ✅ 命中,get(2)=1 → 返回 [1, 2]
那"相同值后存覆盖先存"会不会出问题?不会。题目保证唯一解,先出现的下标先入库,刚好等到后出现的那个来配对,[3, 3]、target=6 会正确返回 [0, 1]。
💡 加分小动作 :写完代码后,主动用
[3, 3]、target=6 这个用例在面试官面前手推一遍流程,展示你考虑到了重复元素这个边界。这一步往往比代码本身更值钱。
口诀背下来:边走边存,先查后存。
代码写到这,面试官通常不会放过你,下一个问题几乎必然是:HashMap 凭什么 O(1)?
六、深挖:HashMap 凭什么 O(1)
这是高频追问,也是区分"背过题"和"真懂"的分水岭。JDK 1.8 的 HashMap 底层是数组 + 链表 + 红黑树:
三个关键机制:
- 哈希扰动 :
(h = key.hashCode()) ^ (h >>> 16),让高 16 位也参与运算,减少哈希碰撞。 - 定位桶 :
(n - 1) & hash,用位运算代替取模,高效且分布均匀。 - 冲突兜底 :哈希冲突时用拉链法挂到链表上;链表长度 ≥ 8 且数组长度 ≥ 64 时转红黑树,把最坏查询从 O(n) 压到 O(log n)。
🎤 面试话术,可以直接背 :"HashMap 用拉链法解决冲突,JDK 1.8 引入红黑树兜底极端链表。单次查询理想情况是 O(1),即使退化到红黑树也是 O(log n),远优于暴力扫描。另外哈希冲突不会导致查询出错,
containsKey和get会沿链表/树逐个比对 key,只是变慢,不会查错。"
顺带把两个容器选型问题一次说清:
为什么用 HashMap 而不是 TreeMap?
| 维度 | HashMap | TreeMap |
|---|---|---|
| 底层结构 | 哈希表(数组 + 链表/红黑树) | 红黑树 |
| 查找复杂度 | O(1) ✅ | O(log n) |
| key 是否有序 | 无序 | 有序 |
| 本题需要有序吗 | 不需要 | --- |
| 结论 | ✅ 最优选择 | 没必要,白付 log n 的代价 |
为什么用 HashMap 而不是 HashSet? 因为题目既要判断"值存不存在",又要拿回"下标",必须同时存两份信息,Set 只能存值,直接出局。
七、追问:为什么不用排序 + 双指针?
面试官常追一句:"排序后双指针也是 O(n log n),为什么不用?"
它没错,但有个硬伤:题目要返回原始下标。排序会把下标打乱,排完还得额外记录"值 → 原下标"的映射才能还原答案,代码更绕,空间也省不下来。
| 维度 | 哈希表 | 排序 + 双指针 |
|---|---|---|
| 时间复杂度 | O(n) | O(n log n) |
| 空间复杂度 | O(n) | O(1),但要返回原下标时回到 O(n) |
| 返回原始下标 | ✅ 天然支持 | ❌ 需要额外映射 |
| 适用场景 | 无序数组、要下标 | 已排序数组、只要值 |
但如果面试官说"数组已经排好序",立刻切换双指针------有序就有单调性,左右指针相向夹逼,O(n) 时间 + O(1) 空间,比哈希表还省内存:
java
public int[] twoSumSorted(int[] nums, int target) {
int l = 0, r = nums.length - 1;
while (l < r) {
long sum = (long) nums[l] + nums[r]; // 同理,防溢出
if (sum < target) l++; // 和小了 → 左指针右移
else if (sum > target) r--; // 和大了 → 右指针左移
else return new int[]{l, r}; // 注意:这是排序后的下标
}
throw new IllegalArgumentException("No two sum solution");
}
⚠️ 坑点提醒 :如果题目要求原始下标,得先存
(value, index)二元组再排序,空间回到 O(n),那还不如直接用哈希表------能把这层权衡说出来,本身就是加分项。
八、进阶亮点:工程场景下的四刀优化
这一节的内容面试不一定用得上,但说出来,面试官会知道你是真写过生产代码的。
亮点 1:预分配容量,告别扩容抖动
HashMap 默认负载因子 0.75,元素数量超过阈值就触发扩容------容量翻倍 + 全量 rehash ,数据量大时性能抖动很明显。提前按 n / 0.75 + 1(即 n * 4/3 + 1)指定容量,一次到位:
java
int initialCapacity = (int) (nums.length / 0.75f) + 1;
Map<Integer, Integer> map = new HashMap<>(initialCapacity);
亮点 2:一次 get 省掉一半寻址
containsKey() + get() 是两次独立的哈希寻址,白白浪费。改成一次 get() 判空,或者用 getOrDefault(complement, -1),查询开销直接减半。
亮点 3:long 防溢出
target - nums[i] 在极端值下会溢出:比如 nums[i] = Integer.MIN_VALUE、target = 1 时,1 - (-2147483648) 溢出成负数,可能误命中。用 long 中转并做范围校验,是工程代码的基本修养。
亮点 4:海量数据场景,干掉装箱开销
HashMap<Integer, Integer> 的装箱开销很重:一个 entry 要装对象头、两个 Integer 包装对象、Node 指针,百万元素级别内存直接暴涨。性能敏感场景可以换 primitive 实现:
java
import it.unimi.dsi.fastutil.ints.Int2IntOpenHashMap;
Int2IntOpenHashMap idx = new Int2IntOpenHashMap(expectedSize);
idx.defaultReturnValue(-1); // 用 -1 当哨兵,避免装箱判 null
int j = idx.get(need);
if (j != -1) return new int[]{j, i};
这一节信息量不小,如果你正在系统准备面试,可以先点个关注收藏起来慢慢看,这个系列我会一直更下去。
九、技术难点全景表(自查清单)
把全文的坑浓缩成一张表,面试前扫一遍:
| # | 难点 / 坑 | 踩坑表现 | 解决方案 |
|---|---|---|---|
| 1 | 查找效率瓶颈 | 暴力 O(n²),大数据超时 | 哈希表把单次查找降为 O(1),整体 O(n) |
| 2 | 元素自匹配 | 先 put 后查,返回 [0, 0] |
先查后存,表里永远只有"前辈" |
| 3 | 重复元素覆盖 | 先全存再查,[3,3] 返回 [1,1] |
一遍遍历边查边存,先入库的等后出现的配对 |
| 4 | 容器选型错误 | 误用 HashSet 拿不到下标;误用 TreeMap 白付 log n | HashMap 存"值 → 下标" |
| 5 | 键值设计反了 | map 存成"下标 → 值" | key 存值、value 存下标,因为查询入口是值 |
| 6 | 整型溢出 | target - nums[i] 越界成负数 |
用 long 算补数并做范围校验 |
| 7 | 扩容抖动 | 默认容量 16,频繁 resize + rehash | 构造时按 n * 4/3 + 1 预分配 |
| 8 | 重复哈希寻址 | containsKey + get 查两遍 |
一次 get 判 null,或 getOrDefault |
| 9 | 装箱内存放大 | 百万元素内存暴涨 | fastutil / eclipse-collections 的 primitive map |
| 10 | 排序丢下标 | 双指针返回的是错位下标 | 存 (value, index) 二元组,或改回哈希表 |
| 11 | 线程安全 | JDK7 并发 put 可能成环死循环,JDK8 仍有数据覆盖 | 并发场景用 ConcurrentHashMap |
| 12 | 边界输入 | null、长度不足导致 NPE |
入参校验 + 抛 IllegalArgumentException |
| 13 | 无解约定 | 静默返回 null 埋雷 | 主动跟面试官约定:空数组 or 抛异常 |
十、面试官高频追问,提前备好弹药
Q1:数组已经有序,还用哈希表吗?
不用。有序就有单调性,对撞双指针 O(n) 时间 + O(1) 空间,比哈希表还省内存。但要留意下标问题。
Q2:要返回所有不重复的数对呢?
推荐排序 + 双指针,命中后 l++ / r-- 并跳过所有重复值(while (l < r && nums[l] == nums[l+1]) l++),避免重复解。哈希表也能做,但要额外去重,更绕。
Q3:数据量大到内存放不下怎么办?
三板斧:① 外排序 + 归并双指针;② 按值域分桶落盘,逐桶加载(或分片外哈希、MapReduce 思路);③ 用 Bitmap / BloomFilter 做存在性预判,减少无效 IO。能聊到分布式,这轮基本就稳了。
Q4:多线程下能用 HashMap 吗?
不能。JDK7 并发 put 可能导致链表成环死循环,JDK8 修了死循环但仍有数据覆盖。用 ConcurrentHashMap,或者更好的思路------分片并行,各线程维护局部表,最后合并,从源头避免共享写。
Q5:升级到三数之和(LeetCode 15)呢?
排序 + 双指针固定一个数再夹逼,核心难点变成去重:外层跳过相同的第一个数,内层跳过相同的配对值。这是这道题最常被追问的下一站。
Q6:用 Go / Python 写有什么区别?
思路完全一样,只是语法差异:Go 用 map[int]int,Python 用 dict,本质都是哈希表的 O(1) 查找。
Q7:如果要求不能用额外空间呢?
只能退回暴力 O(n²),或者排序 + 双指针------但下标会乱,得先保存原始下标,绕了一圈还是回到 O(n) 空间。把这个"绕回来"的过程讲给面试官听,比直接说"做不到"高级得多。
十一、面试节奏:决定印象分的软细节
最后聊点代码之外的东西,这些细节往往决定你是 60 分还是 90 分。
❌ 两个大忌:
- 上来二话不说直接写暴力法,印象分直接打六折;
- 上来直接默写最优解,跳过思考过程,面试官会怀疑你背题。
✅ 正确姿势,五步走:
- 花 60~90 秒问清边界(重复元素、有无解、负数);
- 口头说暴力解作为 baseline,并主动指出瓶颈在"查找是 O(n)";
- 自然过渡到哈希表优化,点明"空间换时间";
- 写代码,报复杂度:时间 O(n),空间 O(n);
- 主动讲边界处理:先查后存防自匹配、溢出、无解兜底。
收尾话术,可以直接用:
"这道题的核心考察点不是'会不会写哈希表',而是有没有空间换时间的优化意识,以及对 HashMap 底层实现的真正理解。实际写的时候,先问清边界,再口头说暴力解作为 baseline,然后自然过渡到哈希表优化,代码里注意'先查后存'这个细节,基本就稳了。"
写在最后
一句话带走全文:
两数之和 = 用哈希表空间换时间,把"两层枚举"压成"一次遍历";工程上再补三刀:先查后存防自匹配、long 计算防溢出、预分配容量防扩容。
落地建议很实在:刷题别只满足于 AC,把每一行代码的"为什么"讲给自己听------为什么键值这么设计、为什么先查后存、为什么不用 TreeMap。能讲清楚的,才是真正长在你身上的东西。
聊个具体的:你面试遇到算法题,是习惯先说暴力解再优化,还是直接上最优解?有没有在"先查后存"这类小细节上栽过跟头? 评论区聊聊,经典的翻车经历我下期文章帮你复盘。
如果这篇对你有帮助,顺手点个赞和在看,你的支持是我更新的最大动力。我在更的「面试拆解」系列下一篇讲三数之和 / 四数之和的进阶链路,关注我,别迷路。