☰
哈希表刷题实战:从两数之和到前缀和的四种微操作
2026/10/15 5:36:44 网站建设 项目流程

先说句实在话,力扣hot100里的哈希专题,是我见过“性价比”最不均匀的一个板块。有的题三分钟秒掉,有的题不看题解根本想不到那个映射关系,还有的题你以为用的是哈希,其实考的是前缀和。但这也恰好是哈希专题最有意思的地方:它不像动态规划那样需要大量沉淀,也不像图论那样前置知识一堆,它考察的核心就一句话——你会不会用“空间换时间”。

只要你把哈希表的底层逻辑和四五种经典微操吃透,hot100里这一整块基本就稳了。这篇文章我直接按刷题的实际顺序,把哈希专题的底层原理、核心题目拆解、代码陷阱一次讲透,适合正在刷hot100准备面试的读者,也适合刚学完哈希表想找实战练手的朋友。

1. 哈希专题的整体思路:为什么这些题长着不同的脸,骨子里却是一套拳

1.1 哈希表的本质:一张带索引的“作弊小抄”

很多人提起哈希表就想到 HashMap、dict、unordered_map,但始终没想明白它到底解决了什么问题。我习惯用一个极其生活化的类比——查字典。

你查一个生字,正常人是直接翻到对应的页码,而不是从第一页开始逐页找。哈希函数就是那套“根据部首笔画算出页码”的规则,而哈希表就是那本已经把页码索引编好的字典。数组本身就是一张朴素的哈希表,下标就是天然的顺序索引,只是它的key只能是整数,不能是字符串、对象。真正意义上的哈希表,是对任意类型的key做一层“编码”,把它映射到一个数组下标上,从而把“查找某个key是否存在”“拿到某个key关联的值”从遍历变成O(1)。

那哈希专题刷的是什么?刷的就是如何把问题里的“查找需求”抽象成一个“key-value映射”。

举个例子,两数之和为什么会是哈希的经典题?因为它的需求是“每遍历到一个数,我都想知道之前有没有出现过某个数”。这个“想知道之前有没有出现过”的本质就是一个查找操作。你当然可以用两重循环把所有组合搜一遍,但那就是一本每查一个字都要从第一页翻起的字典,没必要。

所以在我看来,哈希专题的所有题目都遵循同一个解题范式:遍历一次数据,边读边把「已经看到的信息」记到哈希里,然后每次处理新数据时,只花O(1)时间就能查询历史,从而把整体复杂度从O(n²)降到O(n)。

1.2 hot100哈希题目的四种“微操作”

我把hot100里涉及哈希的题目——包括两数之和、字母异位词分组、最长连续序列、和为K的子数组——背后的微操作归纳为四种:

  • 去重判断:已经出现过这个元素吗?代表是哈希集合,典型题是“最长连续序列”里判断候选起点。
  • 映射配对:当前这个值需要匹配历史上某个值吗?代表是哈希表,典型题是“两数之和”。
  • 频率统计:这个元素出现了多少次?代表是哈希表val存次数,典型题是字符串相关的计数题。
  • 前缀和追溯:当前前缀和减K得到的结果在历史上出现过吗?出现几次?代表是哈希表存前缀和的次数,典型题是“和为K的子数组”。

这四种微操作其实可以串成一条逻辑链:先判断你要查的是“存在性”还是“数量”,再判断key是元素本身还是元素的某种变形(排序后字符串、频率向量、前缀和)。一旦想清楚这两件事,题就做完一半了。剩下的一半是细节,后面逐一展开。

2. 四道核心题逐题拆解:从暴力到最优的思路演进

2.1 两数之和:理解“边存边查”的第一步

题目本身不赘述了,给定数组nums和一个目标值target,找出和为target的两个下标。

普通解法是两重循环,枚举所有下标组合,时间复杂度O(n²),空间O(1)。这题被放在hot100开头是有道理的,因为它的最优解完整展示了哈希最典型的应用方式。

最优解是这样:维护一个哈希表,key是数组元素的值,value是下标。遍历数组时,先检查 target - nums[i] 是否已经在哈希表里,如果在,直接返回两个下标;如果不在,把 nums[i] 和它的下标存入哈希表,继续往后走。

这里有一个我早年踩过的坑,也是很多初学者第一次写会错的地方——到底是先查再存,还是先存再查?

如果先把自己存进去再查,就会出问题:假设数组是[3, 3],target是6。遍历第一个3,先存进去,然后查 target - 3 = 3,发现哈希表里有3,直接返回[0, 0],结果就错了,因为同一个下标用了两次。正确操作一定是先查后存,保证当前元素只和“之前出现过的历史元素”配对。

代码很简单,Python版长这样:

