☰
C++手写哈希表:开散列与闭散列的实现原理与工程选型
2026/10/15 7:37:10 网站建设 项目流程

哈希表这玩意儿,做后端和底层的兄弟肯定不陌生,但说实话,很多人对它的理解停留在“unordered_map 很快”这个层面。C++ 里实现一个能用的哈希表,核心就是解决冲突,而主流方案就两条路:开散列和闭散列。我当年自己手写了一遍,才真正明白为什么标准库要那么设计,也踩了不少坑。这篇文章就把这两种实现掰开了讲清楚,从原理到 C++ 代码落地,再到工程选型和个人经验,希望对正在啃数据结构的、或者想自己造轮子的人有点帮助。

1. 哈希表的本质:一套“字典”的存取游戏

哈希表不是什么高深概念,你可以把它理解成一个超级智能的储物柜。你存东西的时候,柜子根据“钥匙”算出你应该放哪个格子,取东西的时候再用“钥匙”算出格子号直接去拿。这个过程的时间复杂度平均是 O(1),和数据量多大基本没关系。这就是哈希表的核心价值:把“查找”这个操作从遍历的 O(N) 拉到了常数级。

1.1 哈希函数:钥匙到格子的映射

哈希表的基础是一块连续内存,也就是数组。所有操作的关键在于哈希函数,它负责把任意类型的“钥匙”转化为数组下标。比如存一个字符串,你需要设计一个算法把字符串变成一个整数,再对数组长度取模,得到下标。

哈希函数的质量直接决定哈希表的性能。如果它能把所有键均匀地散列到各个位置,冲突就少,性能就稳定;如果散列能力很差,比如把所有字符串都映射到同一个下标,那你的哈希表就退化成了一条链表,查找复杂度直接变成 O(N)。日常开发中,整数本身可以当哈希值,字符串则需要设计。一个最常见的朴素做法是“多项式哈希”:

