最近集中刷了一批 Hash 相关的 LeetCode 题,三道题分别涉及表达式求值、区间重复检测和字符串分组,正好把哈希表的几个典型用法都过了一遍。这篇记录不会把官方题解复读一遍,只写我实际思考过程中踩过的坑和最后沉淀下来的通用套路,适合正在刷 Hot 100、周赛或者准备面试的同学当个参考。
先说结论:Hash 之所以高频,不是因为它难,而是因为它能把“查找”这个动作从 O(n) 降到 O(1),代价只是多花一点内存。三道题分别对应了哈希表的三种常见用法——做映射、做标记、做分组键。理解了这三类场景,后续再遇到“两数之和”“无重复字符的最长子串”“字母异位词”这些题,基本都能快速定位到哈希解法。
1. 先聊聊 Hash:为什么算法题绕不开它
1.1 Hash 到底在做什么
哈希表本质上是一个“键到值的映射结构”。你给它一个 key,它通过一个哈希函数算出存储位置,然后直接去那个位置取值。这个过程的平均时间复杂度是 O(1),前提是哈希函数分布均匀,冲突控制得当。
我习惯用一个生活化类比来理解:你去图书馆找一本书,如果书是按书名拼音首字母分区的,你不需要一本一本地翻,直接去对应字母区域找就行。哈希函数就是“首字母分区规则”,而冲突就是“同一个字母分区里有多本书”,这时候需要再花一点时间在分区内逐个比对。
这个类比能解释很多面试追问。面试官问“哈希表为什么快”,不是让你背“因为用了哈希函数”,而是希望你讲出“通过哈希函数直接定位桶,再用链表或红黑树解决桶内冲突”的完整链条。
1.2 冲突处理的两条路
哈希冲突是绕不开的问题,两道不同的 key 算到了同一个桶。常见的处理方式有两种:
- 链地址法:同一个桶后面挂一条链表,冲突元素依次追加。JDK 的 HashMap 在桶内元素超过阈值后会转红黑树,本质就是优化链表的查找性能。
- 开放寻址法:冲突了就往下一个空闲位置放,ThreadLocalMap 用的就是这种。它的缺点是删除元素麻烦,需要标记而不是直接清空。
写 LeetCode 的时候,大多数语言的哈希表已经帮你封装好了冲突处理,比如 Python 的 dict、C++ 的 unordered_map、Java 的 HashMap。但你得知道底层是什么,否则没法解释为什么某些场景下自定义哈希函数能显著提升性能。
1.3 什么时候“想到用 Hash”
这是我刷题过程中最想分享的一点。很多人拿到题不知道什么时候该用哈希表,我的判断标准很简单:题目里出现了“查找”“是否存在”“统计次数”“分组归类”这些关键词,而且数据规模在 O(n^2) 不可接受的范围,优先考虑 Hash。
判断流程是这样的:
- 先想暴力解法是什么,复杂度多少。
- 如果暴力是 O(n^2),看能不能用一次遍历 + 空间换时间。
- 把“每次都要查找”的东西提前存进哈希表,查找成本从 O(n) 降到 O(1)。
这套思路放在后面三道题里,每一道都适用。逆波兰表达式求值是“查找运算符对应的计算逻辑”,存在重复元素是“查找之前是否出现过”,字母异位词分组是“把相同特征的字符串归到同一组”。
2. 第一道题:150. 逆波兰表达式求值
2.1 题目到底在考什么
题目给了一个后缀表达式,比如["2", "1", "+", "3", "*"],要求计算结果,结果是 9。逆波兰表达式的特点是运算符写在两个操作数后面,不需要括号来改变优先级。
乍一看这题和哈希表没关系,核心是用栈:遇到数字入栈,遇到运算符弹出两个操作数,计算后把结果压回栈里。但我在实际做的时候,发现 Hash 在这里的作用很容易被忽略——它映射的是“运算符字符串到具体计算行为”。
很多官方解法会写一大堆 if/else 判断运算符,代码长且容易漏。用哈希表存下运算符对应的执行函数,代码会清晰很多。这个思路在工程里也很常见,叫“策略模式”的简化版。
2.2 解法核心:栈 + 运算符映射
我用的思路是维护一个操作数栈,同时准备一个哈希映射,运算符是 key,处理逻辑是 value。在 C++ 里,这个映射可以写成std::unordered_map<std::string, std::function<int(int, int)>>,在 Python 里可以直接用dict存lambda。
大致的流程:
- 遍历 tokens 里的每个 token。
- 如果 token 是数字,直接转成整数压栈。
- 如果 token 是运算符,从栈里弹出两个数,注意弹出来的顺序。
- 调用映射里对应的计算逻辑,把结果压回栈。
- 最后栈顶元素就是答案。
关键细节是弹出顺序。后缀表达式中,先压栈的是左操作数,后压栈的是右操作数。弹出的时候先拿到的是右操作数,后拿到的是左操作数。减法和除法尤其容易在这里翻车,顺序反了结果就错了。
2.3 写代码的细节和容易翻车的点
- 负数和多位数:token 可能是
"-2"或者"123"。不要用token[0]是不是数字来判断运算符,因为负数开头也是-。稳妥的判断方式是:先看长度,如果长度大于 1 且不是运算符,就按数字处理。 - 整数溢出:LeetCode 的测试用例里会出现中间结果超过 int 范围的情况。C++ 直接用
int会溢出,我建议用long long或者std::stol。Python 没有这个问题。 - 除法的截断方向:C++ 对负数的整数除法是向零截断,Python 的
//是向下取整。比如-3 / 2,C++ 结果是-1,Python 的(-3) // 2结果是-2。提交前要确认语言的取整规则,否则可能挂在一个很不起眼的用例上。
我第一版代码就是忘了处理负数判断,"-2"被当成运算符解析,直接报错。加了长度判断之后才通过。这个错误很小,但排错花了我十分钟,写在这里提醒大家。
3. 第二道题:219. 存在重复元素 II
3.1 题目要求与思路
题目给一个整数数组和一个整数 k,判断是否存在两个不同的下标 i 和 j,使得nums[i] == nums[j],并且abs(i - j) <= k。
暴力做法是两层循环枚举所有下标对,复杂度 O(n^2)。数组长度上万的时候就吃不消了。用哈希表可以一次遍历解决:遍历数组时,把每个元素的值作为 key,它的最新下标作为 value 存进哈希表。每遇到一个元素,先查表里有没有出现过相同的值,如果有,就计算当前下标和已存下标的差值。
这里必须注意一个关键点:遇到重复时,哈希表里存的是哪个下标?应该存最近一次出现的下标,而不是第一次出现的下标。因为我们需要的是“存在一对距离不超过 k”,越近的下标越可能满足条件,同时也能覆盖更多后续可能性。
我举个例子:数组[1, 0, 1, 1],k = 1。遍历到第三个元素时,哈希表里存的是下标 0 还是 1 其实无所谓,因为都是同一个值。但遍历到第四个元素时,如果哈希表里存的是下标 2(最近一次),那么当前下标 3 和上次下标 2 的差是 1,满足条件,直接返回 true。如果存的是第一次的下标 0,差值是 3,超过 k,可能就漏掉了正确答案。
3.2 哈希表版本和滑动窗口版本的对比
这题还有另一种解法:维护一个大小为 k 的滑动窗口,用集合判断窗口内有没有重复元素。窗口滑动的过程中不断加入新元素、移除离开窗口的旧元素。
- 哈希表版本:空间复杂度最坏 O(n),因为每个不同元素都可能存一个下标。
- 滑动窗口版本:空间复杂度严格 O(k),因为集合里最多只有 k+1 个元素。
时间复杂度两者都是 O(n)。如果 k 很小,滑动窗口更省内存;如果 k 很大接近 n,两者没有本质区别。
实际工程里我倾向先写哈希表版本,因为代码简单、逻辑更直观。但面试如果聊到空间优化,能说出滑动窗口方案会加分不少。这也是为什么我建议两道解法都掌握,不需要二选一。
3.3 复杂度与边界检查
- 边界一:k 可能是负数吗?题目默认不会,但如果你用了滑动窗口,窗口大小是负数时会出错。建议开始前做一次
if (k <= 0)的兜底。 - 边界二:数字范围很大,甚至可能是负数。哈希表不存在“索引偏移”问题,直接用负值当 key 完全没问题,这也是它比某些计数数组方案更优的地方。
- 边界三:数组长度为 1 时,任何 k 都不可能产生重复,直接返回 false。
我提交的时候有一次就是因为窗口大小写成k而不是k+1,导致边界用例没过。滑动窗口判断去重时,新元素加入前要先检查集合的大小,否则窗口会超过合法范围。
4. 第三道题:49. 字母异位词分组
4.1 异位词的本质
题目是给一个字符串数组,把由相同字母重新排列组成的字符串分到同一组,比如["eat", "tea", "tan", "ate", "nat", "bat"],结果是[["bat"], ["nat", "tan"], ["ate", "eat", "tea"]]。
这题的核心是找到一个判别函数,让“字母异位词”映射到同一个键,而不同的字母组合映射到不同的键。哈希表的 key 设计决定了这道题的成败。
异位词的本质是字母构成相同,只是排列顺序不同。所以判别逻辑必须摆脱“顺序”的影响。
4.2 两种 key 设计:排序法和计数法
第一种:排序法。把字符串按字母排序,排序后的结果作为 key。"eat"排序后是"aet","tea"排序后也是"aet",两者自然归到一组。这种方案实现简单,但每个字符串都要排序,总复杂度是 O(n * m log m),m 是字符串平均长度。
第二种:计数法。统计每个字符串中每个字母出现的次数,把次数序列作为 key。比如"eat"的计数序列是[1, 0, 0, ..., 1, ..., 1](按 26 个字母),"tea"的序列完全一致。C++ 里可以把 26 个计数拼成一个字符串,Python 里可以用tuple(counts)作为 dict 的 key。这种方案复杂度是 O(n * m),没有排序开销,但 key 会稍微长一点。
排序法适合字符串很短、题解速度要求快的场景;计数法适合字符串很长、字母集合固定的场景。实际提交 LeetCode,两种都能过,计数法在极端用例下更快。
4.3 实现细节和复杂度对比
- Python 中注意:
str可以作为 key,但多字符计数用collections.Counter的items()转 tuple 有点慢。我实测下来直接用 26 长度的 tuple 更稳。 - C++ 中注意:
std::map和std::unordered_map的选择。key 是字符串时,unordered_map需要额外的哈希函数,标准库默认支持std::string,直接用即可。 - key 的编码不要用分隔符连接计数,否则可能被某些特殊字符干扰。直接用定长数组转字符串最稳妥。
两种方案的复杂度对比如下:
| 方案 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 排序法 | O(n * m log m) | O(n * m) | 字符串短、编码简洁 |
| 计数法 | O(n * m) | O(n * m) | 字符串长、字母范围固定 |
空间复杂度两者差不多,主要差别在时间上。我一开始用排序法,AC 之后改了计数法,发现运行时间大概快了一倍。实战中遇到这题,我建议直接写计数法,顺手把“优化点”写在注释里,面试官问起来还能多聊几句。
5. 三道题放在一起看:Hash 的变式与思维模式
5.1 同一个 Hash,三种用法
刷完三道题再回头看,Hash 不是一种单一技巧,而是一类思维模式。三道题恰好是三种常见用法:
- 逆波兰表达式求值:用 Hash 做“调度分发”,把字符串运算符映射到可执行逻辑。这是哈希表在“策略查找”场景的应用。
- 存在重复元素 II:用 Hash 做“历史状态记录”,记录元素上次出现的位置,实现空间换时间。这是“标记去重”场景的应用。
- 字母异位词分组:用 Hash 做“归类依据”,把有相同特征的物体映射到同一个桶。这是“分组聚合”场景的应用。
这三种用法可以迁移到很多其他题目:两数之和是“记录状态”,无重复字符的最长子串是“滚动去重”,单词规律是“双向映射”。你想通了这三个场景,Hash 相关的题基本就通了一半。
5.2 Hash 相关的常见坑
这些坑不只是三道题里遇到的,是刷了几十道 Hash 题之后总结出来的:
- 遍历过程中修改哈希表:Python 里边遍历 dict 边删除元素会直接报错,或者导致行为不确定。可以先收集要删的 key,遍历结束后再统一删。
- key 的可变性:Python 的 list 不能作为 dict 的 key,因为不可哈希。遇到需要把数组当 key 的情况,转成 tuple 或字符串。
- 默认值问题:
dict.get()和defaultdict的行为要区分,前者不会自动创建键,后者会。该用get的时候别偷懒用defaultdict,否则会往哈希表里塞一堆空键。 - 自定义对象的哈希:如果给自定义类写
__hash__,记得同时重写__eq__,否则哈希值相同但相等性判断不一致,会出现找不到 key 的情况。
5.3 从刷题到工程:Hash 键设计的实际映射
刷题时我们关注怎么设计 key,工程里同样有“哈希键设计”问题。比如前端打包工具里输出的 JS 文件名带一串 hash,是为了实现“内容寻址”——内容不变则 hash 不变,浏览器可以长缓存;内容一变则 hash 变,浏览器自然加载新文件。这个本质和字母异位词分组里的“特征映射”是同一种思路。
再比如终端里校验文件完整性时用的hash命令,是哈希函数在数据完整性校验场景的应用,和哈希表是两个概念。很多初学者容易混淆,其实哈希表使用的哈希函数更关注“分布均匀”,而校验哈希更关注“雪崩效应”和“碰撞概率低”。严格来说,工程中的文件 hash 是“摘要”,LeetCode 里的哈希表是“索引结构”,两者只是共享了“通过函数计算指纹”这个底层思想。
这也是为什么我建议刷题的时候多想一层:这题里的哈希,到底是用来做“索引查找”还是“指纹归类”?想清楚这个,面试时讲解决方案会更有深度,而不是只会背代码。
6. 一些排查心得和刷题建议
三道题里最容易卡住的是第一道题的负数判断,最容易漏掉的是第二道题的“最近下标”,最值得琢磨的是第三道题的 key 设计。我把它们整理成一个速查表,方便大家复习:
| 问题 | 现象 | 原因 | 解法 |
|---|---|---|---|
| 负数 token 被误判 | 报错无法解析运算符 | token[0] == '-'覆盖了负数 | 用token.size() > 1排除负数 |
| 减除法结果错误 | 部分用例失败 | 弹出顺序搞反 | 先弹出的赋给 right,后弹出的赋给 left |
| 窗口重复漏判 | 边界用例失败 | 窗口大小多算了 1 | 控制集合大小为 k+1 |
| 异位词 key 不一致 | 分组错误 | 计数序列拼法有问题 | 用定长数组转字符串 |
| Python 的 list 当 key | 运行报错 | list 不可哈希 | 转成 tuple 或字符串 |
关于刷题顺序,我的建议是先做“存在重复元素 II”这类简单的标记题,再做“逆波兰表达式求值”这类综合题,最后啃“字母异位词分组”这种需要设计 key 的题。难度是阶梯递进的,可以帮你把 Hash 的基础概念一层层夯实。不要上来就扎进难题,容易产生挫败感。
我特别喜欢在 LeetCode 里搜索“哈希表”标签,把 easy 和 medium 的题按通过率降序刷一遍,大概三四十题之后,你会发现自己对“什么时候用 Hash”已经有了肌肉记忆。看到题目里出现“互不相同”“存在重复”“按特征分组”这些词,第一反应就会想到用一个哈希表去解决。
最后分享一下我的个人习惯:每道题 AC 之后,我会强迫自己再写一个不同解法的版本。比如存在重复元素 II 我写了哈希表和滑动窗口两个版本;字母异位词分组我写了排序法和计数法两个版本。不是为了刷题量,而是为了比对两种方案的复杂度差异,这个习惯让我的算法基础扎实了很多。下一轮刷题,我打算围绕“哈希 + 前缀和”这个组合多找几道题练练,这类题目在周赛里出现频率很高,复杂度比单考哈希表要高一个档次,值得花时间研究。