def twoSum(nums, target): seen = {} for i, x in enumerate(nums): complement = target - x if complement in seen: return [seen[complement], i] seen[x] = i return []

C++版用unordered_map是同样的逻辑:

class Solution { public: vector<int> twoSum(vector<int>& nums, int target) { unordered_map<int, int> seen; for (int i = 0; i < nums.size(); i++) { auto it = seen.find(target - nums[i]); if (it != seen.end()) return {it->second, i}; seen[nums[i]] = i; } return {}; } };

为什么能这么优化?因为在暴力解法里,第二层循环做的事就是“寻找历史上出现过的、与当前数互补的数”。用哈希表替代这层查找,就是把记忆存下来,每次查询成本变成O(1),整体复杂度降到O(n)。空间上多用一个哈希表,这就是标准空间换时间。

2.2 字母异位词分组:key的设计能力是哈希的灵魂

第二道经典题,字母异位词分组。给定一组字符串,把字母相同但排列不同的单词分到同一组。

这题有个很有趣的点:它没有明显的“查找需求”在表面,但仔细一想——我们需要知道“当前这个字符串,应该放进哪个组”。这个“属于哪个组”的判断,本质上是为每个字符串找一个唯一标识它的key。

异位词不care字母顺序,只care词频。所以两个思路:

思路一,把字符串里的字母排序后作为key。字符串"eat"和"tea"排序后都是"aet",天然分到一组。排序复杂度O(k log k),其中k是单词最大长度,总复杂度O(nk log k)。

思路二,用长度为26的频率数组作为key。每个词扫描一遍,统计'a'到'z'各出现几次,拼成一个字符串或元组作为key。比如"eat"对应的就是"10010000000000000001000000"这类表达。这个方案复杂度O(nk),因为字符串长度固定为26。

我个人更推荐思路一,代码更短,写起来也不容易出错。Python里可以直接用tuple作为dict的key,但C++里如果想把vector当key,需要自己重写哈希函数,所以大部分解法会用排序字符串当key:

def groupAnagrams(strs): groups = {} for s in strs: key = "".join(sorted(s)) groups.setdefault(key, []).append(s) return list(groups.values())

C++版:

class Solution { public: vector<vector<string>> groupAnagrams(vector<string>& strs) { unordered_map<string, vector<string>> mp; for (auto& s : strs) { string key = s; sort(key.begin(), key.end()); mp[key].push_back(s); } vector<vector<string>> res; for (auto& p : mp) res.push_back(p.second); return res; } };

这题真正的考点不是哈希本身,而是“你会不会设计一个稳定、不会碰撞的key”。排序法里的key是唯一的,两个异位词的排序结果一定相同,两个非异位词的排序结果一定不同,这个双射关系保证了正确性。我觉得这一题对后面做字符串分组、聚合类题目有非常大的启发——凡是遇到“把某类东西归到一起”的需求,第一步永远是找一个规范化的key。

2.3 最长连续序列:哈希集合去重的进阶用法

最长连续序列也是一个非常典型的哈希题。题目是给一个未排序的数组,找出数字连续的最长序列长度,要求O(n)时间。

这题的难点在于数组没有排序,如何不排序却知道连续区间?答案是:把数组放进哈希集合,然后只从“连续序列的起点”开始向后数。

什么叫连续序列的起点?如果集合里存在num-1,说明num不是起点,从它开始数出来的序列一定被包含在某个更长的序列里,所以没必要从它开始数。只有num-1不在集合中时,num才可能是一段连续序列的开头,这时候才从num开始逐步递增检查num+1、num+2……直到断掉。

把每个数往集合里一存,这个操作把整个判断从O(n²)降下来了。如果不做起点判断,每个数都往后数一遍,最坏情况下O(n²)。有了起点判断,每个连续序列只有它的起点被数一遍,整体遍历次数不超过O(n)。

def longestConsecutive(nums): num_set = set(nums) longest = 0 for num in num_set: if num - 1 not in num_set: cur = num length = 1 while cur + 1 in num_set: cur += 1 length += 1 longest = max(longest, length) return longest

这个解法很巧妙,但真正动手时会遇到一个细节:range能不能替代while + in?不能。因为range是连续的,但数据可能中间断开,你只能用while + in来逐个数。另外一个容易被忽略的问题是,遍历的时候到底遍历原数组还是遍历set?遍历原数组可能出现重复元素导致重复计算,虽然不影响结果正确性但会浪费时间,直接遍历set是最优习惯。

2.4 和为K的子数组:前缀和与哈希表的组合拳

这道题是hot100哈希专题里难度上限的一个代表,很多人第一次做都会懵:它看起来和哈希关系不大,更像数组题。但实际上它的最优解法正好是“前缀和 + 哈希表”的经典组合。

