哈希表在力扣里被严重低估了。很多人觉得它简单——无非是查重、计数、存中间状态,但真正面试的时候,能把哈希题讲清楚的人并不多。我见过不少候选人,两数之和能默写,但被问到“为什么这里用哈希而不是排序”就卡住了;也有刷了几百题的人,遇到和为 K 的子数组,想不到用前缀和配合哈希表统计出现次数。原因很简单:哈希表作为工具不值钱,值钱的是“什么时候用”和“用什么做 key”这两个判断。
本文不打算讲哈希函数怎么实现、扩容怎么处理,那些是《算法导论》的事。我要做的是把力扣上哈希相关的经典题目按场景拆开,讲清楚每类题背后的思考逻辑:判断存在、归类分组、原地哈希、前缀和统计。如果你正在刷题准备面试,或者算法基础不牢想系统补哈希,这篇文章可以当作一份按场景组织的刷题地图来用。文里的题都不难,但每一道都代表一种高频考法,吃透它们比闷头刷几十道重复题有用得多。
1. 哈希表在力扣里到底考什么:不只是“拿空间换时间”
1.1 哈希表解决的是哪一类问题
哈希表本质是一个“字典”:给我一个 key,我能在平均 O(1) 时间内告诉你它的 value。力扣里的哈希题,绝大多数可以归结为“把未知问题转化为查字典问题”。判断数组中是否存在重复元素,本质上就是问“这个元素之前见过吗”;字母异位词分组,本质上就是问“这些单词的某种特征值相等吗”;两数之和,本质上就是问“之前遍历过的数里有我要找的那个吗”。
我习惯用一个类比来理解哈希表:查单词的时候你不会从头到尾翻词典,而是按拼音或者部首先定位到某一页。哈希函数就是那个“定位规则”,哈希表就是那本词典。力扣不考你怎么查得快,它考的是两件事——第一,你在什么场景下会想到去查词典;第二,你用什么词条去查。这两件事,恰恰是很多刷题的人从来没认真想过的。
很多人一看到哈希就说“拿空间换时间”,这句话对,但没有任何指导意义。空间换时间谁都知道,问题是什么时候换、怎么换、换了之后 key 怎么设计。这才是哈希题的真正考点。哈希表里的 value 也不一定就是“出现次数”或者“下标”,它可以是最近一次出现位置、前缀和出现的次数、某个区间的起点,甚至是状态是否出现过。答案的形态决定了 value 的形态。
1.2 哈希、排序、双指针、暴力怎么选
做题第一步永远不是写代码,而是选算法。哈希虽然万能,但并不是所有场景的最优解。我整理了一个简单的判断表:
| 思路 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 暴力枚举 | O(n^2) 或更高 | O(1) | 数据量小(n < 1000) |
| 排序 + 双指针/二分 | O(n log n) | O(1) 或 O(n) | 需要有序关系、找差值/边界 |
| 哈希表 | O(n) 平均 | O(n) | 判断存在、计数、配对、去重 |
| 原地哈希 | O(n) | O(1) | 数值范围有限 + 空间受限 |
判断准则其实就一句话:先看题目问的是“关系”还是“值”。如果题目问“集合里有没有”“出现过几次”“哪两个数可以凑成目标”,优先想哈希;如果题目问“第几个比它大的数”“最长递增序列”“区间求第 K 大”,那排序或者有序结构通常更自然。
但这只是起点。真实面试里,面试官经常会在你写完哈希解法后立刻改条件。比如两数之和数组有序,你会不会想到双指针?比如题目要求空间 O(1),你会不会想到原地哈希?同一个问题,换一个约束,答案可能完全不一样。只背模板是应付不了这种变化的,你得理解每种算法的适用边界。
1.3 哈希题的三条主线
刷多了之后你会发现,力扣上的哈希题表面五花八门,拆到最底层就三条主线:
- 成员查询(Set 类):判断某个元素/状态是否出现过,典型题是存在重复元素、快乐数。
- 键值统计(Map 类):存 key 对应的下标、次数、最近位置,典型题是两数之和、和为 K 的子数组。
- 键的构造(key 设计):把复杂对象映射成一个可哈希的 key,典型题是字母异位词分组。
后面几章我就按这三条主线展开,不是按题目难度排,而是按思考方式排。你会发现,一旦脑子里有了这条主线,看到一道新题,你会下意识地问自己:这题需要查什么?查出来的信息怎么用?用什么做 key 最合适?这三个问题想清楚了,代码反而是最简单的部分。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一类经典:判断“来过没有”——从两数之和到四数相加 II
2.1 两数之和:从暴力到“一边查一边存”
两数之和是力扣第一题,也是无数人的第一道哈希题,但我会花点篇幅讲它。不是因为有人不会做,而是因为这道题把“边遍历边存”这个哈希核心技巧表现得最完整。
暴力做法是固定 i,遍历 j,时间 O(n^2),空间 O(1)。n = 10^4 时大概要执行 10^8 次比较,已经卡在超时边缘;n = 10^5 时直接没戏。排序加双指针可以把时间降到 O(n log n),但题目要求返回下标,排序会丢掉原始下标,你还得额外记录,而且如果数组里有重复值,双指针移动时的边界处理很容易写错。
哈希的做法是:遍历到 nums[i] 时,查一下“之前有没有一个数等于 target - nums[i]”,如果有就找到了。把已经遍历过的数存进哈希表,key 是数值,value 是下标。
cpp复制class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> pos;
for (int i = 0; i < nums.size(); ++i) {
int need = target - nums[i];
if (pos.count(need)) {
return {pos[need], i};
}
pos[nums[i]] = i;
}
return {};
}
};
提示:这里一定要“边遍历边存”,不要先全塞进哈希表再查。全塞进去之后,如果数组里有重复值,后面的下标会覆盖前面的;更麻烦的是,遍历到某个元素时它自己已经在表里了,可能会返回自身和自身配对的结果。
这道题能引出的追问很多。比如数组有序时,双指针 O(n) 时间 O(1) 空间,明显比哈希更优;再比如如果题目改成“找出所有不重复的组合”,哈希就不好使了,排序加双指针才是正解。面试官很喜欢在写完模板后切换这些条件,本质是考察你是背了答案还是理解了思路。
2.2 存在重复元素系列:从“有没有”到“距离多远”
217 存在重复元素是哈希集合的最基本用法:把所有数放进 set,如果发现有元素已经存在,直接返回 true。这个题没什么好说的,它存在的意义是让你熟悉哈希集合的 API。
219 存在重复元素 II 就多了一个维度:不仅要判断是否重复,还要判断重复的两个数下标差是否不超过 k。这时候哈希表的 value 就不是布尔值了,而是“该数字最近一次出现的下标”。遍历到 nums[i] 时,先在哈希表里查 nums[i]:
- 如果存在且
i - lastIndex <= k,返回 true; - 如果没有,或者距离太远,就把 value 更新为当前的 i。
这题的启发是:哈希表的 value 可以携带额外信息,不是只能存“有没有”。存下标、存次数、存最早位置、存最近位置,全部取决于你要回答什么问题。
2.3 四数相加 II:哈希表的“分组配对”思想
四数相加 II 是一道被很多人低估的题。它不算难,但把“分组配对”这个思想展现得淋漓尽致。题目是给四个数组 A、B、C、D,各取一个数,问有多少种组合让四个数之和为 0。暴力是 O(n^4),n 稍微大一点就完蛋。
正确思路是两两分组:先把 A 和 B 的所有两数之和算出来,存进哈希表,value 是这个和出现的次数;然后遍历 C 和 D 的所有两数之和,查哈希表里有没有 -(c + d),有的话把对应次数累加到答案里。
cpp复制class Solution {
public:
int fourSumCount(vector<int>& A, vector<int>& B, vector<int>& C, vector<int>& D) {
unordered_map<int, int> cnt;
for (int a : A)
for (int b : B)
cnt[a + b]++;
int ans = 0;
for (int c : C)
for (int d : D)
ans += cnt[-c - d];
return ans;
}
};
这题价值在于:它不止是“查重”,而是“查出现次数”,value 从“下标”变成了“次数”。同样是哈希表,语义完全不同。以后遇到“多个集合配对求和”的题,两两拆分是通用套路。四数之和 I 可以用排序加双指针,但四数相加 II 因为涉及四个独立数组,哈希分组反而是最自然的选择。
2.4 快乐数:查重不仅查数组,也查状态
202 快乐数的最优解不是模拟到结果为 1,而是用哈希集合检测循环。每次计算平方和后,如果结果是 1,返回 true;如果这个结果已经在 set 里出现过,说明进入了循环,永远到不了 1,返回 false。
这个题的跨界价值在于:哈希集合的成员不一定是原始数组元素,也可以是中间状态。以后遇到“这个状态会不会循环”的问题,第一反应就应该是哈希集合记状态。弗洛伊德判圈算法确实也能做,空间更省,但哈希方案在面试里更容易解释,代码也更不容易出错,通常足够应对。
3. 第二类经典:把同类项映射到同一个 key——异位词分组与最长连续序列
3.1 字母异位词分组:排序 key 还是计数 key
字母异位词分组是哈希 key 设计的入门题。什么是 key?就是你要用什么样的“特征值”来判断两个字符串属于同一组。
最直接的方案是“排序后的字符串”。ate 排序后是 aet,eat 排序后也是 aet,它们就分到同一组。时间复杂度是 O(n * k log k),k 是字符串平均长度。
进阶方案是“26 个字母的计数结果”。统计每个字符串里每个字母出现的次数,拼成一个字符串或者元组当 key。比如 aet 和 ate 都会得到 1#0#0#...(a 出现 1 次,e 出现 1 次,t 出现 1 次),所以它们 key 相同。时间复杂度 O(n * k),比排序快。
python复制class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
from collections import defaultdict
mp = defaultdict(list)
for s in strs:
cnt = [0] * 26
for ch in s:
cnt[ord(ch) - ord('a')] += 1
key = '#'.join(map(str, cnt))
mp[key].append(s)
return list(mp.values())
面试时建议先说排序方案,因为它好解释;被追问优化时再说计数方案。更重要的是能说出计数方案的适用范围:如果字符集是 26 个小写字母,计数数组很划算;如果字符串可能包含几万个 Unicode 字符,计数数组就不现实了,排序 key 反而更通用。key 设计要结合数据范围,这是这道题真正的考点。
3.2 设计 key 的三个经验
从这道题能总结出设计 key 的三个经验:
- key 必须能唯一区分不同类。如果两个不同类的对象映射到了同一个 key,那分组就错了。
- key 的构造开销不能太大。如果构造 key 本身就是 O(n^2),那哈希加速就没有意义了。
- key 必须可哈希。这个最容易被忽略。
第三点在实际编码里特别烦人。C++ 的 unordered_map 要求 key 类型有 std::hash 的特化,pair 和 tuple 默认没有,直接编译报错。Python 的 dict 要求 key 是不可变类型,list 不能做 key,但 tuple 可以。很多人在做“根据两个维度分组”的题时,想用 pair<int, int> 当 key,结果在 C++ 里卡住,最后只能转成 long long 编码或者转字符串。
3.3 最长连续序列:哈希集合 + 起点判断
最长连续序列也是哈希经典题。给一个未排序数组,找最长连续序列的长度。排序后扫一遍是 O(n log n),但题目要求 O(n)。
核心思路是用 unordered_set 存所有数,然后遍历每个数,只有当 num - 1 不在集合里时,才以这个数为起点向后扩展。为什么“只有当自己是区间起点时才扩展”能让复杂度变成 O(n)?因为每个数最多被访问两次:一次是作为某个区间起点被向后数,另一次是作为被检查的元素。整个遍历过程均摊下来是 O(n)。
cpp复制class Solution {
public:
int longestConsecutive(vector<int>& nums) {
unordered_set<int> s(nums.begin(), nums.end());
int best = 0;
for (int num : s) {
if (s.count(num - 1)) continue;
int cur = num;
int len = 1;
while (s.count(cur + 1)) {
cur++;
len++;
}
best = max(best, len);
}
return best;
}
};
这道题的启示是:哈希集合不只是“存东西”,它可以在 O(1) 时间内回答“某个元素是否存在”,这个能力可以用来做“起点判断”。面试时经常有人写成“对每个数都向后扩展”,复杂度就成了 O(n^2),因为每个数会被展开多次。加一个 num - 1 的判断,看起来不起眼,却是整个算法从 O(n^2) 变成 O(n) 的关键。
4. 第三类经典:原地哈希——把数组本身当哈希表用
4.1 缺失的第一个正数:空间限制下的哈希表
先看题目:给定未排序数组,找缺失的第一个正整数,要求时间 O(n),空间 O(1)。如果用普通哈希表,空间 O(n) 不满足要求。
但注意一个关键限制:数组长度为 n,而答案的范围一定在 [1, n+1] 之间。换句话说,我们只关心数组里值在 [1, n] 范围内的数字。数组的下标天然是正整数,如果我们能把“数字 x”放到“下标 x-1”的位置,数组自己就成了一张哈希表:key 是下标,value 是“这个下标对应的数字是否出现过”。
这道题我第一次见的时候觉得很惊艳,因为它把“数组”和“哈希表”彻底打通了。数组不过是 key 固定为 0..n-1 的哈希表。我们平时用 bool visited[26] 记录字母是否出现过,本质也是在用数组当哈希表,只是没有意识到这一点。
4.2 交换与死循环:原地哈希的核心实现细节
原地哈希的实现有几个坑,每一步都是面试官最喜欢追问的地方。
cpp复制class Solution {
public:
int firstMissingPositive(vector<int>& nums) {
int n = nums.size();
for (int i = 0; i < n; ++i) {
while (nums[i] >= 1 && nums[i] <= n && nums[i] - 1 != i) {
if (nums[nums[i] - 1] == nums[i]) break;
swap(nums[i], nums[nums[i] - 1]);
}
}
for (int i = 0; i < n; ++i) {
if (nums[i] != i + 1) return i + 1;
}
return n + 1;
}
};
三个细节:
- 为什么用 while 而不是 if:交换之后,当前位置会从目标位置换过来一个新元素,这个新元素可能也不在自己的位置上,需要继续处理。
- 为什么用 swap 而不是直接赋值:直接覆盖会丢掉目标位置的元素,必须交换。
- 为什么遇到相等要 break:如果
nums[nums[i] - 1] == nums[i],说明目标位置已经有相同的值了,再交换会陷入死循环。这种情况直接跳过就行,因为重复值不影响后面的判断。
交换完成后,从头扫一遍,第一个 nums[i] != i + 1 的位置就是答案。如果全部对上了,说明数组里正好是 1 到 n,答案就是 n + 1。
提示:这个 while 循环一定要加
nums[i] >= 1 && nums[i] <= n的范围判断,否则负数、0、大于 n 的数会被无限交换,因为它们的“目标位置”根本不存在。
4.3 消失的数字和重复数字:标记法也是原地哈希
缺失的第一个正数用的是“交换法”,还有一种更轻量的“标记法”,典型题是 448 找到所有数组中消失的数字。
题目说数组长度为 n,元素值范围在 [1, n],找哪些数字没出现。思路是:遍历数组,把 nums[i] - 1 位置上的数改成负数,表示“数字 nums[i] 出现过”。最后再扫一遍,哪个位置还是正数,说明下标加 1 这个数字没出现过。
cpp复制class Solution {
public:
vector<int> findDisappearedNumbers(vector<int>& nums) {
vector<int> ans;
for (int x : nums) {
int idx = abs(x) - 1;
if (nums[idx] > 0) nums[idx] = -nums[idx];
}
for (int i = 0; i < nums.size(); ++i) {
if (nums[i] > 0) ans.push_back(i + 1);
}
return ans;
}
};
注意这里遍历时要用 abs(x),因为当前位置的值可能已经被之前的操作改成负数了。这类题还有一个经典变体:287 寻找重复数。如果允许修改数组,可以用类似标记法;如果不允许修改,通常要用弗洛伊德判圈。这两个题合在一起,正好覆盖“值域受限 + 空间受限”场景下的两种不同做法。
4.4 什么时候能想到原地哈希
总结一下触发条件,帮你在面试时快速定位:
- 数组长度是 n;
- 元素值范围集中在
[1, n]或[0, n-1]; - 题目问的是“缺失”“重复”“消失”“出现次数”;
- 题目要求空间 O(1)。
四个条件同时满足,优先想原地哈希。它不是高深的技巧,本质就是发现“数组的下标可以充当哈希表的 key”。一旦打通这层关系,你会发现很多排序题、数组题背后都藏着哈希的影子。
5. 第四类经典:哈希表不止查重——前缀和 + 哈希统计子数组
5.1 和为 K 的子数组:为什么 O(n^2) 可以优化
先看题目:给一个整数数组和一个整数 k,求连续子数组和为 k 的个数。暴力做法是枚举每个起点和终点,累加判断,O(n^2) 或者 O(n^3)。数据量一大就超时。
优化思路用前缀和。定义 prefix[i] 表示数组前 i 个元素的和,那么子数组 [j, i) 的和等于 prefix[i] - prefix[j]。问题变成:遍历到 i 时,之前有多少个 j 满足 prefix[j] = prefix[i] - k。
这时候哈希表的 key 是“前缀和的值”,value 是“这个前缀和出现过的次数”。注意是次数,不是下标,因为所有满足条件的前缀和下标都能构成一个答案。
cpp复制class Solution {
public:
int subarraySum(vector<int>& nums, int k) {
unordered_map<int, int> cnt;
cnt[0] = 1;
int prefix = 0, ans = 0;
for (int x : nums) {
prefix += x;
ans += cnt[prefix - k];
cnt[prefix]++;
}
return ans;
}
};
提示:
cnt[0] = 1一定要先初始化。它表示前缀和为 0 的情况出现了一次。否则当某个子数组从下标 0 开始,也就是prefix[i] == k时,你查不到答案。
5.2 为什么不能用双指针
这个问题面试必问。如果数组元素全是正数,滑动窗口完全可行,因为窗口和单调递增,右指针右移和变大,左指针右移和变小,可以放心移动。但数组里如果有负数,窗口和就不再单调,左指针右移后窗口和可能反而变大,滑动窗口的单调性没了,所以必须用前缀和加哈希。
这也解释了为什么“前缀和 + 哈希”这个组合能处理负数,而双指针不行。它不是简单的一个更快一个更慢,而是适用条件的差别。
5.3 同余变形:连续的子数组和
523 连续的子数组和是同一类思路的变形。题目要求找是否存在长度至少为 2 且和为 k 的倍数的子数组。由于 (prefix[i] - prefix[j]) % k == 0 等价于 prefix[i] % k == prefix[j] % k,所以哈希表的 key 变成了“前缀和模 k 的余数”,value 存这个余数第一次出现的下标。
为什么要存“最早出现的下标”?因为题目要求子数组长度至少为 2,也就是 i - j >= 2。存最早下标,就能用当前下标减去最早下标来判断长度是否达标。这个题还有一个细节:k 可能为 0。k 为 0 时“k 的倍数”就是“和等于 0”,不能取模,要单独处理。
这类题的本质是:哈希表里存的不再是“值本身”,而是“值经过某种变换后的特征”。变换可以是取模、是异或、是排序,关键是找到那个能等价判断的特征。
5.4 这一类题的通用框架
遇到“连续子数组 + 和/整除/奇偶性”这些条件时,我的思考顺序是:
- 先求前缀和数组;
- 推导出当前前缀和与历史前缀和之间需要满足什么关系;
- 用哈希表记录“历史前缀和的某种特征”的出现次数或最早位置;
- 边算边查,不要先把整个前缀和数组建好再查。
这四步走下来,大多数这类题都能套进去。但这里说的模板不是背代码,而是推导路径。关键是第 2 步,你得自己推出来要存什么,而不是拿到题就盲目往哈希表里塞东西。
6. 刷哈希题最容易踩的坑和面试加分细节
6.1 key 选型和“手动哈希表”
刷题时最容易卡住的往往不是算法,而是 key 的类型。C++ 里 unordered_map<pair<int, int>, int> 默认编译不过,因为 pair 没有哈希函数。常见的处理方式有三种:转成 long long 编码,比如 (a, b) 编码成 (long long)a << 32 | b;转成字符串,比如 "a,b";或者自己写一个仿函数提供哈希。
另一个容易被忽略的技巧是“用数组代替哈希表”。如果 key 的取值是集中且范围不大的整数,比如 26 个字母、n 个数字,直接用数组或者 vector 会比 unordered_map 快很多。我之前在 字母异位词分组 里用的 cnt[26],本质上就是一个 key 为字母、value 为次数的哈希表,只是我们用数组实现了而已。理解这一点,你会更清楚哈希表的本质:它只是一个“键到值的映射”,实现方式可以是红黑树、哈希链表,也可以是一段连续内存。
6.2 遍历过程中修改容器的坑
这是一个经典的运行时错误。Python 里遍历 dict 时删除元素,直接报 RuntimeError: dictionary changed size during iteration。C++ 里在 for (auto& kv : mp) 中 erase 当前迭代器会导致迭代器失效,行为未定义。
正确做法是:先收集要删除的 key,循环结束后再统一删除;或者 C++ 里用 it = mp.erase(it) 这种惯用法。平时写题,我的建议是能不改就不改,非要改就先收集再删,省得出问题。
6.3 哈希冲突导致的性能退化
做题时几乎遇不到哈希碰撞的极端输入,但面试官一定会问底层原理。unordered_map 底层是哈希桶加链表,当多个 key 映射到同一个桶时,链表会越来越长,查找就从 O(1) 退化到 O(n)。负载因子超过阈值会触发 rehash,重新分配桶数组并重新哈希所有元素,这是一次比较大的开销。
所以不是所有场景用哈希都稳赢。数据量很小的时候,O(n^2) 暴力可能比哈希更快,因为哈希要额外计算哈希值,常数很大。刷题前先估一下数据规模和题目约束,再决定要不要上哈希。
6.4 map 还是 unordered_map
很多刚刷题的人搞不清什么时候用 map,什么时候用 unordered_map。map 底层是红黑树,CRUD 是 O(log n),但自带有序性;unordered_map 底层是哈希表,平均 O(1),但无序。
如果题目跟“最小”“最大”“第 k 个”“有序遍历”有关,map 可能更合适,因为你不用额外排序;如果只是判断存在、计数、配对,选 unordered_map。不过注意,unordered_map 的常数很大,如果 n 只有几百,vector 加暴力可能是最优解。做题不是秀技术,是选最合适的工具。
6.5 哈希题超时排查三步
如果你在某道哈希题上超时了,先别急着换语言,按这三步检查:
- 是不是把“边算边查”写成了“先全部算完再查”,导致多遍历了一遍;
- 是不是哈希表 value 的类型选错了,该存次数存成了下标,或者该存下标存成了布尔值;
- 是不是该用“手动哈希”(数组代替哈希表)的地方用了复杂容器,常数太大。
最后分享一个我自己的习惯。很多同学刷题喜欢按题号刷,但哈希这块我建议按场景刷:两数之和理解“边查边存”,异位词分组理解“key 设计”,最长连续序列理解“集合查重加起点判断”,和为 K 的子数组理解“前缀和加次数统计”,缺失的第一个正数理解“原地哈希”。这五道题串下来,哈希在面试里能考的基本就跑不出这个圈了。每次面别人,我基本也都是从这几道题去判断候选人哈希到底有没有入门。
