☰
哈希表拉链法从原理到C++实现:手写一个支持自动扩容的HashMap
2026/9/30 4:32:40 网站建设 项目流程

哈希表在面试和工程里出现的频率,高到几乎不用我多说。很多人一提哈希表,第一反应是“用数组存 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.5?0.75 是时间和空间的折中。负载因子太大,冲突变多,链长增加,查找效率下降;太小,大量桶空着,浪费内存,而且扩容频繁,扩容本身要重新哈希所有元素,成本很高。JDK 的 HashMap 默认负载因子也是 0.75,C++ 的 unordered_map 实现里也常见类似设定,这算是工业界的经验值。

2. 拉链法的核心细节与数据结构选型

2.1 底层数据结构:桶数组 + 链表的组合

拉链法的底层需要两个部分:一个数组存储链表头(或者指针),每个数组元素是一个桶。每个桶里面是一条链表,链表节点存储 key 和 value。

在 C++ 里实现,最自然的方式是vector<list<pair<K, V>>>,用标准库链表来当桶。但如果你自己手写节点,思路会更直白:一个结构体 Node,包含 key、value 和指向下一个节点的 next 指针。

为什么不用vector<vector<pair<K, V>>>或者干脆全部放在一个大 vector 里?因为哈希表要求插入、删除都是 O(1) 平均复杂度。如果桶里用 vector,删除一个元素就需要搬移后续元素,复杂度退化成 O(n)。链表则可以在 O(1) 时间内完成插入和删除,只要你知道位置。这就是为什么拉链法一定用链表而不是动态数组。

当然,工程上有一个改进方案值得提一句:当单个桶的链表长度超过某个阈值(比如 8)时,把链表转换成红黑树。Java 8 的 HashMap 就是这么干的,专门用来防止恶意哈希攻击导致某个桶链表过长。C++ 的 unordered_map 标准实现里也有类似思路,但具体处理方式由标准库实现决定。你自己实现时,如果 key 的可预测性高、攻击面大,可以考虑这个优化;一般场景没必要。

2.2 哈希函数与取模运算的配合

哈希函数负责把 key 转成整数。C++ 里,标准库提供了std::hash<K>模板,基本类型都有默认特化。你只需要用std::hash<K>{}(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> template<typename K, typename V> class HashMap { private: struct Node { K key; V value; Node(const K& k, const V& v) : key(k), value(v) {} }; std::vector<std::list<Node>> buckets; // 桶数组,每个桶是一个链表 size_t bucketCount; size_t elementCount; float loadFactorThreshold; size_t hash(const K& key) const { return std::hash<K>{}(key) % bucketCount; } void rehash(size_t newBucketCount) { std::vector<std::list<Node>> 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_cast<float>(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::hash<K>{}(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 里有非平凡的成员,比如 string,delete 时析构顺序不对,可能导致 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。每一步跑通再继续,远比直接抄完整代码有效。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询