LeetCode Hot 100 题解 · 哈希篇
- 本专题收录 LeetCode Hot 100 中所有 哈希 相关题目。
- 每道题提供最直接、最容易理解的解题思路,包含详细注释的代码实现,方便笔试和面试复习。
📑 目录
| 题号 | 题目 | 难度 | 核心思路 |
|---|---|---|---|
| 1 | 1.两数之和 | 🟢简单 | 暴力枚举两层循环是 O(n²),瓶颈在于"找搭档"这一步太慢。哈希表把查找降到 O(1):一边遍历一边问"我需要的那个数出现过没",出现过直接返回下标。 |
| 49 | 49.字母异位词分组 | 🟠中等 | 异位词的本质是字母组成相同 ,那就找一个"同组成映射到同 key"的规则:排序后的字符串,或者 26 个字母的计数串。哈希表按 key 收集,一组组捞出来就是答案。 |
| 128 | 128.最长连续序列 | 🟠中等 | 要 O(n),排序 O(n log n) 不行。全丢进 HashSet,只从"没有前驱 x-1 的数"开始往后数 ------每个数最多被访问两遍,总代价还是 O(n)。 |
前置知识:刷哈希题要熟的 Map / Set API
写哈希题时真正卡手的往往不是思路,是 Map 这些"取出时顺便处理"的 API 不熟。这里一次讲清。
声明与遍历
java
// 声明:接口 Map 声明,实现用 HashMap(刷题 99% 用它)
Map<Integer, Integer> map = new HashMap<>();
// 存 / 取 / 删 / 判存在
map.put(1, 100); // 存(重复 put 同一个 key 会覆盖旧 value)
Integer v = map.get(1); // 取,key 不存在返回 null(不是抛异常)
map.remove(1); // 删
map.containsKey(1); // 判 key 存在
map.containsValue(100); // 判 value 存在(O(n),刷题基本不用)
map.size(); // 键值对个数
// 遍历三种,最常用 entrySet(key 和 value 都要时)
for (Map.Entry<Integer, Integer> e : map.entrySet()) {
e.getKey(); e.getValue();
}
for (int k : map.keySet()) { } // 只要 key
for (int val : map.values()) { } // 只要 value
// Set 同理(Map 只留 key 的版本)
Set<Integer> set = new HashSet<>();
set.add(1); set.contains(1); set.remove(1);
getOrDefault:取出时兜底,不改表
"key 存在就取 value,不存在就返回我给的默认值" ,表本身不动。最典型的场景是计数:
java
// 统计每个数出现的次数
map.put(num, map.getOrDefault(num, 0) + 1);
// 等价的老写法,对比一下就知道它省了什么:
if (map.containsKey(num)) {
map.put(num, map.get(num) + 1);
} else {
map.put(num, 1);
}
computeIfAbsent:取出时顺手建组
"key 存在就取 value;不存在就执行 lambda 算出一个 value,存进去再返回" ------它和 getOrDefault 的区别就一个:没命中时会写入 。所以它专治 Map<K, List<V>> / Map<K, Set<V>> 这种"value 是个容器"的分组结构:
java
// 分组:key -> 这组的所有元素
map.computeIfAbsent(key, k -> new ArrayList<>()).add(elem);
// └ 不存在就建一个空列表存进去 ┘ └ 拿到列表直接 add
// 等价的老写法:
List<String> list = map.get(key);
if (list == null) {
list = new ArrayList<>();
map.put(key, list);
}
list.add(elem);
49 题"异位词分组"用的就是它:每组第一次遇到时建列表,之后往里 add。
compute:取出旧值,算完写回(不管 key 在不在)
"按 key 取出旧值(可能是 null),执行 lambda 算出新值,写回" 。lambda 收到 (key, oldValue),返回值就是新 value。key 不存在时 oldValue 是 null,要自己处理 ------返回 null 则整个 entry 被删掉。适合"新值要同时看 key 和旧值"的计算:
java
// 计数也能这么写,oldValue 为 null 时当成 0
map.compute(num, (k, old) -> old == null ? 1 : old + 1);
merge:计数的最短写法
"key 存在就把旧值和给定值用函数合并;不存在就存入给定值" 。lambda 收到 (oldValue, value)。它的妙处是没命中时 lambda 根本不执行,直接存 value ,不用像 compute 那样判 null:
java
// 计数一行流,49 题那个计数串也能用它写
map.merge(num, 1, Integer::sum);
// │ │ └ 旧值和 1 求和:old + 1
// │ └ 第一次遇到时直接存的值
// └ key
// 等价于 getOrDefault 版:map.put(num, map.getOrDefault(num, 0) + 1);
merge和compute的分工:"不存在时存默认值"用merge(不碰null),"新值依赖旧值做计算"用compute。计数这种场景merge最短,但getOrDefault版意图更直白,团队代码里两种都常见。
这几个 API 怎么选
| API | key 不存在时 | 会不会写表 | 典型场景 |
|---|---|---|---|
getOrDefault(k, defaultValue) |
返回 defaultValue |
不会 | 计数:put(k, getOrDefault(k,0)+1) |
computeIfAbsent(k, fn) |
执行 fn,结果存入再返回 |
会 | 分组:computeIfAbsent(k, k->new ArrayList<>()).add(x) |
get(k) |
返回 null |
不会 | 只取不兜底(记得判 null) |
merge(k, v, fn) |
直接存入 v(fn 不执行) |
会 | 最短计数:merge(k, 1, Integer::sum) |
compute(k, fn) |
fn 收到 oldValue = null,需自判 |
会 | 新值要同时看 key 和旧值的计算 |
一句话记:计数用 getOrDefault 或更短的 merge,建组用 computeIfAbsent,"旧值 + 计算"用 compute,不确定 key 在不在还直接取的用 get + 手动判 null。
三个 API 怎么选
| API | key 不存在时 | 会不会写表 | 典型场景 |
|---|---|---|---|
get(k) |
返回 null |
不会 | 只取不兜底(记得判 null) |
getOrDefault(k, defaultValue) |
返回 defaultValue |
不会 | 计数:put(k, getOrDefault(k,0)+1) |
computeIfAbsent(k, fn) |
执行 fn,结果存入再返回 |
会 | value为集合时使用,分组:computeIfAbsent(k, k->new ArrayList<>()).add(elem) |
一句话记:计数用
getOrDefault,建组用computeIfAbsent,不确定 key 在不在还直接取的用get+ 手动判null。另外Map还有merge、compute等变体,逻辑是"取出旧值 + 函数计算 + 写回"三合一,刷题用得少,遇到再查即可。
1.两数之和
题目链接 :1.两数之和
题目描述 :给你一个整数数组 nums 和目标值 target,找出数组中和为 target 的两个整数,返回它们的下标。同一个元素不能重复用,题目保证恰好有一个答案。
思路一:哈希表(一遍遍历,边存边查"搭档")
核心思想:
- 暴力做法是两层循环固定一个数、再线性找另一个,
O(n²)。瓶颈很清楚:"找target - nums[i]在不在前面"这个查找动作太慢。 - 哈希表正好把查找降到
O(1)。遍历到nums[i]时先问哈希表:target - nums[i]出现过吗?出现过直接返回;没出现过把nums[i]存进去,留给后面的数当搭档。 - 先查再存 ,顺序不能反------先存的话同一个元素会被自己匹配上(比如
target = 6,nums = [3, ...],3 会和自己配对)。
时间复杂度 : O ( n ) O(n) O(n),其中 n 为数组长度,一遍遍历,每次查/存哈希表 O(1)。
空间复杂度 : O ( n ) O(n) O(n),哈希表最多存 n 个元素。
详细注释版本:
java
class Solution {
/**
* 两数之和问题:给定一个整数数组 nums 和一个整数目标值 target,
* 请你在该数组中找出和为目标值 target 的那两个整数,并返回它们的数组下标。
* 你可以假设每种输入只会对应一个答案,且数组中同一个元素在答案里不能重复出现。
*
* 人话:
* 其实就是要检测数对,因为对于某一个数而言我们有明确的目标,
* 所以我们可以通过哈希表的方式进行前值的存储,便于后值的查询。
*
* @param nums 输入的整数数组
* @param target 目标和值
* @return 两个整数的索引组成的数组,按出现顺序返回(前一个索引小于后一个)
*/
public int[] twoSum(int[] nums, int target) {
int n = nums.length; // 获取数组长度,用于循环遍历
// 创建哈希表,键为数组元素值,值为该元素对应的索引,用于快速查找已遍历过的元素
Map<Integer, Integer> mp = new HashMap<>();
// 遍历数组中的每个元素
for (int i = 0; i < n; ++i) {
int x = nums[i]; // 将当前元素赋值给变量x,减少数组访问次数,优化性能
// 计算目标值与当前元素的差值,即需要找到的另一个元素值
int complement = target - x;
// 检查哈希表中是否存在该差值:若存在,说明之前已遍历过该元素,直接返回两个索引
if (mp.containsKey(complement)) {
return new int[] { mp.get(complement), i };
}
// 若哈希表中不存在该差值,则将当前元素及其索引存入哈希表,供后续元素查找
mp.put(x, i);
}
// 题目假设存在唯一解,此处为默认返回(实际不会执行到)
return new int[] { -1, -1 };
// retur new int[0];
}
}
精简注释版本:
java
class Solution {
public int[] twoSum(int[] nums, int target) {
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[0]; // 题目保证有解,走不到这
}
}
思路二:暴力枚举(固定一个,线性找另一个)
核心思想 :两层循环,外层固定 nums[i],内层从 i + 1 开始到 nums.length结束,找 nums[j] 使两者相加等于 target。逻辑最直白,但 O(n²) 在 n 大时会超时,面试时作为引出哈希解法的铺垫提一句即可。
时间复杂度 : O ( n 2 ) O(n^2) O(n2),其中 n 为数组长度。
空间复杂度 : O ( 1 ) O(1) O(1)。
java
class Solution {
public int[] twoSum(int[] nums, int target) {
// 暴力解法:枚举所有可能的两个数
// 看它们的和是否等于 target
int n = nums.length; // 数组长度
// 第一层循环:固定第一个数 nums[i]
for (int i = 0; i < n; ++i) {
// 第二层循环:从 i 的后一个位置开始找第二个数 nums[j]
// 这样可以避免重复枚举,例如 (0,1) 和 (1,0) 只算一次
for (int j = i + 1; j < n; ++j) {
// 如果当前这两个数的和正好等于目标值
if (nums[i] + nums[j] == target) {
// 返回这两个数的下标
return new int[] { i, j };
}
}
}
// 如果遍历完所有数对都没有找到
// 按题意一般不会发生,这里只是为了代码完整性
return new int[] { -1, -1 };
}
}
49.字母异位词分组
题目链接 :49.字母异位词分组
题目描述 :给你一个字符串数组 strs,把字母异位词 (字母相同、顺序可以不同的词,如 eat / tea / ate)组合在一起,返回分组后的列表。不考虑输出顺序和组内顺序。
思路一:排序后的字符串作 key(异位词排序后必相同)
核心思想:
- 分组问题的关键是找等价关系 :什么样的字符串算"同一类"?异位词的定义是字母组成相同,那把每个字符串的字符排序,异位词排序后的结果必然完全一致 (
eat/tea/ate都排成aet)。 - 用哈希表:key = 排序后的串,value = 原串列表。遍历每个词,排好序找到自己的组,塞进去。最后把所有组捞出来就是答案。
时间复杂度 : O ( n ⋅ k log k ) O(n \cdot k \log k) O(n⋅klogk),其中 n 为字符串个数,k 为字符串的最大长度------每个串排序 O(k log k)。
空间复杂度 : O ( n ⋅ k ) O(n \cdot k) O(n⋅k),哈希表要存下所有字符串。
computeIfAbsent写法:
java
class Solution {
public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> m = new HashMap<>();
for (String s : strs) {
// 把 s 排序,作为哈希表的 key
char[] sortedS = s.toCharArray();
Arrays.sort(sortedS);
// 排序后相同的字符串分到同一组
// computeIfAbsent:如果 key 不在哈希表中,则插入一个新的 ArrayList
m.computeIfAbsent(new String(sortedS), _ -> new ArrayList<>()).add(s);
}
// 哈希表的 value 保存分组后的结果
return new ArrayList<>(m.values());
}
}
普通get和getOrDefault写法:
java
class Solution {
/**
* 可以以排序后的字符串作为key,如果有就加入,如果没有就新开再加入
* 最后需要从HashMap到List赋值
*/
public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> mp = new HashMap<>(); // 排序后的串 -> 原串列表
for (String str : strs) {
char[] arr = str.toCharArray(); // 暂存数组用于排序,因为java不能直接排序字符串
Arrays.sort(arr); // 异位词排序后必相同,是这组词的"指纹"
String key = new String(arr); // 作为 新key比较
// 写法1:
// if (!mp.containsKey(tmp)) {
// mp.put(key, new ArrayList<>());
// }
// mp.get(key).add(str);
// 写法2:
List<String> list = mp.getOrDefault(key, new ArrayList<String>()); // 有组就进组;没组就建组再进组
list.add(str);
mp.put(key, list);
}
// 写法1:
// int n = mp.size();
// List<List<String>> ans = new ArrayList<>();
// for (String k : mp.keySet()) {
// ans.add(mp.get(k));
// }
// return ans;
// 写法2:
return new ArrayList<>(mp.values());
// 等价:return new ArrayList<List<String>>(mp.values());
}
}
思路二:字母计数作 key(26 维计数串代替排序)
核心思想:
- 排序的目的是生成"指纹",但排序要
O(k log k)。"字母组成相同"还有更直接的刻画:26 个字母各出现几次 ------统计计数,拼成a3b1c2...这样的串作 key,异位词的计数串也必然相同。 - 生成计数串只要
O(k),比排序省一个log。但Java里要手动拼字符串,常数不小,长度短时实际提升有限,面试时作为"还能怎么优化"的加分项讲。
时间复杂度 : O ( n ⋅ k ) O(n \cdot k) O(n⋅k),其中 n 为字符串个数,k 为字符串的最大长度。
空间复杂度 : O ( n ⋅ k ) O(n \cdot k) O(n⋅k)。
java
class Solution {
public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> map = new HashMap<>();
for (String str : strs) {
int[] count = new int[26]; // 26 个字母的出现次数
for (char c : str.toCharArray()) {
++count[c - 'a'];
}
// 计数拼成串当 key,比如 "1,0,2,...,0" ;异位词的计数串必相同
StringBuilder sb = new StringBuilder();
for (int cnt : count) {
sb.append(cnt).append('#'); // 加分隔符,防止 2,18 和 21,8 拼出同一个串
}
map.computeIfAbsent(sb.toString(), k -> new ArrayList<>()).add(str);
}
return new ArrayList<>(map.values());
}
}
注意
#分隔符不能省:不加的话计数[2,18]和[21,8]会拼成同一个串218,不同组成的词被错分到一组。
128.最长连续序列
题目链接 :128.最长连续序列
题目描述 :给你一个未排序的整数数组 nums,找出数字连续的最长序列 (数值上连续,不要求在数组中相邻,如 [100,4,200,1,3,2] 里 1,2,3,4)的长度。要求时间复杂度 O(n)。
思路一:HashSet + 只从序列起点数(没有前驱的数才配当起点)
核心思想:
O(n)的要求直接堵死了排序(O(n log n))。数组顺序既然没用,先把所有数丢进HashSet------去重 +O(1)判断某个数存不存在,这两点是后面一切的前提。- 朴素的数法:对每个数
x,从它开始x+1、x+2... 一直往set里查,数出序列长度。但这样有大量重复劳动------[1,2,3,4]里从 1、2、3 出发各数一遍,总代价最坏O(n²)。 - 关键优化:只从"序列的起点"开始数------
x是起点,当且仅当x - 1不在set里 。x - 1存在说明x是某个更长序列的中段,从它数必然是白费,直接跳过。这样每个数只被"它所在序列的一次完整扫描"访问到,每个数最多被访问两遍 (一遍判前驱、一遍被数到),总代价O(n)。
时间复杂度 : O ( n ) O(n) O(n),其中 n 为数组长度,每个数最多被访问两遍。
空间复杂度 : O ( n ) O(n) O(n),HashSet 存所有数。
java写法:
java
class Solution {
public int longestConsecutive(int[] nums) {
Set<Integer> set = new HashSet<>();
for (int num : nums) {
set.add(num); // 去重 + O(1) 判存在
}
int longest = 0;
for (int num : set) { // 遍历 set 而不是 nums,重复数只处理一次
if (set.contains(num - 1)) {
continue; // 有前驱说明 num 是序列中段,从这数必然重复劳动,跳过
}
// 走到这说明 num 是某段连续序列的起点(x-1 不存在)
int cur = num;
while (st.contains(++cur)) {
}
longest = Math.max(longest, cur - num);
}
return longest;
}
}
C++写法:
cpp
class Solution {
public:
/*
* 时间:2025年2月17日13:42:31
* 位置:哈希计数,
* 算法思路:若排序则nlogn不满足要求
* 优化思路:若遍历nums,必定是O(n*n)=>遍历哈希表,O(n*1)
* 在代码中每个num最多被访问两次["所处num-1的位置"],所以O(n)
* ->->->子数组考虑前缀和,子序列考虑哈希
*/
int longestConsecutive(vector<int>& nums) {
unordered_set<int> hashSet(nums.begin(), nums.end()); // nums->哈希集合
int re = 0;
for (int num : hashSet) { // 枚举起点
if (hashSet.contains(num - 1)) // count 起点更换为num-1
continue;
// else
int end = num + 1; // 新起点
while (hashSet.contains(end))
++end; // 一直找到连续的终点 end-1
re = max(re, end - num); // end-1 -num +1 = end-num
}
return re;
}
};
面试时常被追问"为什么是
O(n)不是O(n²)":内层 while 不是对每个外层数都跑满,而是每个连续段只被完整扫一次 (只有起点能触发扫描)。[1..n]这种最坏输入里,外层只有 1 是起点,内层扫一遍n,其余数全部被continue跳过------均摊下来每个数O(1)。
思路二:排序后数连续段(好想但不满足题目要求)
核心思想 :排序后相同的数相邻、连续的数也相邻,线性扫一遍:和前一个相等就跳过(去重),大 1 就延长当前段,否则开新段。好写好想,但排序 O(n log n),题目明确要 O(n) 时这版只能当铺垫讲。
时间复杂度 : O ( n log n ) O(n \log n) O(nlogn),其中 n 为数组长度,瓶颈在排序。
空间复杂度 : O ( 1 ) O(1) O(1)(不计排序本身的开销,取决于语言实现)。
java
class Solution {
public int longestConsecutive(int[] nums) {
if (nums.length == 0) return 0;
Arrays.sort(nums); // 排完相等的数相邻、连续的数也相邻
int longest = 1, cur = 1; // cur:以 nums[i] 结尾的连续段长度
// 枚举每个位置作为 以 nums[i]结尾的连续段长度
for (int i = 1; i < nums.length; ++i) {
if (nums[i] == nums[i - 1]) {
continue; // 重复数直接跳过,不影响当前段
}
if (nums[i] == nums[i - 1] + 1) {
cur++; // 还在同一个连续段里,延长
} else {
cur = 1; // 前面断了,从自己开新段
}
longest = Math.max(longest, cur);
}
return longest;
}
}
这版的价值是给你一个"能对拍"的朴素解法:思路一写完可以用它验证。注意
cur的语义是以当前元素结尾的段长,这样状态转移只有"延长 / 重开"两种,不用回看。
总结:哈希篇三道题是同一个模式的三档难度------"把一个
O(n)级的查找/判定动作压到O(1)"。 1.两数之和 靠哈希表即时查"搭档出现过没";49.字母异位词 靠"指纹作 key"(排序串或计数串)把等价类归到一组;128.最长连续序列 靠HashSet的O(1)判存在,再配"只从没有前驱的数起数"避免重复扫描。核心一句话:遇到"要在前面/全体里找某个特定值"或"要按等价关系分组",第一反应就是哈希表;而O(n)的硬要求下,还要想清楚每个元素到底会被访问几遍。