一、引言
LeetCode 第一题「两数之和」是无数程序员入门算法的起点。这道题解法多样,从直观的双重循环到优雅的哈希表,背后涉及了 HashMap 的使用、自动装箱拆箱、重复元素处理等诸多 Java 基础与进阶知识。本文将用四段代码,从暴力枚举到两遍哈希表,再到一遍哈希表的正确版与一个极易出错的版本,全面梳理这些知识点,帮助读者真正理解"为什么这样写是对的,那样写会翻车"。
二、解法1:暴力枚举
最直接的思路:用两层循环遍历所有数对,检查它们的和是否等于 target。
class Solution {
public int[] twoSum(int[] nums, int target) {
// 外层循环:固定第一个数,遍历到倒数第二个即可
for (int i = 0; i < nums.length - 1; i++) {
// 内层循环:第二个数从 i+1 开始,避免重复组合和同一元素使用两次
for (int j = i + 1; j < nums.length; j++) {
// 找到目标数对,直接返回两个索引
if (nums[i] + nums[j] == target) {
return new int[]{i, j};
}
}
}
// 根据题目假设一定会有一个解,这里返回空数组只是语法要求
return new int[0];
}
}
知识点汇总表格
| 知识点 | 说明 |
|---|---|
| 双层 for 循环 | 穷举所有数对,时间复杂度 O(n²) |
| 避免重复组合 | 内层 j = i + 1,保证不会把同一元素用两次,也不重复检查 (i,j) 和 (j,i) |
| 返回数组 | new int[]{i, j} 直接创建并初始化匿名数组返回 |
| 空数组返回 | new int[0] 在没有解时返回长度为 0 的数组,避免返回 null |
| 时间复杂度 | O(n²),空间复杂度 O(1) |
三、解法2:两遍哈希表
先遍历一次将每个数的值和它的索引存入 HashMap,再遍历一次查找补数。利用哈希表将查找时间降为 O(1),整体 O(n)。
class Solution {
public int[] twoSum(int[] nums, int target) {
// 创建 HashMap,键为数组元素值,值为对应索引
Map<Integer, Integer> map = new HashMap<>();
// 第一遍:将所有元素及其索引存入 map
for (int i = 0; i < nums.length; i++) {
map.put(nums[i], i); // 如果出现重复元素,后面的索引会覆盖前面的
}
// 第二遍:遍历数组,寻找补数
for (int i = 0; i < nums.length; i++) {
int complement = target - nums[i];
// 检查补数是否存在,并且补数的索引不是当前元素自己
if (map.get(complement) != null && map.get(complement) != i) {
return new int[]{i, map.get(complement)};
}
}
return new int[0];
}
}
为什么要判断 map.get(k) != i ?
假设 nums = [3, 2, 4],target = 6。第一遍存完之后 map 包含 3→0, 2→1, 4→2。当 i = 0 时,补数是 3,map.get(3) 得到 0,此时 0 == i,如果不排除就会返回 [0,0],实际上元素 3 被使用了两次。正确答案是 [1,2]。因此 必须确保补数的索引不是当前索引。
知识点汇总表格
| 知识点 | 说明 |
|---|---|
Map<Integer, Integer> |
泛型接口,存储键值对;常用实现 HashMap |
put(K, V) |
将键值对插入映射;若键已存在则更新值 |
get(K) |
返回键对应的值,若键不存在返回 null |
!= null 判断 |
避免对 null 进行拆箱操作导致 NullPointerException |
自动拆箱与 != i |
map.get(k) 返回 Integer,与 int i 比较时自动拆箱为 int,比较的是值 |
| 防止同一元素用两次 | 通过 map.get(complement) != i 检查索引不同 |
| 时间复杂度 | O(n),空间复杂度 O(n) |
四、解法3:一遍哈希表(正确版)
在两遍哈希表的基础上,将"查找补数"和"存入当前数"合并到一个循环中。关键:先查找,后存入。
class Solution {
public int[] twoSum(int[] nums, int target) {
// 哈希表:键为元素值,值为索引
Map<Integer, Integer> hashTable = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
int complement = target - nums[i];
// 先检查补数是否已经在表中
if (hashTable.containsKey(complement)) {
// 存在,直接返回结果(补数索引必不为 i,因为当前元素还没被放入)
return new int[]{hashTable.get(complement), i};
}
// 后存入当前元素,保证不会匹配到自己
hashTable.put(nums[i], i);
}
return new int[0];
}
}
顺序的魔力 :因为当前元素在检查补数时还未进入哈希表,所以查找到的补数一定是之前遍历过的其他元素。这天然避免了"同一个元素用两次"的问题,也无需再写 != i。
五、解法4:一遍哈希表(错误版)及原因分析
如果将 put 和 containsKey 的顺序写反,就会踩坑。
class Solution {
public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> hashTable = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
// 错误:先把当前元素放入哈希表
hashTable.put(nums[i], i);
// 再检查补数 ------ 这时补数可能就是刚放进去的自己!
if (hashTable.containsKey(target - nums[i])) {
return new int[]{hashTable.get(target - nums[i]), i};
}
}
return new int[0];
}
}
报错场景还原
以 nums = [3, 2, 4], target = 6 为例:
| 循环轮次 | i | nums[i] | 操作 | 哈希表状态 | containsKey 检查 | 返回结果 |
|---|---|---|---|---|---|---|
| 第一轮 | 0 | 3 | put(3,0) | {3=0} | containsKey(3) → true | [0, 0] ❌ |
在 i=0 时,3 刚被放入哈希表,补数 target - 3 = 3 立即在表中被找到,hashTable.get(3) 返回 0,于是输出 [0, 0],错误地把一个元素用了两次。正确答案 [1, 2] 被完全错过。
即使数组是 [3, 3],target = 6,错误版在 i=0 时也会返回 [0, 0],而不是期待的 [0, 1]。因为第二个 3 还没遍历到,此时表中只有一个 3,匹配的还是自身。
教训 :使用一遍哈希表时,必须先检查,后放入。
正确版与错误版对比表格
| 对比维度 | 正确版(先查后放) | 错误版(先放后查) |
|---|---|---|
| 放入时机 | 检查补数之后 | 检查补数之前 |
| 是否会匹配自身 | 不会(自身未入表) | 会(自身刚入表) |
是否需要额外 != i 判断 |
不需要 | 仍会出现同一元素用两次的问题 |
| 典型错误输出 | 无 | [0, 0] 等 |
| 正确性 | ✅ 正确 | ❌ 错误 |
六、拓展知识点全景
1. HashMap 核心方法详解
| 方法 | 描述 | 注意事项 |
|---|---|---|
put(K key, V value) |
插入键值对;若键已存在,新值覆盖旧值,返回旧值 | 在重复元素场景下,后出现的索引会覆盖先前的 |
get(Object key) |
返回键对应的值,不存在则返回 null |
返回值可能是 null,拆箱前需判空 |
containsKey(Object key) |
判断键是否存在 | 时间复杂度 O(1),常用于检查后再操作 |
containsValue(Object value) |
判断值是否存在 | 时间复杂度 O(n),需遍历 |
remove(Object key) |
删除键值对,返回被删除的值 | 不存在则返回 null |
size() / isEmpty() |
返回元素个数 / 判断是否为空 | |
keySet() / values() / entrySet() |
返回键集、值集合、键值对集 | 常用于遍历 |
2. 自动装箱与拆箱对比较的影响
Java 中 Integer 是对象,int 是基本类型。当 Integer 与 int 进行 == 或 != 比较时:
-
Integer会自动拆箱为int,然后进行值比较。 -
这保证了
map.get(k) != i是在比较数值而非引用。
但如果用两个 Integer 进行 == 比较,则比较的是引用地址 。在 -128 ~ 127 范围内由于享元模式会缓存对象,相同值可能引用相同;超出范围则每次自动装箱会新建对象,== 可能得到 false。所以在对象比较时务必使用 equals() 或确保一边是基本类型。
Integer a = 200;
Integer b = 200;
System.out.println(a == b); // false(不同对象)
System.out.println(a.equals(b)); // true
// 在 map.get() != i 中:
// map.get 返回 Integer,i 是 int,Integer 被拆箱成 int,安全
3. 哈希表的时间复杂度与冲突处理
-
理想情况:O(1) 插入和查找。
-
哈希冲突 :不同的键计算出相同的桶位置。
HashMap在 JDK 8 中采用 数组 + 链表 + 红黑树 的结构:-
当链表长度 ≥ 8 且数组长度 ≥ 64 时,链表转为红黑树,查找复杂度降为 O(log n)。
-
当树节点数 ≤ 6 时,退化回链表。
// 简单示意 HashMap 内部结构(JDK 8+)
// Node<K,V>[] table; // 哈希桶数组
// Node 可能链向下一个节点形成链表
// 当链表过长时 TreeNode 替代 Node 形成红黑树
-
4. Map 接口的常见实现对比
| 实现类 | 底层结构 | 是否有序 | 线程安全 | 适用场景 |
|---|---|---|---|---|
HashMap |
数组+链表+红黑树 | 无序 | 否 | 通用场景,查找插入快 |
LinkedHashMap |
哈希表+双向链表 | 保持插入顺序 | 否 | 需要顺序遍历,如 LRU 缓存 |
TreeMap |
红黑树 | 键自然排序 | 否 | 需要键排序的场景 |
Hashtable |
哈希表 | 无序 | 是(方法 synchronized) | 遗留类,不推荐 |
ConcurrentHashMap |
分段锁+CAS+红黑树 | 无序 | 是 | 高并发场景 |
5. 关于返回 new int[0] 的思考
当没有解时,返回 null 可能导致调用方出现 NullPointerException。返回空数组 new int[0] 是一种防御性编程 实践。调用方可以安全地使用 result.length 而不必判空。
七、总结
从暴力枚举到两遍哈希表,再到一遍哈希表,我们看到了算法优化的清晰路径,也看到了一个小小的顺序错误如何让程序结果完全错误。核心要点:
-
暴力法虽然慢,但直观可靠,是验证复杂解法的基准。
-
两遍哈希表 将复杂度降到 O(n),但需手动检查
!= i防止元素重用。 -
一遍哈希表最优雅,通过"先查后放"自然避免了重用问题;反之"先放后查"会酿成大错。
-
背后涉及的
HashMap方法、自动拆箱、哈希冲突、防御性编程,都是每个 Java 开发者必须掌握的基本功。
