尧图精选

哈希表底层原理与字典区别:手写实现、刷题用法与实战踩坑记录

🕒 发布时间:2026/10/1 10:46:03 📁 来源:尧图网络
翻开我之前的学习记录这两天一直在跟哈希表死磕。今天是第6天也是哈希表专题的第二轮。比起第一轮那种“原来还有这么神奇的数据结构”的新鲜感这一轮我重点盯的是两件事一是哈希表的底层实现到底怎么回事二是大家天天挂在嘴边的“哈希表”和“字典”究竟是不是同一个东西。如果你也在系统复习数据结构或者在准备面试又或者在项目里总是遇到“HashMap查询慢”“dict内存大”之类的困惑这篇文章应该适合你。我会从原理、实现、刷题用法、实战踩坑四个维度把这几天摸出来的经验完整记录下来。全程没有绕弯的教科书话术都是我自己手写、实测、跑过例子的结论。1. 哈希表的底层逻辑它在解决什么问题1.1 哈希表本质上是一套“空间换时间”的方案先聊一个基础问题在没有哈希表之前我们想存一堆“键值对”并快速按key取值该怎么办最笨的方案是线性表像个档案柜一样一格一格摆好。插入的时候直接丢在末尾很方便但查找的时候得从头翻到尾数据量一大就变成灾难。另一种方案是用有序结构比如二叉搜索树、跳表查找是 O(log n)已经好了很多但仍然不是“一步到位”。哈希表的思路完全不一样。它先把key通过一个“哈希函数”加工成一个数字再让这个数字直接对应到数组的下标。想象你去图书馆借书管理员并不满书架翻找而是根据索书号直接走到对应的那一排那一格。存储和读取都精确到位置不用对比、不用遍历。这就是为什么哈希表在平均情况下能做到 O(1) 的插入、删除、查找。代价是什么呢是一个容量确定的数组。数组开小了装不下几组数据开大了内存空着也是成本。典型的以空间换时间。我在实际写代码时经常用一句话给自己提神哈希表不是“存数据的地方”而是一张“根据key直接定位地址的地图”。只要哈希函数稳定、冲突足够少这张地图就能让查询像读数组一样快。1.2 哈希函数的选择与冲突处理的取舍哈希函数是哈希表的心脏。它负责把任意类型的key字符串、数字、对象映射成数组下标。最基础也最常见的做法是取模运算index hash(key) % capacity但这里有几个细节非常影响效果。第一hash(key) 本身要足够“散”。如果写了一个糟糕的哈希函数比如把字符串每个字符的ASCII码简单相加那么 “abc” 和 “cab” 会得到完全相同的值冲突率会直线上升。关于这点我在手写实现时特意参考了 Java String 的经典思路对每个字符执行result result * 31 charValue。乘以 31 是个经验值因为 31 是质数能降低碰撞概率而且现代CPU对n*31可以用(n5)-n的位运算优化速度快。第二capacity 的选择有讲究。如果数组长度是 2 的幂取模运算hash % capacity可以等价替换成hash (capacity - 1)位运算会比除法快不少。Java HashMap 就是这么干的默认容量 16扩容时翻倍始终保持 2 的幂。但这么做有一个副作用——低位的分布直接决定了哈希质量所以 Java HashMap 在计算下标前还会对 hash 值做一次“扰动”处理把高位的随机性扩散到低位。第三冲突处理方案要在“时间”和“空间”之间权衡。主流有两种链地址法数组的每个格子不是存一个元素而是挂一条链表。冲突的元素直接挂在同一个链表后面。Java HashMap、C unordered_map 都是这种思路。开放寻址法某个下标被占了就按某种探测规则去找下一个空位。Python 的 dict 底层用的就是开放寻址法配合随机探测的序列。我个人的体会是链地址法实现简单、删除方便但链表太长后查询会退化开放寻址法对缓存更友好、内存紧凑但删除操作处理起来很麻烦装得太满时性能会急转直下。这也是为什么 Python dict 虽然“内存占用大”但实际查询非常快的原因之一。2. 哈希表和字典到底是什么关系2.1 从“字典”这个词说起抽象与实现的区别“哈希表”和“字典”的区别是我这次复习最先想通的点也是很多人面试时被问懵的地方。先明确一个概念字典Dictionary/Map是一种抽象数据类型ADT。它定义的是“能做什么”——提供 key 到 value 的映射支持 put、get、delete 这几类操作。至于内部用数组还是树字典本身并不关心。哈希表则是一种具体的数据结构。它用数组 哈希函数来实现“根据 key 定位”的机制。也就是说哈希表是“字典”众多实现方式中的一种而且是目前最主流、性能最好的一种。用一个类比你想实现“按姓名查电话号码”的功能这是字典的需求你决定用一本按姓氏拼音排序的通讯录来实现这本通讯录就是哈希表。需求层和实现层不在同一个维度。为什么不把所有字典都用哈希表实现因为哈希表有一个明显短板它天生无序。如果你还需要按 key 排序遍历哈希表就无能为力了这时可以选择红黑树比如 Java TreeMap、C std::map或者跳表。2.2 不同语言里的实现差异对比当你翻开各种语言的源码能看到一件很有趣的事同样是“字典”这个抽象概念底层选型完全不同。为此我整理了一张对比表。语言/库名称底层实现冲突处理key是否有序线程安全JavaHashMap哈希表链地址法 链表转红黑树否否JavaHashtable哈希表链地址法否是全方法加锁性能差JavaTreeMap红黑树无是否Pythondict哈希表开放寻址法是3.7 保序否受GIL保护Cstd::unordered_map哈希表链地址法否否Cstd::map红黑树无是否Gomap哈希表桶 溢出桶否否这张表给我最大的冲击是Python 的 dict 在 3.7 之后居然保持了插入顺序同时底层还是哈希表。之前我一直以为 Python dict 的“有序”是用了什么额外结构后来查源码才知道它是在原来的稀疏数组之外加了一个紧凑的索引表让遍历时能按插入顺序读取。这说明“哈希表无序”不是一条铁律关键还得看怎么设计内部布局。2.3 面试和项目里怎么回答它们的区别如果你在面试中被问到“哈希表和字典的区别”我总结了一套稳的回答框架分三层。第一层定义本质。字典是存储键值对映射的抽象数据结构哈希表是实现这一抽象的一种具体底层结构基于数组和哈希函数提供 O(1) 的插入、查询、删除。第二层语言命名。Python 里把“用哈希表实现的字典”直接叫做 dictJava 里有 HashMap、Hashtable、ConcurrentHashMap 之分C 里 unordered_map 才是哈希表而 map 是有序红黑树。看到语言命名差异就要意识到“字典”和“哈希表”不是等价关系。第三层能力边界。哈希能解决无序键值映射问题有序需求就得换树或跳表哈希表还能用于去重、集合、缓存、布隆过滤器等场景这些都不叫“字典”。如果在项目里做技术选型我会再加一层需要线程安全的时候Java 选 ConcurrentHashMap需要按 key 范围查询时选 TreeMap内存要求苛刻而读多写少时可以考虑开放寻址式的自定义哈希表。选型前先把“字典”这个需求拆开看你才知道底层该用什么。3. 把原理落到代码手写一个可用的哈希表3.1 基础版链地址法的完整实现要把哈希表真正吃透光看别人的代码是不够的。我自己的经验是手写一遍比看十遍源码都有效。下面是我在复习第6天写出来的一个可运行的简化版哈希表用 Python 实现基于链地址法。class SimpleHashMap: def __init__(self, capacity16): self.capacity capacity self.buckets [[] for _ in range(capacity)] self.size 0 self.threshold int(capacity * 0.75) def _hash(self, key): # 参考 Java String 的哈希思路result result * 31 char total 0 for ch in str(key): total (total * 31 ord(ch)) % self.capacity return total def put(self, key, value): idx self._hash(key) bucket self.buckets[idx] for i, (k, v) in enumerate(bucket): if k key: bucket[i] (key, value) return bucket.append((key, value)) self.size 1 if self.size self.threshold: self._resize(self.capacity * 2) def get(self, key): idx self._hash(key) for k, v in self.buckets[idx]: if k key: return v raise KeyError(key) def delete(self, key): idx self._hash(key) bucket self.buckets[idx] for i, (k, v) in enumerate(bucket): if k key: del bucket[i] self.size - 1 return raise KeyError(key)这段代码看起来不长但真实的哈希表核心要素都在里面了。我逐个解释一下几个容易忽略的点。_hash方法里的取模有两个作用一是把任意大的整数压缩到一个合法下标二是让哈希值始终落在[0, capacity-1]内。注意我是在循环内就取了模而不是算完总和再取模这是为了避免总和过大导致性能浪费同时也让中间结果的增长可控。put的时候先遍历目标链表的每个元素。如果 key 已经存在说明这是“更新”操作直接替换值只有 key 不存在时才追加新节点。这个细节如果漏掉同一个 key 就会出现两份记录取的时候永远拿到旧的。get和delete的逻辑结构完全对称先算下标、再遍历链表、找到就处理、找不到就抛异常。写下这三个方法之后你会非常直观地理解哈希表最坏情况什么时候出现——当所有 key 都撞到同一个 bucket 时遍历链表就和遍历数组没什么区别O(1) 秒变 O(n)。3.2 加入扩容与重新哈希负载因子的作用上面代码里我留了一个_resize方法没展开这是哈希表的另一条命脉扩容。为什么需要扩容数组容量固定了而放入的数据越来越多时每个 bucket 挂的链表会越来越长查询性能必然下降。这时候就需要给数组“变宽”让链表重新摊开。触发时机用“负载因子”来控制也就是size / capacity。当这个比值超过某个阈值大概 0.75 左右就该扩容了。0.75 不是拍脑袋定的数字。太小的负载因子意味着数组很大很空浪费内存太大了意味着链表过长性能退化。Java HashMap 把默认负载因子设成 0.75是一个在时间和空间上公认比较均衡的经验值。我的代码里threshold int(capacity * 0.75)就是把这个阈值直接算好避免每次 put 都做一次除法。扩容动作本身不复杂但有一个极其关键的点扩容后每个 key 重新计算出来的下标可能会变。因为取模运算的分母 capacity 变了。举个简单例子capacity 从 16 变成 32原来hash % 16 5的元素现在hash % 32可能是 5也可能是 21。所以扩容不是简单地把旧数组元素“搬”到更大的数组里而是必须逐个重新哈希、重新插入。下面是我测试时用的_resize实现def _resize(self, new_capacity): old_buckets self.buckets self.capacity new_capacity self.buckets [[] for _ in range(new_capacity)] self.threshold int(new_capacity * 0.75) for bucket in old_buckets: for k, v in bucket: idx self._hash(k) self.buckets[idx].append((k, v))这里有个我踩过的坑扩容后size不用动因为元素总数没有变化。但由于_hash依赖self.capacity而它在_resize里已经被更新成了新值所以重新调用_hash(k)时用的就是新的模数计算出来的下标自然属于新数组。顺序千万不能反如果先遍历旧桶再改 capacity那算出来还是旧下标插入新数组就全乱了。3.3 手写过程中最容易犯的错这一节我单独拎出来写是因为这几个错我都真实犯过而且不看测试结果根本发现不了。第一个错误是忘记处理“同 key 更新”的分支。往哈希表里重复 put 同一个 key结果新增了两条记录get 永远拿到最早那条。这种情况在测试时很容易漏掉因为大部分测试只会 put 不同 key。第二个错误是删除后没有把链表节点正确移除。用del bucket[i]是安全的但如果你在遍历的同时执行删除很容易因为下标错位跳过元素或者抛出越界异常。建议先记录目标下标遍历结束后再统一删除。第三个错误是扩容时没有迁移干净。如果直接self.buckets ...而忘了遍历旧数组那么扩容后所有数据直接丢失而且因为size还停留在旧值整个表就报废了。我建议手写测试时打印size、capacity、每个桶的长度一步一步对着看。手写完后我强烈建议再写一个最基础的冒烟测试连续 put 500 个随机键值对再随机 get 500 次最后 delete 一半再验证剩余数据是否完整。这套流程跑通才能说真正理解了哈希表的基本机制。4. 刷题视角哈希表的三种高频用法4.1 频次统计与“找重复/找唯一”既然这一篇刷题计划里叫“第6天 哈希表2”那当然绕不开用哈希表解算法题。我自己的经验是哈希表在刷题里其实就三个大方向掌握了这三板斧大部分相关题目都能用上。第一板斧是统计频次或者判断重复。这类题的核心套路是把遍历过的元素作为 key 存进哈希表value 存出现的次数或者第一次出现的下标。比如经典的“两数之和”遍历数组时对当前元素x检查target - x是否已经在哈希表里如果在就直接返回答案。这种做法把原本暴力两重循环的 O(n²) 降到了 O(n)代价仅是额外 O(n) 空间。还有一个常见的变体是“找到出现次数最多的元素”或“判断一个字符串是否能重排成回文串”。回文串那类题只要统计每个字符出现次数然后数一数有多少个奇数次字符最多允许一个奇数。这种题用哈希表的 key 存字符、value 存频次代码写起来非常顺手。我在刷这类题时总结出一个小经验能用数组做“伪哈希表”的优先用数组。如果 key 的范围是确定的整数比如 ASCII 码、26 个小写字母直接用长度为 26 或 128 的数组当下标表效率比真正的 HashMap 更高还省去了哈希函数计算。这也是为什么很多字符串题用“int[26]”而不是 HashMap 的原因。4.2 用哈希表构建 O(1) 查询的缓存结构第二板斧哈希表 双向链表实现 LRU 缓存。这是面试里非常爱考的一道经典题也是哈希表在实际工程中的高价值应用。设计思路是需要快速判断某个 key 是否存在并且能 O(1) 拿到 value这交给哈希表需要维护数据的热度顺序淘汰最久没用的数据这交给双向链表。哈希表的 value 里存的是双向链表的节点引用而不是单纯的值。访问某个 key 时先把对应节点从链表原位置摘下来再移到链表头部如果缓存满了就删掉链表尾部的节点同时把这个 key 从哈希表里删掉。核心要点是哈希表和链表必须同步更新。光改链表不改哈希表会留下“幽灵 key”光改哈希表不改链表链表会出现悬空引用。我在练这题时写了不止三遍每次都会在某一个小分支上翻车比如更新已存在的 key 时忘记调整节点位置或者删除尾部节点后忘记从哈希表移除对应 key。这种组合结构的思维方式比题目本身更值得学哈希表给人的是“快速定位能力”链表给人是“顺序维护能力”两种一拼很多复杂需求就能拆解成独立的小问题。除了 LRU像 O(1) 的插入/删除/获取随机元素也是类似套路——哈希表存下标动态数组存值。4.3 分组与状态映射“同特征”聚合第三板斧用哈希表把“具备同一特征”的元素分到同一组。典型的题目是“字母异位词分组”给定一组字符串把字母组成相同但顺序不同的词归为一类。常规解法是对每个单词的字符排序排序后的结果作为 key原单词作为 value 加入同一个列表。比如 “eat” 排序后是 “aet”“tea” 排序后也是 “aet”它们就分到同一组。这里哈希表的 key 就是“特征签名”value 是“拥有该特征的元素集合”。另一个思路是把 key 设计成“每个字母出现次数”的多维数组签名比如 “eat” 对应的签名是a:1, e:1, t:1。把签名编码成字符串a1e1t1作为 key效果和排序一样但避免了每次 O(k log k) 的排序开销。如果是超长字符串这种计数签名的方式往往更快。状态映射这类题还可以延展到图论里的“并查集简化”“连通分量统计”等场景。讲到根上哈希表承担的角色从来都是同一个把复杂的、不可比较的特征转化成一个可比较的、可靠的 key。一旦你习惯了这种思维方式看到“分组”“归类”“状态记录”这些关键词第一反应就是能不能用一个哈希表来落地。5. 实战踩坑记录哈希表的五个经典问题5.1 哈希冲突引起的性能雪崩第一个坑也是最隐蔽的坑哈希冲突多到一定程度哈希表平均查询时间会从 O(1) 退化到 O(n)。我自己在项目里就遇到过一个接口平时 2 毫秒返回某天突然变成 2 秒。排查过程很有意思先看数据库没慢查询再看下游服务也正常最后抓了个 heap dump 才发现一个 HashMap 里上百万数据全挂在同一个 bucket 下面。原因是业务 key 是某个字符串拼接出来的而拼接的那个底层字段取值只有十几种再加上这个字符串哈希值恰好有规律一取模全撞一起了。遇到这种情况处理手段有几个方向换一个更“散”的哈希种子。Java 的String.hashCode()是固定的但很多哈希表实现支持自定义哈希函数或通过随机种子打散。把 key 本身改造一下比如拼一个随机因子或者把多个字段组合成更复杂的签名。调整初始容量和负载因子减少扩容频率同时让桶数量更大降低碰撞概率。这事的教训是哈希函数是否均匀必须结合真实业务数据的分布去看。光看算法理论上的均匀没用线上数据往往有自己的偏好说不准哪个取值就会疯狂集中。5.2 自定义对象当 key结果 get 不到值第二个坑是关于 Java 自定义对象作为 key 时hashCode()和equals()必须配套实现。很多人写了个类没重写 hashCode直接当成 HashMap 的 key 塞进去结果存的时候用的是对象默认地址计算的哈希取的时候构造了一个“内容相同”的新对象地址不同哈希不同get 直接返回 null。解决方案是重写时遵守三条规则两个对象 equals 相等则 hashCode 必须相等。hashCode 相等equals 不一定相等这是允许的。重写 equals 时必须同时重写 hashCode。我还会在写完自定义 key 后专门写个单元测试故意构造两个内容相同的对象分别执行put和get确保能正常取回。这类问题看起来低级但实际踩坑率非常高特别是刚从其他语言转过来的同事。Python 里也有类似的坑。自定义类如果不定义__hash__和__eq__默认用对象身份做哈希如果只定义了__eq__Python 会直接把__hash__置为 None这个对象就没法放进 set 或 dict 了。正确做法是同时定义两者且保证相等的对象哈希值一致。5.3 可变对象做 key改完立刻“失灵”第三个坑进阶一些把可变对象当作 key存进去之后修改了对象内容。Java 中如果你把 key 对象的某个字段改了而 hashCode 依赖这个字段那么哈希表里存储时的位置和现在计算出来的位置就对应不上了这个对象会“凭空消失”——表还存着旧位置但你已经永远算不出它的坐标。Python 也有同样问题。list不能当 dict 的 key 就是因为它可变tuple可以当 key是因为它不可变。工程上我的建议很简单优先用不可变类型做 key比如 Java 的 String、IntegerPython 的 str、tuple。如果必须用可变对象约定“存进去之后绝不允许修改”并把这条写进代码注释。实在要改就走“先删除旧的再插入新的”流程。这条规则我是在一次搞砸了内存缓存之后记住的。当时缓存了一批配置对象后来业务上更新了配置内容直接改了对象的字段结果缓存命中率掉到几乎为零排查半天才发现是 key 的 hashCode 变了。5.4 并发场景下的使用禁忌第四个坑是关于线程安全。Java 的 HashMap 不是线程安全的多线程并发 put 时JDK 7 时代甚至可能在扩容时形成循环链表导致下次查询时死循环、CPU 飙满。JDK 8 改成尾插法后循环链表的问题缓解了但并发 put 仍然可能丢数据或者读到脏值。处理并发场景我的建议是按严重程度分级低读低写直接加Collections.synchronizedMap简单粗暴。高并发读、写也多用ConcurrentHashMap它把锁粒度细化到桶性能好得多。缓存语义优先考虑Caffeine这类专门做本地缓存的类库而不是自己拿 HashMap 硬做。Python 并发dict 本身有 GIL 保护单个操作但“先检查再更新”的组合操作不是原子的需要加锁或使用专门的同步容器。Go 的 map 更需要提高警惕多个 goroutine 同时读写同一个 map 会直接抛出fatal error: concurrent map writes。官方推荐的并发同步方案是sync.Mutex或sync.RWMutex如果读多写少用sync.Map在某些场景下效果更好。5.5 常见问题速查表最后把这两天复习中遇到最多的问题整理成一张表方便你直接对照排查。现象可能原因排查方式解决方案get 返回错误顺序的数据key 的 equals/hashCode 未正确实现打印 hashCode 对比重写 hashCode 和 equalskey 存进去后查不到可变对象 key 被修改检查 key 对象字段是否变动改用不可变 key或先删后插哈希表查询突然变慢哈希冲突过多、桶链表过长统计各 bucket 长度分布换哈希函数、加大容量并发写入报错或丢数据线程不安全容器被多线程使用检查并发访问路径使用 ConcurrentHashMap / 加锁内存占用过高容量开太大、负载因子设置不当打印 capacity 和 size调整初始容量与负载因子这里还有一条很实用的心得当你怀疑哈希表出问题时不要凭感觉猜写一段小脚本或者测试代码把capacity、size、每个桶的链表长度分布都打印出来。数据一出来问题基本就定位了。最后说几句实操体会把这几天手写哈希表、刷题、排查线上问题的经历串在一起我最大的感受是哈希表这个结构看起来只有几行代码但它牵扯出来的细节能写满一整篇博文。哈希函数怎么选、冲突怎么处理、扩容为什么必须重新哈希、key 的设计规范、并发场景的选型每一个点都是真实项目里踩过坑才能记住的。如果你也在学这一块我真心建议别停留在“会用 HashMap”这个层面。找一天时间把手写哈希表、手写 LRU、再刷几道用哈希表分组的题走一遍第二天再看源码你会发现自己突然能看懂那些注释里为什么反复强调 equals 和 hashCode 的一致性了。今天关于哈希表的记录就到这里。接下来我计划直接把“第7天”的内容定成哈希表的应用强化把字符串处理、滑动窗口、前缀和这些容易和哈希表配合的题型系统地过一遍到时候再把新的经验整理出来分享。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →