
1. 为什么哈希是算法题里的“万金油”1.1 从一道“两数之和”看哈希解决的问题凡是刷过 LeetCode 的人几乎都把两数之和放在第一题刷过。题目长这样给定一个整数数组nums和一个整数目标值target请你在该数组中找出和为目标值的那两个整数并返回它们的数组下标。我最早做这道题的时候脑子里最先蹦出来的思路是两层循环。外层固定一个数内层找另一个数判断两者之和是否等于target。这就是暴力法时间代价是 O(n²)。数组规模小的时候没问题但面试官只要把 n 提到十万、百万级别O(n²) 就彻底跑不动了。痛点其实很清晰每次内层循环都是在“查找”一个数。数组本身是无序的顺序查找只能从头扫到尾一次查找就是 O(n)。外层再套一次循环乘起来就是 O(n²)。那能不能把查找的代价降下来这就是哈希表登场的地方。哈希表在平均情况下可以在 O(1) 时间内完成“一个键是否存在”以及“这个键对应的值是什么”的查询。你只需要在一遍遍历的过程中把已经见过的数放进哈希表里同时检查target - nums[i]是否已经在表中。如果存在说明之前遇到过能与当前数凑成目标值的那一个下标直接从哈希表里拿出来。def two_sum(nums, target): seen {} for i, num in enumerate(nums): if target - num in seen: return [seen[target - num], i] seen[num] i return []这里有一个非常容易被新手忽略的细节必须是“边遍历边放入”而不是先把所有元素一次性塞进哈希表再遍历。如果先把全部元素放进去当数组中存在两个相同元素且它们的和恰好等于target时你就会把同一个下标的元素当成两个数来用。举个例子nums [3, 3], target 6一次性存入的话seen[3]只能保存最后一次出现的下标你永远无法同时拿到两个 3 的下标。而边遍历边存前一个 3 已经在表里后一个 3 来的时候刚好能查到。从暴力法的两重循环降成单层循环加哈希查询时间复杂度变成 O(n)空间换时间。这也是哈希题目的核心思维方式当某个“查找”操作反复出现且成为性能瓶颈时就用哈希表把查找成本压到常数级别。1.2 哈希表的底层原理函数、冲突与扩容很多人刷题用了很多次哈希表但遇到“为什么哈希 O(1)”这个问题时却说不太清楚其实这个原理搞懂了很多坑都能提前避开。哈希表本质上就是“数组 哈希函数”。数组天然支持按下标 O(1) 访问问题在于数组下标必须是整数而且不能太大。但现实中的键往往是字符串、浮点数、元组、自定义对象。这时候就需要一个哈希函数把这些任意类型的键计算成一个整数再用这个整数去映射数组的下标。常见做法是hash(key) % capacity也就是计算出一个哈希值然后对数组长度取模。哈希函数不可能是完美的两个不同的键很可能算出同一个数组下标这就叫哈希冲突。解决冲突最常见的手段有两种一种是链地址法让数组的每个位置都挂一条链表冲突的元素依次挂上去另一种是开放寻址法冲突了就向后探测空位比如 Python 的dict用的就是开放寻址的思路。链表法的查询复杂度取决于每条链的长度。当元素越来越多链表越来越长查询退化得很严重。所以哈希表内部会维护一个“负载因子”也就是已存储元素数量与桶数量的比值。当负载因子超过阈值Java 的HashMap是 0.75就触发扩容把底层数组翻倍然后所有键重新计算下标位置。这也是为什么哈希表的 O(1) 是“均摊”出来的单次操作偶尔会因扩容变慢但整体来看每个操作的平均代价仍然是常数。有一个生活化类比很合适图书馆里的索引卡片柜。你要找一本书不是一排一排书架挨着翻这就是顺序查找也不是记住每本书精确到毫米的摆放位置这就是数组直接按下标访问而是先去索引柜查一下书的位置编号再到对应书架区域去取。编号就是哈希值书架区域就是哈希桶多个索引卡指向同一排书架就是哈希冲突。1.3 什么时候用哈希什么时候用普通数组刷题刷久了会发现很多时候看似该用哈希表的地方用普通数组反而更快。关键判断依据有三个键的类型是不是整数、键的取值范围是不是有限、取值是否连续。最典型的例子是统计字符串中每个字母的出现次数。如果字符串只包含小写字母只有 26 个可能键完全没必要开一个dict。直接开一个长度为 26 或 128 的数组用字符的 ASCII 码或相对偏移做下标既省哈希计算的开销又能保证严格的 O(1) 时间。反过来如果键的取值很离散比如索引值可能达到 10^9 级别就不可能开那么大的数组。比如题目给你 10 万个整数取值范围从 -10^9 到 10^9你总不能开一个长度 2 * 10^9 的数组。这时候哈希表就派上用场只给真正出现的 10 万个键分配存储空间。数组的优势是极致的速度和零哈希冲突缺点是空间必须连续分配且下标必须能承载所有键。哈希表的优势是空间紧凑、键类型灵活缺点是有哈希计算开销、存在冲突概率、平均 O(1) 但最坏可能退化到 O(n)。这个判断在做题时直接影响代码的常数性能。很多人在 LeetCode 上遇到“输出超时”的边界情况往往不是时间复杂度设计错了而是常数太大。能用数组代替哈希表的场景一定要用数组这一点在后面章节我会结合具体题目再展开。2. 不同语言刷哈希题的正确打开方式2.1 Pythondict 与 set 的惯用法Python 刷哈希题核心就是dict和set。两者底层结构一样唯一区别是set只存键、不存值。刷题时应该掌握几个高频的惯用法别每次都用最笨的“先判断 in再手动赋值”写一长串。第一个是d.get(key, default)。统计字符频率时新手最容易写成这样if c in counter: counter[c] 1 else: counter[c] 1其实两行就能搞定counter[c] counter.get(c, 0) 1get在键不存在时返回默认值 0这样既省去判断分支代码也简洁很多。如果统计的目标是字符串里字符出现的频率直接用collections.Counter更合适它本质上就是一个带了很多便利方法的dict子类。第二个好用的是setdefault(key, default)和defaultdict。它们的区别在于处理方式setdefault是“如果键不存在就塞入默认值并返回默认值如果存在直接返回已有值”而defaultdict是“访问不存在的键时自动创建默认值”。比如做字母异位词分组时你需要把相同特征的字符串追加到同一个列表里用setdefault一行就能解决groups.setdefault(key, []).append(word)如果写成groups[key].append(word)而 key 不存在直接抛KeyError。第三个必须背下来的坑Python 的 dict 键要求对象可哈希。list、dict、set这些可变容器不能直接作为键。很多题目里你想把一个数组作为键直接写就会报TypeError: unhashable type: list。解决办法是把数组转成不可变类型比如转成tuple或者转成排序后的字符串。这个在 3.2 节的异位词分组里会正经遇到。最后特别提醒一点遍历 dict 的同时直接删除元素会触发RuntimeError: dictionary changed size during iteration。需要边遍历边筛选时要么用列表推导构建新字典要么先把要删的键收集成一个列表遍历完了再统一删除。2.2 JavaHashMap 与 HashSet 的底层差异Java 刷题侧重点跟 Python 有些不一样。Java 生态里最常用的是HashMap和HashSet两者底层都是哈希表HashSet其实就是内部包了一层HashMap值统一用一个占位对象。理解这点后你就知道 HashSet 的操作复杂度与 HashMap 完全一致。HashMap在 Java 8 之后底层的实现是“数组 链表 红黑树”。当桶内链表长度超过阈值 8 且总桶数大于 64 时链表会转成红黑树查询复杂度从 O(n) 优化成 O(logn)。这说明即使哈希冲突很严重现代哈希表也能兜底不至于真正退化成线性扫描。Java 刷题还有一个容易忽略的优化点初始容量。HashMap默认容量是 16负载因子是 0.75意思是当元素个数达到 12 就会扩容。扩容时所有元素重新计算哈希并搬运虽然均摊下来还是 O(1)但高频扩容会带来很大常数开销。如果你知道题目里最多会有多少元素比如 10 万个字符串就应该一开始设置初始容量为 10万 / 0.75 1 ≈ 133334。这样 10 万个元素全部放进去也不会触发扩容。面试时能随口说出这个计算过程非常加分。computeIfAbsent是另一个高频 API作用和 Python 的setdefault类似。构建“键 - 列表”的分组结构时Java 的惯用写法是map.computeIfAbsent(key, k - new ArrayList()).add(word);如果键不存在就创建空列表并放入如果存在直接返回已有列表。这一行代码替代了“先判断 containsKey再 get再 put”三行逻辑省事且不易出错。遍历时也有讲究。如果在遍历HashMap的过程中直接调用put或remove会抛出ConcurrentModificationException。要修改就用Iterator的remove方法或者先收集需要删除的键再统一处理。2.3 C数组代替哈希表的极致优化C 里做哈希题大家常用的容器是unordered_map和unordered_set。它们底层是哈希桶平均 O(1) 查询。但 C 场景下我特别建议养成一个习惯能用数组就先别上unordered_map。举个最典型的场景判断两个字符串是否为字母异位词。如果字符串只含小写字母只要开一个长度为 26 的int数组遍历字符串统计频率即可完全不需要哈希表。bool is_anagram(string s, string t) { int cnt[26] {0}; for (char c : s) cnt[c - a]; for (char c : t) cnt[c - a]--; for (int x : cnt) if (x ! 0) return false; return true; }为什么数组比unordered_map快一是省去了哈希函数计算二是数组访问是真正的 O(1)不需要处理冲突更不可能出现链表退化三是局部性更好、缓存命中率高。C 编译器对固定数组的优化也极度激进这点优势在性能极限的竞赛场景里非常明显。之前我在 LeetCode 上跑过一组字符频率统计的对比数据量是 500 万字符的随机小写字符串数组方案比unordered_map方案快了将近十倍。刷题时感受可能不明显但生产环境做实时流量分析时这个差距就是能不能扛住峰值请求的差距。当然并不是所有场景都能用数组。字符集范围太大、键是字符串或其他复杂类型、取值范围不连续时该上哈希还是得上。核心原则是“先分析键的分布再选容器”。3. 必刷经典哈希题的分类拆解3.1 查找类两数之和与其多种变体两数之和是哈希题的第一课但它远不止一道题这么简单。围绕它至少有三个变体每个变体都对应不同的哈希使用姿势。第一个变体是“数组有序找两个数之和等于 target”。这时候双指针是更好的解法左右指针从两端向中间靠时间复杂度 O(n)空间复杂度 O(1)。哈希反而会多花一份空间。这个变体告诉我们哈希不是唯一的正确答案空间受限时要把其它手段放在考虑范围内。第二个变体是“给定数组输出所有和为 target 的不重复二元组”。这时候哈希表要配合排序去重或者更稳妥的做法是先排序再用双指针。哈希在这类变体的价值在于快速判断某个数是否存在而不必真的把所有组合都展开。第三个变体是“两数之和 II输入多组测试”。如果你要反复处理大量查询可以先把数组预处理进哈希表之后每个查询平均 O(1) 回答。这种“预处理 查询”的思维在工程里极其常见比如词频统计后回答“某个词出现几次”本质就是一次哈希查询。面试时更常见的是把两数之和扩展成三数之和、四数之和。三数之和的核心套路是固定一个数然后对剩下的两个数用双指针。但如果允许使用额外空间也可以把两数之和的哈希方案内嵌进外层循环。需要说明的是三数之和里双指针通常更优因为题目要求去重哈希去重的实现边际条件很多很容易漏掉。我见过不下五次面试者写哈希版三数之和写到一半自己都绕晕了最后用双指针简洁收场。我的建议是多指针能解的题优先双指针哈希留给那些没有顺序信息的场景。3.2 分组类字母异位词分组的哈希键设计LeetCode 第 49 题“字母异位词分组”是一道考察哈希键设计能力的经典题。给你一组字符串把字母异位词分到同一组。所谓字母异位词就是组成字母相同、排列顺序不同的词比如ate、eat、tea是一组。核心问题是怎么让同一组词计算出同一个哈希键两种主流方案。第一种是“排序键”。每个字符串按字母排序后作为 keyate和eat排序后都是aet自然落到同一组。实现极简from collections import defaultdict def group_anagrams(strs): groups defaultdict(list) for s in strs: key .join(sorted(s)) groups[key].append(s) return list(groups.values())复杂度是 O(n × k log k)其中 k 是字符串最大长度。排序的开销主要集中在长字符串上。第二种是“计数键”。统计每个字符串中每个字母出现的次数把这个计数序列作为 key。因为字母一共 26 个计数序列可以直接用一个长度为 26 的元组表示。eat的计数序列是(1, 0, 0, 0, ..., 1, 0, ...)tea的计数序列完全一样。复杂度变成 O(n × k)比排序方案更优尤其适合字符串特别长的场景。Python 里这两种方案都能直接用元组做键实现很干净。我实际测试过当字符串平均长度超过 20 时计数键方案明显快于排序键而当字符串很短时两者差距不大。面试时先说出两种方案再对比复杂度考官通常会认可你对哈希键设计的理解深度。这个题的延伸方向也很多比如“判断两个字符串是否为字母异位词”LeetCode 242就是只处理一个分组的最小版本“找到所有字母异位词在字符串中的起始下标”LeetCode 438则是分组题加上滑动窗口的组合题。键值设计一旦想清楚这些衍生题都比较顺。3.3 序列与子数组类哈希沉淀中间结果最长连续序列LeetCode 128是另一道必刷题。给定一个未排序的整数数组找出数字连续的最长序列的长度。比如[100, 4, 200, 1, 3, 2]最长的连续序列是1, 2, 3, 4长度 4。最容易想到的办法是先把数组排序然后一遍扫描统计连续段长度复杂度 O(n log n)。但题目要求 O(n)这就必须依靠哈希表去重和快速判断元素存在。一个巧妙的思路是用set存下所有元素然后只对“连续序列的起点”往上累加。怎么判断一个数是不是起点就看它的前一个数num - 1是否在集合里。如果不在说明num是一段连续序列的起点此时从num开始不断找num 1、num 2直到断掉为止。def longest_consecutive(nums): nums_set set(nums) longest 0 for num in nums_set: if num - 1 not in nums_set: current_num num current_len 1 while current_num 1 in nums_set: current_num 1 current_len 1 longest max(longest, current_len) return longest为什么这个做法整体还是 O(n)因为内层 while 循环只在“每个连续序列的起点”处触发而且每个元素最多被访问常数次。如果每个数都往上跳那只有一次不是起点的数外层循环直接跳过不做内层扫描。这就是典型的状态沉淀思维先判断状态是否值得深入再决定是否展开计算。另一个有代表性的子数组类是“和为 K 的子数组”LeetCode 560。题目要求统计数组中和为 K 的连续子数组个数。朴素做法枚举子数组起点和终点是 O(n²)。哈希优化思路是用前缀和。prefix[i]表示从数组开头到第 i 个元素的累加和。那么从下标 j1 到 i 的子数组之和就是prefix[i] - prefix[j]。要让它等于 K等价于prefix[j] prefix[i] - K。也就是说遍历到位置 i 时查一下前面出现过多少次prefix[i] - K这个前缀和答案累计上这个次数即可。这里必须边遍历边更新哈希表把当前前缀和的出现次数加进计数里。注意初始时要设置prefix_sum[0] 1这样当prefix[i]本身就等于 K 时能正确统计到从开头开始的子数组。这道题是“前缀和 哈希计数”的典型结合对理解哈希如何沉淀历史计算结果非常有帮助。3.4 滑动窗口与哈希的搭场滑动窗口是处理子串问题的利器但每逢涉及“去重”“频率统计”哈希的配合几乎就是标配。最经典的还是 LeetCode 第 3 题“无重复字符的最长子串”。题目要求找到不含有重复字符的最长子串的长度。暴力做法枚举所有子串并检查是否重复复杂度 O(n²)数据稍大就会超时。用滑动窗口加哈希表可以用 O(n) 时间完成。一种直观写法是用set维护窗口内的字符集合右指针不断扩展遇到重复字符时左指针右移同时把左指针指过的字符从set中删除直到窗口内没有重复。这是最基础的双指针写法逻辑清晰但每次移动左指针都要做删除操作。更高效的做法是用dict记录每个字符最近一次出现的位置。遍历到位置 i 时如果字符s[i]之前出现过且位置在窗口内窗口起点直接跳到上次出现位置的后面而不是一步步挪def length_of_longest_substring(s): last_pos {} left 0 max_len 0 for i, char in enumerate(s): if char in last_pos and last_pos[char] left: left last_pos[char] 1 last_pos[char] i max_len max(max_len, i - left 1) return max_lenleft的跳跃式前进就是哈希表的价值所在。普通的滑动窗口每次移动一个位置而哈希给了你“跳到正确位置”的能力。这种“索引 状态修改”的模式在字符串处理的题目里可以用在很多地方比如“找到所有字母异位词”那道题就是用哈希表记录窗口内字符频率再和目标字符串的频率表逐项比较。4. 常见错误与面试踩坑实录4.1 先查后存还是先存后查顺序决定成败两数之和这道题我已经在前面强调过一次但因为这个错误真的太高频了还是要单独拿出来说。很多人第一次写的时候习惯先把全部元素放进哈希表然后第二次循环里直接查找target - nums[i]结果在下标是同一个元素的问题上栽了跟头。就算你避免了全量预存也还有一种更隐蔽的写法错误在同一轮循环里先执行if target - num in seen再seen[num] i顺序如果反了比如先存入当前元素再查询那么当target - num恰好等于 num 本身时就会查到当前元素自己。省去if判断直接返回结果大概率踩雷。这个原则放到其它题里同样适用。和为 K 的子数组里必须先在哈希表查询prefix[i] - K再把当前前缀和放入计数。反过来就会把刚计算出的前缀和也计入历史恰好计算出的是“同一个位置的前缀和差为 0”这种情况导致统计重复。做题多了你会发现很多看起来“只是顺序问题”的代码差别实际对应的却是完全不同的逻辑语义。我总结一个帮自己记忆的口诀先查旧账再记新账。哈希表只存“历史信息”当前元素要先作为查询条件用完之后再入账这条规则能避开绝大多数边界错误。4.2 键值设计什么能当键什么不能哈希表的时间复杂度是“平均 O(1)”但这是在哈希函数质量正常的前提下。如果键设计得有问题要么计算复杂要么容易冲突要么根本存不进去。Python 里最常见的坑是拿list当键。有些题目需要把数组整体作为键直接写会抛异常。我的建议是遇到这种情况第一步思考数组里的元素是否适合转成元组第二步思考能否用业务特征作为键。比如字母异位词分组里用排序字符串或者计数元组本质上就是在寻找一个“能代表等价类但不泄露具体内部顺序”的键。Java 里另一个高频坑是自定义对象的 hashCode 不重写。面试中如果题目让你用自定义类作为HashMap的键必须同时重写equals和hashCode两者要基于相同的字段计算否则对象相等但哈希值不同直接导致get查不到。键的粒度也值得留意。有些时候你不需要把整个数据结构作为键而是提取一个“签名”。比如判断两个数组是否含有相同元素签名可以是排序后的元组判断两个字符串是否字符组成相同签名可以是频率表。签名设计得越紧凑哈希查找越快冲突概率越低。4.3 复杂度的误判哈希不是银弹刷题群里经常有人说“哈希是万能的”这其实是误区。哈希表的平均 O(1) 有几个隐藏条件哈希函数足够均匀、冲突足够少、负载因子合理。如果题目故意构造了导致大量冲突的数据Python 的字典因为有随机化种子一般问题不大但某些基于固定哈希函数的语言容器可能被人为构造的输入卡到 O(n)。更微妙的问题是常数开销。哈希表每次操作要计算哈希值、处理可能的扩容、应对缓存不命中这些都比数组访问慢一个数量级。当 n 很小时比如几十个元素用哈希表反而比扫描数组更慢。所以刷题时看到数据规模小于 100直接考虑 O(n²) 暴力都能过不必强行哈希。还有一个常被忽略的限制空间。有些题目明确要求空间复杂度 O(1)比如“只出现一次的数字”LeetCode 136哈希表虽然能解但空间不达标这时候要用位运算。我的建议是做题前先把时间空间两个约束读懂再决定哈希是不是合适的工具。哈希适合“空间宽松、查找频繁”的场景如果空间本身就紧张就要考虑排序、双指针、位运算这些替代方案。5. 从刷题到工程把哈希用出生产价值5.1 缓存与去重工程里每天都在用哈希很多觉得算法题“只是应付面试”的人其实没意识到哈希表在工程里就是基础设施。后端服务处理请求时最常见的优化手段就是加一层缓存。缓存系统的核心结构往往是“键 - 值”映射这个映射在内存中的实现就是哈希表。举个具体的例子用户查询商品详情时每次都要查数据库、拼装返回结构代价几十毫秒。如果用一个哈希表把商品 ID 映射到已经拼装好的详情对象那么命中缓存时只需要一次哈希查找耗时降到微秒级。这就是典型的时间换空间的取舍和两数之和里用哈希表换掉一层循环是同一个思维。幂等性和去重也一样。在消息队列中保证“同一条消息不被重复消费”每个消费者都会维护一个已处理消息 ID 的集合这个集合就是一个set。判断某条消息是不是第一次来本质上就是一次哈希查找。学会在算法题里正确使用哈希的人写这种代码会有天然的敏感度知道什么时候该用dict、什么时候该用set、什么时候该用数组。5.2 哈希指纹和数据校验的玄学问题搜索引擎热词里有一条“视频重新导出之后哈希值和指纹改变吗”很多非程序员也对哈希值感兴趣。哈希在工程上有另一类应用数据校验和内容指纹。比如下载文件后计算 MD5 或 SHA-256对比官方给出的哈希值判断文件是否完整。文件和视频重新导出之后哪怕画面看起来一模一样哈希值大概率也会改变因为哈希是对文件的每一个字节进行计算的重新编码会改变压缩参数、元数据、时间戳、像素采样细节任何一位的变化都会导致最终哈希值完全不同。所以“哈希值是否能作为视频内容指纹”这个问题取决于你要比较的是“原文件”还是“视觉内容”。如果是视觉内容层面的比较应该用感知哈希它不是比较字节而是把图片缩小到固定尺寸、计算灰度、统计像素均值后生成一串指纹允许轻微差异。做过视频重复检测、图片查重的人都对这个有所体会。工程思维里有一个很重要的原则选择哈希的前提是明确“同等”的定义。算法题里选择异位词的哈希键也是同样的道理先明确哪些属于同一类再决定什么作为键。5.3 我的刷题复盘与建议刷了几年算法题带过不少新人之后我对哈希相关的学习路径有了比较明确的看法。优先级上性价比最高的几道题我都列在文章里了两数之和、有效的字母异位词、字母异位词分组、最长连续序列、无重复字符的最长子串、和为 K 的子数组。这六道覆盖了查找、分组、序列、滑动窗口、前缀和五大高频场景吃透它们比盲目刷 50 道重复题有效得多。刷题手法上我建议不要只看题解就划走。我自己的习惯是每道题至少写两遍第一遍不看任何提示能写多少写多少第二遍对照最优解重点比较键值设计和更新顺序的差异。很多哈希题的边界问题不亲手踩一次看十遍题解也记不牢。最后分享一个小技巧遇到哈希题时先在注释里写清楚“我要把什么作为键什么作为值为什么要这样设计”写完这三句话再动手。大多数时候你能把这三句话写清楚代码就已经在脑子里自动成型了。这个习惯帮我节省了大量改 bug 的时间也推荐给你试试。