先坦白一下,我最近重新整理刷题计划的时候,把 LeetCode Hot 100 里的所有“哈希”相关题单独拉出来过了一遍。之前我也按题号顺序刷过,效果不太理想,因为每次都只顾着提交通过,很少回头看这一类题目到底在考什么。这回换个思路,把两数之和、字母异位词分组、最长连续序列、和为 K 的子数组这些经典题放在一起做横向对比,才猛然发现哈希题其实是很适合用来建立“数据结构思维”的入门模块。这篇东西不光是给你梳理 Hot 100 里哈希题的解法,更是想把哈希表这个结构本身讲透,顺带聊聊为什么这套思维在真实项目和面试里都被反复使用。
如果你是刚开始刷 Hot 100,或者刷了一部分但碰到哈希相关题还是只会暴力解,这篇文章应该能帮你在思路上拧清楚。
1. 为什么刷 LeetCode Hot 100 时先啃哈希
1.1 哈希在 Hot 100 里的存在感
哈希这个标签在 Hot 100 里的覆盖面,比很多人想象中要广。最明显的当然是第 1 题两数之和,但继续往下翻,49 题字母异位词分组、128 题最长连续序列、136 题只出现一次的数字、141 题环形链表、202 题快乐数、560 题和为 K 的子数组,这些表面上解法各异的题目,底层都有一个共同动作:用哈希表额外记录一部分信息。
我统计了一下自己重新整理过的 Hot 100 名单,带哈希标签或者“用哈希表能优化到最优解”的题目大概有十几道。这个密度在所有数据结构和算法标签里都是靠前的。更重要的是,这些题目覆盖了哈希的三种典型用法:查重、计数、建索引。
有意思的是,哈希题往往不是单独存在的,它经常和其他标签组合出现,比如滑动窗口里用哈希表维护窗口内元素,前缀和思想里必须搭配哈希表才能把时间复杂度从 O(n^2) 降到 O(n)。也就是说,啃透哈希,后续刷滑动窗口、前缀和、甚至是图论题目时都会顺手很多。
我建议的刷题顺序里,哈希是放在链表简单题之后、二叉树之前的位置。原因是它不需要你先掌握复杂的递归或动态规划,只需要理解“键值对”这种日常开发里已经非常熟悉的抽象。只要你会写代码,哪怕没系统学过算法,也可以从两数之和开始建立信心。
1.2 哈希题本质上练的是“空间换时间”的思维
刷哈希题最大的收获,不是记住某个 API 怎么调用,而是真正理解空间换时间这句话的分量。
拿两数之和举例。暴力解法是两层循环,时间复杂度 O(n^2),空间复杂度 O(1)。如果允许我额外开一个哈希表,遍历一次数组,每遇到一个数就把 target - nums[i] 查一下,时间复杂度立刻变成 O(n)。代价是额外 O(n) 的空间。这类决策在做工程时同样常见:缓存就是空间换时间最典型的产品级例子,把计算结果存起来,下次直接取,不再重新算。
我刷哈希题时习惯先自己问一个问题:这个题目里,我需要“记住”什么过往信息?两数之和要记住已经出现过的数字;最长连续序列要记住所有数字的集合;和为 K 的子数组要记住前面所有前缀和出现的次数。当你把“需要记住什么”想清楚了,这道题的核心思路基本就浮现了。
哈希表的底层实现特点是,理想情况下读写都是 O(1),但它不是一个数学上保证 O(1) 的神器,而是通过散列函数把任意类型的键映射成数组下标。这个映射过程就是哈希算法。正因为有了这一层映射,我们才能用几乎恒定的时间读写任意键对应的值。
2. 哈希表、哈希算法和字典:先把底层概念拆清楚
2.1 一个哈希表是怎么把“查找”变成 O(1) 的
哈希表本质上是一个数组,数组的每个位置通常被称为槽位或桶。插入一个键值对的时候,先调用哈希函数计算键的哈希值,然后用这个哈希值算出槽位下标,把键值对存进去。查找的时候再用同样的哈希函数算出下标,直接去那个位置取。
这里有个很关键的问题:哈希函数算出来的值可能非常大,而数组长度有限,所以一般会做一次取模,把下标压缩到数组长度范围内。还可能出现不同的键算出的下标相同的情况,这就是哈希冲突。解决冲突最常见的办法是链地址法,也就是每个桶里挂一条链表,超出的元素依次挂到链上。
我用衣帽间来类比:你存衣服的时候,管理员根据取衣服的凭证号(哈希值)把你带到某个储物柜(槽位),凭证号对应的柜子就是你的衣服位置。如果两个人凭证号相同,管理员就把两件衣服挂在同一个柜子里,用绳子(链表)串起来。取的时候先到柜子前,再顺着绳子找自己的那件。好的哈希函数能做到每个柜子只挂一件衣服,这样找起来当然快。
理解这一点之后,你会明白为什么很多刷题笔记里说哈希表是“平均 O(1)”而不是严格 O(1)。如果没有冲突,那就是 O(1);冲突多了,可能要遍历一条很长的链表,退化到 O(n)。工程实现里引入扩容、红黑树优化等手段,目标都是控制冲突率。刷题时我们一般不需要手动实现哈希表,但要理解复杂度分析背后的依据。
2.2 哈希冲突、加载因子与扩容这些隐藏参数
哈希表具体怎么扩容,各语言实现略有不同,但几个核心概念是共通的:加载因子、阈值、重新哈希。
加载因子等于已存储元素数量除以桶数量。加载因子越低,冲突概率越小,但浪费空间越多。Java 的 HashMap 默认加载因子是 0.75,意思是桶用了七成多就触发扩容。Python 的 dict 也类似,装载率超过一定阈值会扩容并重新放置所有键。C++ 的 unordered_map 没有公开加载因子的统一标准,但也存在 rehash 机制。
扩容对刷题有什么影响?最直接的影响是,如果你在写算法题时依赖哈希表遍历的稳定性,比如边遍历边修改哈希表,就很可能踩坑。有些语言的实现会在扩容时改变桶的位置,遍历中的迭代器可能会失效,或者直接抛异常。刷题现场碰见这种问题,很容易怀疑人生。
另外,哈希函数选得好不好,直接影响冲突率。常见的取模机制天生适合整数键,字符串键则需要设计哈希算法。很多常用代码库里用的哈希算法不是简单的求和取模,而是类似 FNV-1a 或者 MurmurHash 这样的算法,目的就是让分布尽可能均匀。刷题时我们不需要自己实现这些算法,但心里要清楚,哈希函数的质量决定了哈希表最坏情况下的表现。
2.3 字典和哈希表到底是不是一回事
哈希表和字典这两个词经常被混着用,但严格说,哈希表是一种实现方式,字典是逻辑上的抽象。字典的意思是“通过键访问值”,能完成这个功能的底层结构有很多种。
例如,C++ 里有 std::map 和 std::unordered_map。std::map 基于红黑树,它也是字典,但它的查找复杂度是 O(log n),不是 O(1),遍历按键的值有序排列。std::unordered_map 才是真正的哈希表,平均 O(1) 查找,遍历顺序不保证有序。你看,同样是“字典”这个语义,底层一个是哈希表,一个是树,性能特性和适用场景完全不同。
C# 里 Dictionary<TKey, TValue> 是哈希表实现,Hashtable 是更古老的非泛型版本。Java 的 HashMap 是哈希表,而 TreeMap 和 LinkedHashMap 又各有不同。Python 的 dict 是哈希表,同时从 3.7 开始保持插入顺序,但顺序有序并不妨碍它是哈希表。
理解这个区别,你就不会在面试里说出“map 底层是红黑树所以查找是 O(logn)”这种片面的结论。正确的说法应该是:在 C++ 里,std::map 是红黑树,std::unordered_map 才是哈希表;在大多数现代语言里,字典类的默认实现就是哈希表。热词里包含“哈希表和字典的区别”,说明这个问题确实经常被面试官拿来考察基础理解。稍不注意就会因为语言背景不同产生误判。
2.4 哈希树和哈希算法在工程中的配合
哈希树这个说法不如哈希表常见,但在某些工程领域非常重要。最有名的是 Merkle 树,也就是经常听说的哈希树。它把数据块的哈希值从叶子节点开始,逐层往上合并,父节点是它下面所有子节点哈希值再次哈希后的结果。
Merkle 树的特性很直观:只要任何一个底层数据块发生变化,向上传递之后根哈希就会改变。用它可以高效地校验两个大文件或分布式系统里大量数据是否一致,因为不需要逐字节对比,只需要比较根哈希,然后沿着路径定位到发生变化的具体分支。这种哈希树应用在文件同步、版本管理、区块链里都有。我在刷题时接触不到这些,但理解之后会明白哈希算法不只是一个配合哈希表的工具函数,它本身可以支撑起一套数据校验体系。
哈希算法本身需要满足几个重要性质:确定性,同一个输入永远产生同一个输出;雪崩效应,输入稍微变化,输出就完全不一样;抗碰撞性,很难找到两个不同输入但哈希值相同的组合。这些性质在密码存储、数字签名、数据完整性校验里都起着决定性作用。这里热词里出现的“哈希树和哈希算法”,在我后来的工程实践里才真正体会到分量。
3. LeetCode Hot 100 哈希题型的识别方法
3.1 三类高频场景:去重、计数、映射索引
刷过两轮 Hot 100 之后,我总结了一套哈希题的粗分类方法,按需求场景去分。
第一类:去重。题目问“是否出现过”“是否存在重复”,典型代表是环形链表、快乐数、只出现一次的数字。这类题用哈希集合最自然,遍历时把见过的元素或状态存进集合,下次遇到直接查询。快乐数之所以能用哈希,是因为不断求各位平方和,如果出现同一个和,说明进入了循环,和链表成环的判断逻辑一样。
第二类:计数。需要统计某个键出现了多少次,典型代表是和为 K 的子数组、字母异位词分组。前者要统计前缀和的出现次数,后者要统计每个字符的出现次数。计数场景里,键往往是某种派生值,而不是元素本身。
第三类:映射索引。一个键对应一个值,这个值可能是数组下标、长度、节点等。两数之和是最典型的例子,把数字作为键,数字出现的下标作为值。后续遍历时直接通过键找下标,就省掉了一层循环。
做题时先判断这道题属于哪一类,再决定用哈希集合还是哈希映射,思路会清晰很多。遇到需要保存“第一个位置”“最后一个位置”这类问题,基本就是映射索引。
3.2 我常用的两个破题套路:快速查找与手动“造键”
哈希题的第二个破题关键,在于“要存什么”。很多题不会直接告诉你存原数组元素,而是需要你构造出一个新键,这个键能唯一代表一类东西。
举个例子,字母异位词分组。如果把单词原样作为键,eat 和 tea 就分不到一组。但如果你把单词按字母排序,eat 和 tea 都会变成 aet,这个排序后的字符串就能作为统一键。这种不改变原数据、而是先变换再作为键的做法,我叫它“造键”。
另外一个经典套路是前缀和。面对数组连续子数组的和等于 K 的题目,直接枚举所有子数组会超时。此时你会先算出每个位置的前缀和,然后想找某个位置到当前位置之间和是否为 K,只要看前缀和等于当前前缀和减 K 的历史出现次数。这里的键不是元素,而是前缀和。从刷题角度看,前缀和加哈希是一个独立的专门技巧,但在哈希题分类里依然跑不掉。
识别题目要用哪种“键”,本质上就是问:这道题里两个对象“相等”的判断标准是什么。用排序后字符串还是用字符计数数组,取决于你对“异位词”的定义;用数字本身还是用数字下标,取决于你要找什么。明白了这一点,很多看起来很难的哈希题就变成造句题了。
4. 精选高频题拆解:一题一坑一收获
4.1 第 1 题 两数之和:必须一边遍历一边写表
两数之和这道题,哈希标准解的写法非常固定:遍历数组,检查当前数字需要的补数 target - nums[i] 是否已经在哈希表里,如果存在就返回两个下标,否则把当前数字和下标写入哈希表。
vector<int> twoSum(vector<int>& nums, int target) { unordered_map<int, int> mp; for (int i = 0; i < nums.size(); ++i) { int need = target - nums[i]; if (mp.count(need)) { return {mp[need], i}; } mp[nums[i]] = i; } return {}; }为什么必须一边遍历一边写,而不是先把所有数字写进去再查第二遍?因为题目要求不能使用同一个元素两次。如果先把所有数据都放进去,当 nums = [3, 2, 4], target = 6 时,先放下标 0 的 3,再查的时候看到 3 在表里,就会误以为 3 和 3 是一对,返回了错误结果。边遍历边写表天然规避了这个问题,因为当前数字还没入表,查到的只可能是前面已经访问过的元素。
这个题的另一个细节是重复数字。如果数组是 [3, 3],target 是 6,一边遍历边更新能正常返回 0 和 1。原因在于第二次遇到 3 时,表里存的是第一次出现的下标 0,查 need = 3 能找到。所以更新逻辑正确时,不会产生歧义。用 C++ 写的时候注意 unordered_map 的 operator[] 在键不存在时会默认插入一个值,所以查询时最好用 count 或 find,避免无意中污染哈希表。这个坑我早期踩过。
4.2 第 128 题 最长连续序列:哈希集合上的起点判断
最长连续序列要求找出数组里能组成连续递增序列的最大长度。暴力直觉是排序后扫描,但题目往往要求时间复杂度 O(n),排序通常是 O(n log n),不满足。哈希给的解法是用无序集合把所有数字存下来,再遍历每个数字,只从连续序列的起点开始数长度。
C++ 代码思路如下:
int longestConsecutive(vector<int>& nums) { unordered_set<int> s(nums.begin(), nums.end()); int ans = 0; for (int x : s) { if (s.count(x - 1)) continue; int y = x; while (s.count(y + 1)) y++; ans = max(ans, y - x + 1); } return ans; }关键判断是 if (s.count(x-1)) continue。只有当前数字没有前驱,即数组中不存在 x-1,它才是一个序列的起点。从起点开始往后数,才能保证每个连续段只扫描一遍,总复杂度才控制在 O(n)。如果从序列中间的任意数字开始数,每个序列可能被重复扫描很多遍,最坏情况会退化。
这个解法表面简单,但我见过很多人在上面纠结“为什么要从起点开始”。如果你在纸上画几个连续数字,比如 4、5、6、7,从 6 开始会数到 7,可你又不知道前面还有 4 和 5,于是等到遍历 4 时还会重新数一遍。只有从 4 这个无前驱数字开始才能一次数全。理解这一点,比背代码重要得多。
哈希集合去重也自然解决了原数组重复数字的问题。如果原数组有重复,排序法还要先考虑去重,哈希集合直接干掉了这个麻烦。
4.3 第 560 题 和为 K 的子数组:前缀和加上哈希计数
和为 K 的子数组这道题,最容易想到的思路是枚举左右端点,把所有子数组的和算出来,复杂度 O(n^2)。数据量一大就超时。优化的核心是:用前缀和把子数组和的计算变成两个前缀和之差。
我先定义 prefix[i] 表示数组前 i 个元素的和。那么从 j 到 i 的子数组和为 prefix[i] - prefix[j-1]。想要这个差等于 K,等价于在前 j-1 个位置中,前缀和等于 prefix[i] - K。如果有哈希表记录每个前缀和出现的次数,这一问就变成 O(1) 查询计数。
实现时我习惯只用一个变量维护当前前缀和,边遍历边更新哈希表,不是先建完整前缀和数组再查。
int subarraySum(vector<int>& nums, int k) { unordered_map<int, int> pref; pref[0] = 1; int cur = 0, ans = 0; for (int x : nums) { cur += x; ans += pref[cur - k]; pref[cur]++; } return ans; }注意 pref[0] = 1 的初始化。它表示空子数组的前缀和是 0,出现次数是 1。如果不初始化,当子数组恰好从头开始且和为 K 时会漏算。这个细节我见过很多人在讨论区问,面试时也容易被追问。
另一个大坑是 C++ 的整型溢出。因为数组元素可能是负数,前缀和在一个很大的范围里波动,计算 cur - k 时也要考虑实际数据范围。严格来说,如果题目没有明确数值范围,边界情况总是存在的。刷题时不要想当然认为数字都很小。
4.4 第 49 题 字母异位词分组:排序键和计数键的选择
字母异位词分组把由相同字符集组成但顺序不同的单词分到一组。常规解法是遍历每个单词,对每个单词内部字符排序,把排序后的字符串作为分组键。Python 写法很直观:
def groupAnagrams(self, strs): groups = {} for s in strs: key = ''.join(sorted(s)) groups.setdefault(key, []).append(s) return list(groups.values())排序键用起来方便,但代价是每个字符串要 O(k log k) 时间,k 是字符串长度。另一种方案是统计每个单词的字符频率,把频率数组转成元组或字符串作为键。比如 "abbccc" 可以用 "(1,2,3)" 表示。这种键也叫计数键,构造时间是 O(k),不需要排序。当字符串比较长时,计数键效率更稳定。
选哪种键没有绝对标准,要结合题目约束。如果字符串长度很大,建议用计数键;如果长度很短,排序键写起来简洁,性能也不差。我在 Hot 100 刷这道题时,先写排序键通过,后来又把计数键写了一遍,对比后体验更直观。两种方案的空间复杂度相同,都是 O(nk),因为要存储所有字符串的分组结果。
这道题对你的哈希思维最有价值的地方,正是“造键”过程。你会发现,所谓分组,本质上就是给每一类对象计算一个代表身份的键,键相同则分到同一组。这个思路不仅适用单词,也能迁移到日后很多业务场景,比如相似商品聚合、日志聚类等。
5. 从刷题走向工程:哈希在存储安全和分布式里的真实角色
5.1 密码存储为什么不直接存哈希,而要加盐
搜索热词里出现“C# 加盐哈希存储”,说明大家关心哈希在工程安全里的作用。这个话题很多人写过,但我还是想从刷题人视角再讲清楚。
直接存储用户密码明文是绝对禁止的。传统做法是存储密码的哈希值,登录时把用户输入哈希一下再对比。问题在于,如果攻击者拿到哈希库,可以用彩虹表离线反查:预先算好海量常见密码的哈希值,直接匹配就能还原出用户的原始密码。同一个密码在所有用户那里哈希值都相同,只要一个用户密码被猜中,所有用同一个密码的用户都失守。
加盐的作用非常简单:在原始密码后面拼接一段随机生成的字符串,再对拼好的内容做哈希。每个用户使用不同盐值,这样相同密码在不同用户名下的哈希值完全不同,彩虹表直接失效。就算攻击者拿到了密码哈希和盐值,也需要对每个用户单独做暴力尝试,成本高出几个数量级。
加盐哈希还需要配合慢哈希算法使用。普通哈希算法如 MD5、SHA-256 计算速度快,反而方便了攻击者暴力枚举。工程上更推荐 PBKDF2、bcrypt、scrypt、Argon2 这类故意设计成需要大量计算和内存的算法。C# 里的实现可以基于 Rfc2898DeriveBytes,通过迭代次数控制耗时。刷题时我们接触到的哈希是解决查找效率的,但哈希算法在密码学里追求的是相反方向:计算速度慢、防碰撞、防枚举。理解这种反差,可以帮助你更清楚地认识哈希这个宽泛概念的多面性。
5.2 一致性哈希和哈希树的实际价值
哈希不只是数据结构,也是一种负载均衡手段。常见例子是分布式缓存需要把不同的 key 分配到不同节点。最简单的方法是 hash(key) % N,但如果节点数量变化,比如服务器宕机或扩容,绝大多数 key 都会重新映射,导致缓存大批量失效。一致性哈希通过在哈希环上为节点和 key 同时计算哈希值,把 key 顺时针分配到第一个节点,节点增减时只影响邻近一小部分 key。
一致性哈希里有“虚拟节点”的概念,目的是让节点在环上分布更均匀,避免某一台机器压力过大。理解这些不需要太高深的数学知识,本质上还是“哈希函数 + 环状数据结构”的组合。很多后端面试喜欢从缓存聊到一致性哈希,这时候你至少能说出它解决的问题和引入的代价。
哈希树则是校验数据完整性的利器。在文件同步和多副本存储场景中,我只需要比较根哈希就能判断数据是否变更。如果要找出具体哪一部分变了,就沿着树的路径往下找,非常高效。刷题本身不会直接刷到哈希树,但热词里提到“哈希树和哈希算法”,我觉得有必要把这块知识补齐。你掌握的哈希概念越完整,写代码时对“哈希到底能干什么”的理解就越立体。
6. 面试和实操中常见的哈希坑,能避一个是一个
6.1 我在刷题和工程里反复踩过的编译期和边界坑
先说语言层面最常见的坑。在 C++ 里用 unordered_map 存储自己定义的结构体作为键时,如果不提供自定义哈希函数和相等判断,编译会直接失败。标准库不知道怎么给一个自定义 class 算哈希,需要你显式实现 std::hash 或传入仿函数。C# 里类似,如果自定义类作为 Dictionary 的键,没有重写 GetHashCode 和 Equals,就可能出现同一个逻辑上的 key 被当成不同 key 存储。
还有一个高频边界坑:和为零或者和为负数的情况。和为 K 的子数组里,K 可能是负数,cur - k 就可能是当前前缀和加上一个正数,完全正常。但如果你固化了“K 大于等于 0”的思维,遇到负数测试用例就会懵。再比如两数之和里 target 可能是负数,不影响算法,但会影响你调试时的猜测方向。
遍历时修改哈希表是另一个经典雷区。C# 里 foreach 遍历字典时修改会抛 InvalidOperationException。Java 里边遍历边 put 也可能触发 ConcurrentModificationException。刷题时一旦涉及“边查边写”,要明确知道是不是在同一个循环里修改容器。两数之和的边查边写是安全的,因为它只是对已经存在的 key 做更新,以及插入新 key,但不同语言对这种操作容忍度不同,最好养成先查再改的习惯,避免在迭代器上直接改。
6.2 一套哈希题自查清单,做题前默念三遍
我把哈希题的常见坑整理成清单,每次卡住都对着看。
- 我要存的东西是原值,还是变换后的“键”?如果是分组,一定要先想清楚分组依据。
- 用哈希集合还是哈希映射?只判断存在用集合,要记录下标或次数用映射。
- 初始值要不要预置?和为 K 的子数组里 pref[0] = 1 就是一个典型。
- 数字范围会不会溢出?C++ 里前缀和、累加和用 long long 更稳妥。
- 遍历时会不会出现元素本身在表中被误查?两数之和需要用边遍历边写避免。
- 自定义对象作为键时,是否重写了哈希和相等函数?工程代码里尤其要注意。
这些检查点看起来分散,实际上一一对应了我在前几章提到的所有易错场景。如果你在做题时先思考“这个哈希表里的键到底是什么”,大概率能规避掉将近七成的坑。
该清单在真实工程中也有指导意义。我在实现业务缓存时,经常要自己设计一个对象作为缓存 key,它包含多个字段。此时如果不实现棋等和哈希,缓存命中就会变成灾难。这和解算法题是一回事。换个角度想,Hot 100 里的哈希题给了你一个低成本练习这种工程意识的机会,这才是它最大的价值。
最后再分享一个我个人觉得特别实用的习惯
我把所有哈希相关的 Hot 100 题放到同一个笔记里,每道题旁边只写一句话:这个哈希表存的键是什么,值又是什么。比如两数之和是“数字 -> 下标”,最长连续序列是“数字是否存在”,和为 K 的子数组是“前缀和 -> 次数”,字母异位词分组是“排序后的字符串 -> 原词列表”。整理完整份笔记之后,我发现所有解法串成了一条清晰的逻辑线。下次不管是刷新题还是面试复盘,我都能从记忆里快速调出“这题在考哪种哈希用法”。这个方法也推荐给你,量不用大,二十题左右就能看到效果。