面了上百人,我最爱问的LeetCode 1「两数之和」:能背代码的很多,能讲清“先查后存”的没几个

📌 摘要汇总|建议先收藏,再细看

一道 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 分。

❌ 两个大忌:

  • 上来二话不说直接写暴力法,印象分直接打六折;
  • 上来直接默写最优解,跳过思考过程,面试官会怀疑你背题。

✅ 正确姿势,五步走:

  1. 花 60~90 秒问清边界(重复元素、有无解、负数);
  2. 口头说暴力解作为 baseline,并主动指出瓶颈在"查找是 O(n)";
  3. 自然过渡到哈希表优化,点明"空间换时间";
  4. 写代码,报复杂度:时间 O(n),空间 O(n);
  5. 主动讲边界处理:先查后存防自匹配、溢出、无解兜底。

收尾话术,可以直接用:

"这道题的核心考察点不是'会不会写哈希表',而是有没有空间换时间的优化意识,以及对 HashMap 底层实现的真正理解。实际写的时候,先问清边界,再口头说暴力解作为 baseline,然后自然过渡到哈希表优化,代码里注意'先查后存'这个细节,基本就稳了。"

写在最后

一句话带走全文:

两数之和 = 用哈希表空间换时间,把"两层枚举"压成"一次遍历";工程上再补三刀:先查后存防自匹配、long 计算防溢出、预分配容量防扩容。

落地建议很实在:刷题别只满足于 AC,把每一行代码的"为什么"讲给自己听------为什么键值这么设计、为什么先查后存、为什么不用 TreeMap。能讲清楚的,才是真正长在你身上的东西。

聊个具体的:你面试遇到算法题,是习惯先说暴力解再优化,还是直接上最优解?有没有在"先查后存"这类小细节上栽过跟头? 评论区聊聊,经典的翻车经历我下期文章帮你复盘。

如果这篇对你有帮助,顺手点个赞和在看,你的支持是我更新的最大动力。我在更的「面试拆解」系列下一篇讲三数之和 / 四数之和的进阶链路,关注我,别迷路。