size_t hashString(const string& key) { size_t h = 0; for (char c : key) { h = h * 131 + c; // 131 是一个经验系数 } return h; }

这种写法利用了乘法和加法的混合,比简单累加 ASCII 码值要好得多,能有效减少因为字符排列顺序不同而产生的碰撞。系数选 131 纯属经验值,你也可以用 31、33、65599 这些,核心思想是把每个字符的权重区分开。

1.2 冲突必然存在:处理策略决定实现方式

就算哈希函数设计得再精妙,只要哈希表的存储空间小于映射空间,冲突就不可避免。这和“鸽巢原理”是一个道理:6 只鸽子放进 5 个笼子里,必然有笼子住着至少两只鸽子。

  • 闭散列(开放寻址法):当发生冲突时,不去新建结构,而是在数组内部寻找下一个空位。相当于当前格子满了,我就挨个往后挪,找个空位把东西放下。
  • 开散列(链地址法):每个数组下标不是一个“格子”,而是链表的“头节点”。冲突的两个键,直接挂在同一条链表下面。

用生活化的例子来类比:闭散列就像现实中的公共停车场,某个车位被占了,你就在附近兜圈子找空位;开散列则像小区一排信箱,每户门口堆好几封信,丢信的人只需要在自己对应的信箱里按顺序叠放就行。两种方案的“找空位”和“叠放”细节,就是我们后面要深入的核心。

2. 闭散列实现:开放寻址法的 C++ 实战

闭散列在 C++ 里实现起来更符合“数组 + 状态标记”的直觉,但它有个很隐蔽的坑:删除操作。我先把完整实现和坑都过一遍,你再自己动手写一遍。

2.1 底层结构与状态机

闭散列的核心就是数组,但每个元素不能单纯存键值对,因为要区分“空位”“有数据”“逻辑删除”三种状态。否则删除后,你无法判断空位是从来没有数据,还是删除后留下的空位,这会影响查找的正确性。

我习惯用一个枚举 + 封装结构体来实现。

#include <vector> #include <utility> namespace close_hash { enum State { EMPTY, EXIST, DELETED }; template<typename K, typename V> struct Elem { std::pair<K, V> kv; // 存储键值对 State state = EMPTY; }; template<typename K, typename V, typename Hash = std::hash<K>> class HashTable { public: HashTable(size_t initCap = 10) : table_(initCap), size_(0) {} bool insert(const K& key, const V& val); bool erase(const K& key); V* find(const K& key); private: size_t hash(const K& key) const { return Hash()(key) % table_.size(); } // 扩容相关 void rehash(); double loadFactor() const { return (double)size_ / table_.size(); } std::vector<Elem<K, V>> table_; size_t size_; // 实际存储的有效元素个数 }; } // namespace close_hash

好的,你先看到这里。代码里有一个非常关键的设计决策:DELETED状态。我在第一次写的时候就没加这个状态,导致删除后查找逻辑直接出 bug,后面会详细说。

Hash模板参数默认用std::hash<K>,这样设计的好处是模板里可以传入任意自定义哈希函数,比如针对std::string专门写一个类。这也是标准库惯用的套路。

2.2 插入与查找:线性探测的实现

闭散列最重要、最简单的方法是线性探测。插入时,先计算哈希值idx,然后从idx开始往后找,直到找到一个EMPTY或者DELETED的位置,把键值对放进去。

template<typename K, typename V, typename Hash> bool HashTable<K, V, Hash>::insert(const K& key, const V& val) { if (loadFactor() >= 0.7) { // 经验阈值 rehash(); } size_t idx = hash(key); size_t start = idx; // 循环直到找到空位或删除位 while (table_[idx].state == EXIST) { if (table_[idx].kv.first == key) { return false; // 相同 key 已经存在 } idx = (idx + 1) % table_.size(); if (idx == start) { // 理论上不会出现,扩容后空间足够 return false; } } table_[idx].kv = {key, val}; table_[idx].state = EXIST; ++size_; return true; }

这里要注意两个细节。第一,扩容阈值设为 0.7 不是拍脑袋,而是基于数学期望推导的。空位越少,线性探测的“聚集”现象越严重,查找时走过的无效路径越长。当负载因子超过 0.7 后,插入和查找的期望探测次数会呈指数级增长,所以 0.7 是平衡空间与时间的关键点。

第二,(idx + 1) % table_.size()这行代码实现了“循环查看”。如果探测到数组末尾还没找到空位,就回头从 0 继续找,直到绕回起点。

查找的逻辑和插入前半段类似,区别在于:只有遇到EXIST且 key 相等才返回指针;遇到EMPTY就可以停止查找,因为插入操作时会保证探测路径上不会越过空位(如果有空位,插入时就会停在那里)。

template<typename K, typename V, typename Hash> V* HashTable<K, V, Hash>::find(const K& key) { size_t idx = hash(key); while (table_[idx].state != EMPTY) { if (table_[idx].state == EXIST && table_[idx].kv.first == key) { return &table_[idx].kv.second; } idx = (idx + 1) % table_.size(); } return nullptr; }

看,这个代码逻辑简单,但性能上有个潜在风险:如果这个 key 不存在,理论上最多要遍历整个数组才敢返回 nullptr。所以扩容阈值真的不能拍脑袋设太高。

2.3 删除:为什么不能直接清空数组元素

这是我当初踩得最惨的坑,必须单独拿出来说。闭散列的删除,绝不能简单地把state设为EMPTY。原因在前面的结构阐述里已经提到,这是由查找算法的“探测链”决定的。

比如有三个键,哈希值算出来 0、1、2 都落在同一个位置idx=0。插入时,它们在数组里依次占用了下标 0、1、2。现在如果删除下标 0 的元素,并把它标记为EMPTY,那么当你查找哈希值为 0 的第三个键时,从下标 0 开始探测,发现EMPTY就直接停止了,因为它认为这个键根本不存在。但实际上,它在下标 2 的位置。

所以删除时必须用DELETED标记,表示“这里曾经有数据,我删除了,但后续的探测链可能还依赖这个位置作为跳板”。

template<typename K, typename V, typename Hash> bool HashTable<K, V, Hash>::erase(const K& key) { V* ptr = find(key); if (ptr == nullptr) { return false; } // 找到精准位置,重新遍历找到下标 size_t idx = hash(key); while (!(table_[idx].state == EXIST && table_[idx].kv.first == key)) { idx = (idx + 1) % table_.size(); } table_[idx].state = DELETED; --size_; return true; }

注意,erase调用find后还要重新遍历一次找下标,其实可以写个内部函数findIndex返回下标,避免两次循环。这里主要是为了逻辑清晰,让你看懂状态的作用。一个更小的问题:DELETED位置如果积累太多,下次插入时会占用DELETED位置,但查找时依然会把它们当跳板,性能会变差。所以老练的实现会在loadFactor里把DELETED也计入分母,或者在扩容时做一次完整清理。

2.4 扩容与 rehash 细节

扩容是哈希表性能的兜底保证。闭散列扩容和开散列类似,但不是简单的“新建数组、搬元素”这么简单,因为哈希函数里用了table_.size()取模。数组大小变了,所有元素的哈希值都变了,必须全部重新计算再插入,这个过程叫 rehash。

template<typename K, typename V, typename Hash> void HashTable<K, V, Hash>::rehash() { size_t newCap = table_.size() * 2; // 经验上扩 2 倍 std::vector<Elem<K, V>> oldTable = std::move(table_); table_.resize(newCap); size_ = 0; for (auto& e : oldTable) { if (e.state == EXIST) { // 注意这里有坑:用 insert 会导致递归调用 rehash size_t idx = Hash()(e.kv.first) % table_.size(); while (table_[idx].state == EXIST) { idx = (idx + 1) % table_.size(); } table_[idx].kv = e.kv; table_[idx].state = EXIST; ++size_; } } }

注意 rehash 里绝对不能调用insert(),因为insert()开头会有负载因子判断,负载因子此时很可能又超过阈值,导致无限递归扩容。我当时就是把通用插入逻辑封装到了 rehash 里,结果在插入大量数据时反复崩溃。这里必须用独立的“裸插入”逻辑。

3. 开散列实现:链地址法的 C++ 实战

开散列是 C++ 标准库 unordered_map 采用的方案。相比闭散列,它的删除操作简单得多,不需要墓碑标记,直接操作链表节点即可。但因为引入了额外的指针,内存开销稍大。

3.1 核心结构:桶与链表节点

开散列的结构通常分为两层:顶层是一个数组,每个元素是一个链表的头指针;底层是链表节点,包含键值对和指向下一个节点的指针。

#include <vector> namespace open_hash { template<typename K, typename V> struct Node { std::pair<K, V> kv; Node* next; Node(const K& key, const V& val) : kv(key, val), next(nullptr) {} }; template<typename K, typename V, typename Hash = std::hash<K>> class HashTable { public: HashTable(size_t initBucket = 10) : buckets_(initBucket, nullptr), size_(0) {} bool insert(const K& key, const V& val); bool erase(const K& key); V* find(const K& key); private: size_t hash(const K& key) const { return Hash()(key) % buckets_.size(); } void rehash(); double loadFactor() const { return (double)size_ / buckets_.size(); } std::vector<Node<K, V>*> buckets_; size_t size_; // 所有链表节点总数 }; } // namespace open_hash

buckets_ 用vector<Node*> initializes with nullptr,即桶里初始就是空指针。每个桶是条单向链表,相同哈希值的键都挂在这条链上。设计里的注意点是:我选择了用裸指针管理节点,没有用智能指针,因为链表的指针指向关系复杂且保持简单,如果要用 unique_ptr 会增加代码复杂度,而 shared_ptr 完全没有必要。实际工程里可以谨慎使用 unique_ptr 来避免内存泄漏,但学习场景裸指针更直观,关键是记得在析构函数里手动清空所有节点。

3.2 插入与查找:链表操作的艺术

开散列插入的逻辑非常简单:先找到对应的桶,然后判断这个 key 是否已经存在。存在则更新或返回,不存在则头插法挂到链表头部。

template<typename K, typename V, typename Hash> bool HashTable<K, V, Hash>::insert(const K& key, const V& val) { if (loadFactor() >= 1.0) { // 经验阈值 1.0 rehash(); } size_t idx = hash(key); Node<K, V>* cur = buckets_[idx]; while (cur != nullptr) { if (cur->kv.first == key) { cur->kv.second = val; // 覆盖更新 return true; } cur = cur->next; } // 头插法,新节点放到链表最前面 Node<K, V>* newNode = new Node<K, V>(key, val); newNode->next = buckets_[idx]; buckets_[idx] = newNode; ++size_; return true; }

头插法效率极高,O(1) 就能完成。不用尾插的原因是没必要维护尾指针。新插入的数据往往后续就会被访问,放链表头还能提升缓存命中率。

查找就顺着链表走,不存在闭散列那种“探测链断裂”的问题,所以删除操作可以直接删除节点而不用担心破坏后续查找逻辑。

template<typename K, typename V, typename Hash> V* HashTable<K, V, Hash>::find(const K& key) { size_t idx = hash(key); Node<K, V>* cur = buckets_[idx]; while (cur != nullptr) { if (cur->kv.first == key) { return &cur->kv.second; } cur = cur->next; } return nullptr; }

3.3 删除操作与内存管理

删除操作要处理“待删节点是头节点”和“待删节点在中间/尾部”两种情况。因为单向链表没有前驱指针,所以遍历时需要用前一个节点的指针来串接。这是链表操作的基本功。

template<typename K, typename V, typename Hash> bool HashTable<K, V, Hash>::erase(const K& key) { size_t idx = hash(key); Node<K, V>* prev = nullptr; Node<K, V>* cur = buckets_[idx]; while (cur != nullptr) { if (cur->kv.first == key) { if (prev == nullptr) { buckets_[idx] = cur->next; // 直接更新头节点 } else { prev->next = cur->next; } delete cur; // 手动释放内存 --size_; return true; } prev = cur; cur = cur->next; } return false; }

这里有个值得说的点:erase的关键在于时刻维护 prev 和 cur 两个指针,prev 指向当前节点的前驱。头插法保证了插入的节点都在最前面,但删除时还是老老实实遍历找,因为唯一能确定的只有哈希值映射到的链表头。

关于内存管理我多说一句:开散列删除必须delete节点,否则就会内存泄漏。如果你的项目环境不允许裸指针,想用 RAII,可以改成vector<vector<pair<K, V>>>,每个桶是动态数组,但这样删除就不是 O(1) 而是 O(N)(需要搬移元素)。工程取舍就看你的具体场景了。

3.4 扩容与 rehash:旧桶元素全部搬新家

开散列扩容同样需要 rehash,因为取模用的桶数变了。但它的 rehash 相对闭散列简单:遍历所有旧桶,把每个节点的值重新计算哈希后放入新桶,不需要处理DELETED状态。

template<typename K, typename V, typename Hash> void HashTable<K, V, Hash>::rehash() { size_t newBucket = buckets_.size() * 2; std::vector<Node<K, V>*> newBuckets(newBucket, nullptr); for (Node<K, V>* head : buckets_) { Node<K, V>* cur = head; while (cur != nullptr) { Node<K, V>* next = cur->next; // 先保存 next,防止断链 size_t idx = Hash()(cur->kv.first) % newBucket; // 头插法加入新桶 cur->next = newBuckets[idx]; newBuckets[idx] = cur; cur = next; } } buckets_.swap(newBuckets); }

这段代码的精妙之处在于它直接把旧的链表节点搬到了新桶,没有做任何 new/delete 操作。这里复用了原有的节点内存,避免了重新分配节点的开销。最后用swap把新桶数组和旧桶数组交换,旧数组自动析构。注意遍历时必须保存next指针,因为头插法会修改cur->next,不保存下一轮就找不到原始链表了。

4. 开散列与闭散列:工程选型与性能对比

很多教程讲完两种实现就完了,但实际工作中你一定会遇到“我应该用哪种”的问题。C++ 标准库帮你做了选择,unordered_map 用的是开散列。但从纯理论角度,这两种各有优劣,我整理一下自己的理解。

4.1 特性全面对比

维度闭散列(开放寻址)开散列(链地址法)
存储方式数据全在数组中数据分散在堆中链表节点
删除实现需要墓碑标记,复杂直接链表删除,简单
负载因子阈值约 0.7,超过后性能骤降约 1.0 甚至更高
空间利用率删除标记会占用空间,实际利用率不高指针额外开销,链式分散
缓存友好性极好,数组连续内存较差,链表节点内存不一定连续
最坏查找复杂度O(N),探测链一直走O(N),单链表遍历
扩容成本高,很多人元素 rehash高,但可复用节点避免二次分配
内存碎片风险低高,频繁 new/delete 节点

从缓存的角度看,闭散列简直碾压开散列。因为数组内存连续,CPU 加载一个缓存行就能同时处理多个元素。开散列则需要指针跳转,内存不连续会频繁触发 cache miss。这也就解释了为什么在不需要频繁删除、且数据量可控的场景下,闭散列反而是性能王者;而 C++ 标准库为了通用性,选择实现更简单的开散列——毕竟它要处理各种自定义类型的插入删除,删除的简单性是首要考量。

4.2 场景选型经验

基于这些差异,我总结一些实际选型经验:

  • 如果哈希表内数据基本只增不减,比如加载词典、初始化配置映射,闭散列会更合适,节省内存且查询快。
  • 如果业务有高频删除、插入交替的场景,比如缓存淘汰、连接管理表,选开散列。墓碑标记积累会导致闭散列性能退化,而开散列没有这个问题。
  • 如果对最坏情况延迟敏感,比如某些实时系统,应该两种都不用,而是改用平衡树(map)保证 O(logN) 严格上界。哈希表最坏 O(N) 在攻击者构造碰撞时可能成为性能黑洞。
  • 如果是存储大对象(value 内部有大量数据),开散列更合适,因为链式存储避免了大对象在数组里的搬移拷贝。

5. 手写哈希表时的关键避坑指南

最后这一部分,我想集中写写我在实际手写和调试哈希表过程中遇到的典型问题。这些经验不在教科书里,但踩过一次就一定记得住。

5.1 哈希函数不能复用,不能随便取模

前面代码里,我在find、insert、erase里都调用了Hash()(key) % buckets_.size()。这里最大的坑是:你封装哈希函数时,必须注意哈希值计算与容器大小变化的解耦。如果你缓存了一个“哈希后的桶下标”,一旦扩容,这个下标就失效了。所以我的建议是永远只存储原始 key,需要时重新计算哈希,而不是存一个哈希值。

另外,std::hash<string>通常已经足够好,但对于特定格式的字符串(比如全是数字的 ID),它的分布可能不佳。踩过几次坑之后,我现在会为业务字符串设计专门的哈希函,比如把字符串中的每个字节乘以不同的质数权重。可以用 simple benchmark 来验证分布均匀性,在刻意构造的输入下看是否出现长链表。

5.2 闭散列扩容后,DELETED 标记必须清理

这个细节我漏过一次。闭散列 rehash 时如果直接复用旧表,可能会把 DELETED 状态的元素也搬过去,导致新表里出现无效位置,插入时还能用,但查找时影响探测链。正确做法是只在 rehash 时处理 EXIST 状态的元素,或者干脆把旧表标记全清。

5.3 开散列的内存泄漏与析构

开散列如果你选择裸指针,别忘记写析构函数。这段代码必须在析构函数里遍历所有桶,释放所有节点。我见过不少初学者漏掉这一步,程序退出时内存泄漏成灾。

~HashTable() { for (Node<K, V>* head : buckets_) { Node<K, V>* cur = head; while (cur != nullptr) { Node<K, V>* next = cur->next; delete cur; cur = next; } } }

还有拷贝构造和赋值运算符。如果你的哈希表作为对象被拷贝,默认的浅拷贝会让两个对象共享同一组链表节点,析构时二次删除直接崩溃。要么手写深拷贝,要么直接用= delete禁止拷贝移动,防止误用。

5.4 迭代器失效问题

STL 容器的 insert、erase 之后迭代器失效规则大家都了解,手写哈希表同样要明确这一点。开散列中,如果是 erase,指向被删除节点的迭代器必然失效,但其他节点的迭代器不受影响;如果是 rehash,所有迭代器都失效,因为桶数组换了。在业务代码里如果边遍历边删除,建议先记录待删除 key,遍历完成后统一删除,或者直接用标准库的方式:把 erase 返回值当成下一个有效迭代器。

5.5 模板特化与自定义类型支持

如果你希望自己的哈希表支持自定义结构体,比如坐标点、订单号,就需要给std::hash增加模板特化,或者给哈希表显式传入一个哈希函数对象。

struct Point { int x, y; bool operator==(const Point& other) const { return x == other.x && y == other.y; } }; namespace std { template<> struct hash<Point> { size_t operator()(const Point& p) const { return ((size_t)p.x << 32) ^ (size_t)p.y; } }; } // 使用 open_hash::HashTable<Point, string> table;

这类自定义类型最容易出错的地方是忘记实现operator==。哈希表在链表查找比较 key 时依赖==,如果没实现,编译器报错会非常不直观。我第一次给自定义结构写模板时,就因为这个错过编译。

6. 从手写哈希表中获得的工程体会

说实话,现在 C++ 标准库的 unordered_map 已经足够强大,日常开发完全没必要自己再写一套。但手写哈希表的收获完全不在“造轮子”本身,而在于对底层机制的理解。我在实现完开散列和闭散列后,再去看高并发内存池、无锁哈希表、布隆过滤器这些进阶内容,明显感觉不再心虚。理解了哈希冲突处理的本质,就能看懂很多系统设计的取舍。

如果你也想上手练一遍,我的建议是别直接抄上面的代码,先合上文章,只凭“状态数组 + 探测”和“桶数组 + 链表”这两句话,自己把插入、查找、删除写一遍。写不出来没关系,去对照代码 Debug 一遍,收获远比直接抄大得多。碰到问题再回来看这篇文章。祝你写得顺利。

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

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

立即咨询