题目是求连续子数组中和等于K的子数组个数。

暴力解法就不说了,三重循环或者两重循环都不够优雅。优化的起点是前缀和数组。定义prefix[i]表示从数组开头到下标i的所有元素之和,那么子数组nums[j..i]的和等于 prefix[i] - prefix[j-1]。要满足和等于K,就是找存在多少个j,使得 prefix[j-1] = prefix[i] - K。

这样,问题就变成了:遍历前缀和时,对于每一个当前位置的前缀和cur,我们想找历史上出现过多少个 cur - K 这个值。这不是正是哈希表最擅长的“历史查询”吗?

于是解法浮出水面:维护一个哈希表,key是前缀和,value是这个前缀和出现的次数。遍历数组时累加前缀和cur,先查cur - K在不在哈希表里,如果在,把次数加到结果上;然后把cur的计数加一。

这里有两个非常关键的边界细节:

第一,哈希表里必须先放一个 (0, 1),表示“前缀和为0出现了一次”。为什么?因为如果整个子数组从头开始,比如nums[0..2]的和就是K,那此时cur = K,cur - K = 0,必须能从哈希表里查到0才行。没有初始值,这种“从头开始”的合法子数组就会漏掉。

第二,先查后存,和两数之和一样的顺序。如果先存再查,同一个位置重复计算,答案是错的。

def subarraySum(nums, k): prefix = {0: 1} cur = 0 ans = 0 for x in nums: cur += x ans += prefix.get(cur - k, 0) prefix[cur] = prefix.get(cur, 0) + 1 return ans

这题其实是hot100哈希专题里最值得反复琢磨的题,因为它看起来不像哈希,但用的是哈希。我和同行聊的时候有个共识:能把“和为K的子数组”思路讲清楚的,基本上哈希的用法就算真正入门了。

3. 哈希的底层细节与不同语言实现的避坑点

3.1 数组模拟哈希的适用范围

不是所有哈希需求都要用HashMap。如果key是有限、连续、可以映射到下标的小整数,直接用数组更快也更省内存。

最典型的例子是字符串频率统计:如果只涉及26个小写字母,用 int[26] 就够了,把字母减去'a'作为下标。力扣上很多字符串异位词、最长子串相关的题目,都用这种数组模拟哈希的技巧,查询速度比HashMap快一个数量级,因为数组下标访问不需要计算哈希值,也不存在碰撞处理。

什么时候必须用真正的HashMap?当key是字符串、对象、大整数,或者key的取值空间非常大、不连续时。比如两数之和里,数组元素的值可能是任意int,你不可能开一个几亿长度的数组来记录下标,此时哈希表是唯一可行的选择。

我建议把“数组模拟哈希”当作优先选项记住,一来能提速,二来能避开一些哈希表扩容、哈希碰撞的底层问题。

3.2 HashMap的扩容、碰撞与复杂度陷阱

很多人以为哈希表查询就是严格的O(1),其实严格说,只在平均情况下是O(1)。哈希表的底层是数组加链表(或红黑树),当哈希函数产生了碰撞,多个key落到同一个桶里,查找就要在链表里遍历。如果发生严重碰撞,最坏情况下退化成O(n)。现代语言里的哈希表都有自己的扩容策略和退化防御机制,但面试理论层面你最好清楚这几个点:

  • 负载因子(load factor)达到阈值时,哈希表会扩容,重新哈希所有元素。扩容那一刻的代价是O(n),所以单个操作可能卡顿。
  • 如果提前知道数据规模很大,可以在初始化时传入容量,减少扩容次数。这是实实在在的性能优化技巧。
  • 自定义对象作为key时,如果没正确覆写hashCode和equals,会出现“看起来相同但哈希不同”的诡异问题。刷题时尽量避免用复杂对象做key,字符串和基础类型是最安全的。

另外提一个面试官喜欢问的陷阱:HashMap能不能存null key?Python的dict可以存None作为key,Java的HashMap允许一个null key,但C++的unordered_map不能存null。这种细节在写跨语言代码时很容易踩坑,面试被问到了别慌,知道什么语言什么行为就行。

3.3 三种主流语言写法对比

就以“两数之和”为例,把Python、C++、Java的写法放一起看:

# Python seen = {} for i, x in enumerate(nums): if target - x in seen: return [seen[target - x], i] seen[x] = i
// C++ unordered_map<int, int> seen; for (int i = 0; i < nums.size(); ++i) { auto it = seen.find(target - nums[i]); if (it != seen.end()) return {it->second, i}; seen[nums[i]] = i; }
// Java Map<Integer, Integer> seen = new HashMap<>(); for (int i = 0; i < nums.length; i++) { if (seen.containsKey(target - nums[i])) { return new int[]{seen.get(target - nums[i]), i}; } seen.put(nums[i], i); }

