ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

哈希表拉链法从原理到C++实现:手写一个支持自动扩容的HashMap

哈希表拉链法从原理到C++实现:手写一个支持自动扩容的HashMap 哈希表在面试和工程里出现的频率高到几乎不用我多说。很多人一提哈希表第一反应是“用数组存 key算个 hash 取模”再问下去就支支吾吾了。尤其拉链法很多人的理解停留在“冲突了就在后面挂个链表”但真正到了 C 里要自己手写一个能插入、查找、删除、还能自动扩容的哈希表时才发现坑比想象的多。这篇我把拉链法从原理到 C 实现完整讲一遍适合正在学数据结构的学生、准备算法面试的开发者以及想搞懂 STL unordered_map 背后思路的人。拉链法也叫链地址法是哈希表最常见的冲突处理方案。它最大的价值是实现简单、删除容易、对哈希函数质量不那么敏感。如果你用开放寻址法线性探测那种删除一个元素可能要把后续一串元素重新整理非常麻烦而拉链法每个槽位独立挂一条链表删除就是链表删除干净利落。1. 哈希表的基本原理与拉链法的设计思路1.1 哈希函数与数组下标的映射哈希表的本质是把“任意类型的 key”映射成“数组下标”。数组本身是最快的数据结构O(1) 时间就能访问任意下标但数组要求下标是非负整数。哈希表就是想办法把字符串、对象、结构体这类 key转成一个整数下标。这个“转换”靠的就是哈希函数。理想情况下哈希函数应该做到同一个 key 永远得到同一个下标不同的 key 尽量得到不同的下标。但现实很残酷不同的 key 算出同一个下标这件事叫作“哈希冲突”这在数学上不可避免。原因很简单假如你的 key 有 2^32 种可能而桶数组只有 100 个槽位那必然有多个 key 映射到同一个槽位。这就是鸽笼原理再好的哈希函数也绕不开。所以哈希表设计真正要回答的问题不是“怎么避免冲突”而是“冲突了怎么办”。拉链法的回答是冲突就冲突把冲突的元素放进同一个槽位的链表里大家排队。1.2 拉链法与开放寻址法的取舍处理冲突主流方案就两大家族。一类是开放寻址法包括线性探测、二次探测、双重哈希另一类就是拉链法也叫链地址法、分离链接法。开放寻址法的思路是冲突了我就往后找下一个空位。如果数组快满了找空位会越来越慢甚至可能出现“堆积”现象——连续一片都满了新 key 要在很后面才找到位置。拉链法是完全不同的思路我不往后找就在当前位置挂个链表。每个数组槽位叫“桶”每个桶下面是一条链表。对比维度拉链法开放寻址法线性探测实现难度低链表操作即可中等需要处理探测序列删除操作直接链表删除麻烦需要标记或搬移后续元素对负载因子的容忍度可以超过 1.0链表变长但可用一般不能超过 0.7超过后性能骤降缓存利用率低链表节点分散高数组连续内存哈希函数要求相对宽松要求更高分布不均会加剧堆积实际工程里像 Java 的 HashMap、C 的 unordered_map核心思路都包含拉链法。C 标准库 unordered_map 实际上是用哈希桶加链表的实现和拉链法是一脉相承的。理解了拉链法你再看 STL 的源码和面试题都会轻松很多。1.3 负载因子什么时候需要扩容负载因子load factor的定义很简单元素个数 / 桶个数。它代表每个桶平均挂了多少个元素。负载因子越小链表越短查找越快但浪费的内存也越多负载因子越大链表越长查找越慢。拉链法的好处是就算负载因子超过 1.0 也能工作只是链越来越长慢慢退化成链表。所以一般会设一个阈值比如 0.75超过就扩容。为什么是 0.75 而不是 1.0 或 0.50.75 是时间和空间的折中。负载因子太大冲突变多链长增加查找效率下降太小大量桶空着浪费内存而且扩容频繁扩容本身要重新哈希所有元素成本很高。JDK 的 HashMap 默认负载因子也是 0.75C 的 unordered_map 实现里也常见类似设定这算是工业界的经验值。2. 拉链法的核心细节与数据结构选型2.1 底层数据结构桶数组 链表的组合拉链法的底层需要两个部分一个数组存储链表头或者指针每个数组元素是一个桶。每个桶里面是一条链表链表节点存储 key 和 value。在 C 里实现最自然的方式是vectorlistpairK, V用标准库链表来当桶。但如果你自己手写节点思路会更直白一个结构体 Node包含 key、value 和指向下一个节点的 next 指针。为什么不用vectorvectorpairK, V或者干脆全部放在一个大 vector 里因为哈希表要求插入、删除都是 O(1) 平均复杂度。如果桶里用 vector删除一个元素就需要搬移后续元素复杂度退化成 O(n)。链表则可以在 O(1) 时间内完成插入和删除只要你知道位置。这就是为什么拉链法一定用链表而不是动态数组。当然工程上有一个改进方案值得提一句当单个桶的链表长度超过某个阈值比如 8时把链表转换成红黑树。Java 8 的 HashMap 就是这么干的专门用来防止恶意哈希攻击导致某个桶链表过长。C 的 unordered_map 标准实现里也有类似思路但具体处理方式由标准库实现决定。你自己实现时如果 key 的可预测性高、攻击面大可以考虑这个优化一般场景没必要。2.2 哈希函数与取模运算的配合哈希函数负责把 key 转成整数。C 里标准库提供了std::hashK模板基本类型都有默认特化。你只需要用std::hashK{}(key)拿到一个 size_t 类型的哈希值然后取模% bucketCount得到桶下标。取模有个细节桶数量最好选一个质数或至少不是 2 的幂。如果你用bucketCount 8这种 2 的幂那么取模等价于保留哈希值的低 3 位这会丢掉高位信息。如果哈希函数在低位上分布不均匀冲突率会明显上升。所以很多实现的默认桶大小是 7、11、13 这类质数。不过C 的std::hash对整数类型通常返回自身如果 key 本身是连续整数取模质数能保证均匀分布取模 2 的幂则会让某些模式下的 key 全部集中到少数几个桶。这一点在面试里很容易考到属于拉链法实现的常见陷阱。2.3 扩容与 rehash 的成本分析哈希表元素增多后负载因子超过阈值就要扩容。扩容不是简单地把数组变大再把链表搬过去。因为桶数量变了每个 key 重新取模后的下标也会变所以必须对已有的全部元素重新计算哈希这个过程叫 rehash。rehash 的时间复杂度是 O(n)n 是当前元素个数。扩容操作本身虽然耗时但因为每次扩容后桶数量翻倍或乘以 2 加 1下次扩容要等元素数量再次翻倍摊还下来每个元素平均只需 O(1) 次 rehash 成本。这就是均摊分析的基本结论不用担心单次 rehash 很慢。一个容易踩的坑是rehash 时千万不能直接复用旧的桶数组因为搬移过程中每个元素的新位置变了如果边搬边覆盖会丢数据且逻辑混乱。最安全的做法是重新创建一个新的桶数组把旧桶中的元素逐个 insert 到新数组里然后销毁旧数组。内存开销确实存在但换来的是实现的简单和正确性。3. C 实操从零实现一个拉链法哈希表3.1 完整代码框架下面给出一个最小但完整的 C 实现。为了可读性我用了标准库的list当做桶链表自己实现了HashMap类支持插入、查找、删除和自动扩容。#include iostream #include vector #include list #include functional templatetypename K, typename V class HashMap { private: struct Node { K key; V value; Node(const K k, const V v) : key(k), value(v) {} }; std::vectorstd::listNode buckets; // 桶数组每个桶是一个链表 size_t bucketCount; size_t elementCount; float loadFactorThreshold; size_t hash(const K key) const { return std::hashK{}(key) % bucketCount; } void rehash(size_t newBucketCount) { std::vectorstd::listNode oldBuckets std::move(buckets); bucketCount newBucketCount; buckets.resize(bucketCount); elementCount 0; for (auto bucket : oldBuckets) { for (auto node : bucket) { insert(node.key, node.value); } } } public: HashMap(size_t bucketCount 7, float loadFactorThreshold 0.75f) : bucketCount(bucketCount), elementCount(0), loadFactorThreshold(loadFactorThreshold) { buckets.resize(bucketCount); } void insert(const K key, const V value) { size_t idx hash(key); auto bucket buckets[idx]; for (auto node : bucket) { if (node.key key) { node.value value; // 已存在则更新 return; } } bucket.push_back(Node(key, value)); elementCount; if (static_castfloat(elementCount) / bucketCount loadFactorThreshold) { rehash(bucketCount * 2 1); } } bool find(const K key, V valueOut) const { size_t idx hash(key); const auto bucket buckets[idx]; for (const auto node : bucket) { if (node.key key) { valueOut node.value; return true; } } return false; } bool erase(const K key) { size_t idx hash(key); auto bucket buckets[idx]; for (auto it bucket.begin(); it ! bucket.end(); it) { if (it-key key) { bucket.erase(it); elementCount--; return true; } } return false; } size_t size() const { return elementCount; } size_t bucket_size() const { return bucketCount; } };3.2 插入流程的细节插入时先算 hash 拿到桶下标然后遍历这个桶的链表看 key 是否已经存在。存在就更新 value不存在就在链表尾部 push 一个新的节点同时 elementCount 加一。这里有个关键决策key 存在时应该更新还是报错这取决于你的使用场景。像unordered_map::operator[]的做法是不存在就默认构造存在就返回引用由调用者赋值。我的实现里直接更新这样语义更接近 map 的insert_or_assign。插入后要注意检查负载因子。我是在每次插入成功后判断如果当前负载因子超过了阈值就执行 rehash。扩容的时机不能太早也不能太晚。太早元素还不多就频繁扩容浪费 CPU太晚链表已经长了查询变慢。0.75 的阈值配合桶数 7 起步在数据量小时也能有不错的体验。3.3 查找与删除的快速实现查找的逻辑最简单算下标遍历链表找到就返回。查找的时间取决于对应桶的链表长度。如果哈希函数质量好每个桶都差不多长平均查找时间接近 O(1)。如果哈希函数差到把所有 key 都映射到同一个桶查找就退化成 O(n)。删除时有个需要注意的点用bucket.erase(it)删除元素后一定要return否则迭代器已经失效继续遍历会出问题。这也是链表删除和数组删除的本质区别链表删除只需要调整前后指针O(1) 完成不搬移其他元素。删除后通常不需要缩容因为频繁缩容会导致抖动插入触发扩容删除触发缩容反复操作会不断 rehash性能损耗很大。工程上一般只扩不缩除非元素数量骤降到某个很低的比例才考虑缩容。我的实现没有缩容保持更稳定。3.4 rehash 的实现细节rehash 函数先保存旧桶数组更新 bucketCount然后创建新的空桶数组。接着遍历每一个旧桶和里面的每个节点重新执行 insert。insert 会重新计算std::hashK{}(key) % bucketCount所以每个元素会搬到新位置。这里有两点必须提醒。第一rehash 里elementCount要先清零然后在 insert 里被重新累加。如果忘记清零最终元素个数会翻倍负载因子计算就错了。这一点我在初学时踩过坑查了很久才发现 size 虚高。第二重新 insert 的过程中会不会再次触发 rehash不会。因为 newBucketCount 比 oldBucketCount 大得多而元素总数没变重新插入时负载因子会显著下降不会再次超过阈值。但如果你桶数增长太慢比如 1 而不是 *2就可能出现 rehash 套 rehash变成死循环。这也是为什么常见实现里扩容都是翻倍而不是固定加一个数。4. 常见问题与排查技巧实录4.1 哈希函数分布不均匀怎么排查哈希函数写得好不好最直观的办法是统计每个桶的链表长度。如果所有元素都挤在一个桶里其他桶全空那不管哈希表实现得多好都没用。简单的排查方法是写一个临时测试插入 N 个元素后遍历所有桶记录每个桶的元素数量看一下最大值和平均值。如果平均长度是 2但最大长度是 50说明哈希函数存在严重的聚集问题。正常情况下均匀哈希下最长链长度的期望大约是log(n) / log(log(n))级别不会离谱地高。如果是自定义类型做 key尤其要注意std::hash的默认实现。C 默认的std::hash对自定义结构体不支持你得自己写特化。很多人的做法是把多个字段组合起来比如hash1 * 31 hash2但这里有一个陷阱组合系数不要取偶数否则高位信息容易丢失。用 31、131 这类质数是常见选择。4.2 链表过长导致查找退化怎么办如果你的哈希表使用场景里某个 key 集合总是映射到同一个桶可能是被恶意构造的。比如输入数据知道你的哈希函数和桶数量故意制造大量相同 hash 的 key拉链法就会退化成 O(n) 的链表。这就是哈希碰撞攻击的思路。工程上的对策有几个层面。第一减轻负载因子让桶更多冲突概率下降第二使用加密级哈希函数让攻击者无法预测结果第三桶内结构升级比如链表长度超过阈值转红黑树。你在自己实现时最简单的防御是换一个更复杂的哈希函数并在桶数组初始化时选择一个较大的质数增加攻击者预测下标的难度。4.3 删除与内存管理的坑自己实现节点链表时最痛的是内存管理。list已经帮你管好了但如果你手写 Node next 指针删除节点后忘了 delete就会内存泄漏。还有更隐蔽的问题如果你的 Node 里有非平凡的成员比如 stringdelete 时析构顺序不对可能导致 use-after-free。另外我自己写哈希表时经常忘的一件事迭代器失效。在使用 find 或 erase 时如果遍历链表的途中对链表做了修改迭代器就失效了。最典型的是在循环里删除多个元素for (auto it bucket.begin(); it ! bucket.end(); ) { if (cond) it bucket.erase(it); else it; }erase 会返回下一个有效的迭代器必须用这个返回值继续不能直接 。4.4 一个实用的调试小技巧哈希表调试起来很难受因为数据分布是分散的直接打印 map 的内容看不出问题。我自己常用的方法是写一个调试函数按桶打印所有元素的 key这样一眼就能看出哪些 key 被分到了同一个桶。void debugPrint() const { for (size_t i 0; i bucketCount; i) { std::cout bucket i : ; for (const auto node : buckets[i]) { std::cout node.key ; } std::cout std::endl; } }这个方法在验证哈希函数质量时特别有用。你可以先插入一组有规律的 key比如连续整数、偶数、奇数然后对比各桶的分布。如果发现某些桶一直很长就能针对性调整哈希函数里的组合参数。另外初次调试时建议把桶数量设小一点比如固定为 7这样数据结构更直观问题更容易暴露。跑通基础逻辑后再把桶数量调回正常范围测试扩容逻辑。最后说一句我自己的体会。手写哈希表虽然看起来“基础”但实际写完一遍你对查询效率、均摊分析、数据分布的理解都会上一个台阶。建议按这个顺序练一遍只用数组和链表手写 Node 结构先实现 insert 和 find再补 erase最后加 rehash。每一步跑通再继续远比直接抄完整代码有效。
返回列表