三者的核心逻辑完全一致,但细节上注意:C++里find失败要用end()判断;Java里map.get前最好containsKey,避免拆箱时的NullPointerException;Python相对最省心,dict的in操作直接搞定。

我个人工作中比较多写Python,刷题如果追求速度和简洁,Python的字典确实是最好用的。但如果你面试用的是C++,不要怕写一长串find判断,那恰恰是C++风格的体现。

4. 常见问题与排查技巧实录

4.1 key的设计错误导致分组错误

字母异位词分组题,新手很容易把key设成“字符的集合”而不是“字符的频率”。比如"aab"和"ab",如果用set当key,"aab"的set是{a,b},"ab"的set也是{a,b},两个根本不同的单词就被错误分到同一组。这种错误不跑测试用例很难发现,但只要记住了“异位词的判定是频率相等,不是字符集合相同”,就能避免。

4.2 前缀和边界问题

和为K的子数组,最常见的错法就是漏掉 (0,1) 的初始化。很多人在本地测试“[1,1,1], k=2”这类用例时发现答案是1而不是2,怎么调都不对,最后才意识到是因为漏了从头开始的子数组。这个坑极深,建议在讲解题思路时先写清楚边界条件,别让代码改懵了。

4.3 遍历去重与性能损耗

最长连续序列里,如果你遍历原数组而不是set,数组有大量重复元素时,while循环会反复计算同一个序列,白白浪费时间。虽然结果正确,但如果面试官追问复杂度,只能尴尬承认最坏情况会退化。改成遍历set,从根源上避免重复计算,代码也更简洁。

4.4 先存后查的顺序问题

两数之和、和为K的子数组,都涉及“先查后存”vs“先存后查”的顺序。凡是计算“配对”类的结果,一律先查后存,防止同一个元素自匹配。凡是计算“数量”类的题目,先查后存、再更新计数,也一样严格有效。这个顺序值得在草稿上画一遍流程,想通了就不容易忘。

5. 刷题节奏与后续扩展

5.1 推荐刷题顺序

我给一个自己用着觉得舒服的顺序,尤其适合第一次刷hot100的读者:

  1. 先做两数之和,理解哈希表对“历史查找”的优化意义。
  2. 再做字母异位词分组,练key的设计思路。
  3. 接着做最长连续序列,体会哈希集合对去重和存在性判断的价值。
  4. 最后做和为K的子数组,把前缀和思想与哈希表结合。

按这个顺序,难度递增但不突兀,每一题都会用到上一题建立的思维模型。建议一天两题,周末把四道题再默写一遍,双指针和滑动窗口阶段会明显感觉到哈希的底子在帮你。

5.2 哈希能力如何迁移到其他专题

刷完哈希专题后你会发现,它是很多高级专题的“地基”。滑动窗口类题目维护窗口内字符频率,用的就是哈希表的更新与查询;图论遍历里的visited集合,就是哈希集合;LRU缓存、LFU缓存,底层更是哈希加链表的结构。所以哈希专题不要只追求把题AC,而是把“查询历史信息”这一思维模型刻进脑子。

还有一点我很想强调:哈希表的空间换时间不是没有代价的。遇到题目时先想暴力解,意识到暴力解的瓶颈是“在重复地扫描历史”,这时再考虑用哈希表。如果暴力解法本身没有对历史的重复查找需求,哈希表大概率帮不上忙。这是判定一道题到底该不该用哈希的法门。

写在最后的一些经验

刷哈希专题那段时间,我最大的感受是:这类题目的代码都很短,短到背下来很容易,但理解背后的“为什么”才是拉开差距的地方。两数之和为什么先查后存?最长连续序列为什么只在起点数?和为K的子数组为什么要初始化(0,1)?每一个“看起来多余的细节”背后都是一个隐藏的边界条件。如果只背代码而不推演这些细节,换一道变种题就会原形毕露。

另外建议你准备一个小本子,把每道题的“暴力解思路”和“哈希优化点”对照写在旁边。这比抄题解有用得多,因为刷题的本质是建立“问题类型 -> 数据结构的适用条件 -> 具体操作”的反射链路。哈希这个专题把这条链路走通之后,后面的链表、树、图专题都会轻松不少。

最后再分享一个小技巧:写完哈希题后,尝试把这些代码用另一种语言重写一遍。不用全写,抽一两道核心题就行。重写的过程会逼着你重新思考语言的边界行为——比如Java的null判断、C++的find写法、Python的get默认值——这些细节在面试手撕代码时非常救命。就到这里,去刷题吧。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询