☰
纯C++实现哈希桶:从unordered_map到unordered_set的封装实践
2026/9/27 0:52:07 网站建设 项目流程

哈希桶这个说法,老读者应该不陌生——但大部分人和它初次见面是在课本的插图上:一个个桶,每个桶里挂着几个元素,哈希函数把键均匀撒进去。真正让我决定自己动手写一份桶式哈希的,是用了两三年std::unordered_map之后的那点好奇心:它凭什么做到平均 O(1) 查找?为什么标准库的迭代器在插入后大概率还健在?以及最常见的疑问——unordered_map 和 unordered_set 明明一个存键值对、一个只存键,底层能不能共用同一套哈希桶逻辑。这篇文章就是我基于纯 C++ 模板实现的一份完整封装记录,核心产出物是UnorderedMap和UnorderedSet两个容器类,底层共用一份HashTable骨架。写出来之后我才发现,真正的难点根本不在哈希函数上,而在迭代器设计和接口兼容上。

1. 为什么选"桶式哈希":冲突方案选型背后的取舍

哈希表绕不开一个话题:冲突处理。就算哈希函数写得再好,只要键空间远大于存储规模,鸽子笼原理就会逼着多个键落到同一个位置。市面上主流的冲突策略无非两类——开放定址法和链地址法,而"哈希桶"属于链地址法的经典形态:底层是一个固定大小的桶数组,每个桶指向一条链表,冲突的元素在同一桶里串起来。

我当时先画了个对比表,把两个方案的关键属性摆在一起看:

维度开放定址法链地址法(哈希桶)
删除操作麻烦,需要墓碑标记链表常规删除即可
满载容忍度负载因子通常得压在 0.5~0.7 以下默认可到 1.0 甚至更高
元素存储位置在桶数组内部连续存储每个元素独立节点,散落堆上
迭代器/引用稳定性搬迁时全部失效只要不删除当前元素,引用稳定

为什么 C++ 标准库的 unordered 系列最终选择了链地址?我查过标准里的一个细节:无序容器的insert和erase有明确的迭代器失效规则——erase只会让指向被删除元素的迭代器失效,insert只要不触发扩容就不会让任何已有元素的引用失效。开放定址法天然做不到这一点,因为元素存在桶数组里,扩容时全部元素都得搬新家,引用全断。链地址法就轻松了:每个元素就是堆上一个独立节点,桶数组只存入口地址,扩容只是把链表节点重新挂到新桶,元素本体纹丝不动。

所以从标准库的角度讲,链地址法不是"性能最优",而是"语义约束下的必然选择"。这也给了我一个明确信号:我自己封装时,如果希望接口行为尽量贴近 STL,就必须用节点式存储。换句话来说,标题里的"哈希桶"不只是一个实现细节,它直接决定了后续迭代器、引用稳定性、扩容逻辑的整套设计。

当然,开放定址法在现代工程里也有它的高光时刻——absl::flat_hash_map这类内存紧凑型容器就是开放定址的产物,性能在某些场景下比标准 unordered 还猛。但那是另一个故事了。作为学习项目的实现方案,桶式哈希有着最清晰的结构逻辑和最低的落地成本,我最后拍板用它,还有一个很实际的原因:调试过程中一眼就能看明白"哪个键挂在哪个桶",排查问题的体验比开放定址友好得多。

2. 底层结构设计:桶数组、节点和内存的那些细节

结构定下来之后,第一步就是把HashTable的骨架写对。我的做法是让底层哈希表不要关心"存的是单个键还是键值对",它只需要知道三件事:怎么从存储对象里取出键、怎么计算哈希值、怎么比较两个键相等。

template <typename Key, typename Value, typename KeyOfValue, typename Hash = std::hash<Key>, typename KeyEqual = std::equal_to<Key>> class HashTable { public: using size_type = std::size_t; private: using Bucket = std::list<Value>; using BucketIt = typename Bucket::iterator; using Buckets = std::vector<Bucket>; Buckets _buckets; size_type _elementCount = 0; double _maxLoadFactor = 1.0; };

_buckets是一个std::vector<std::list<Value>>。这里我特意选了std::list而不是手写单链表节点,原因很简单:std::list的节点独立性天然保证元素引用稳定,而且它内置的splice操作可以在 O(1) 时间内把一个节点从一条链表搬到另一条链表,这在扩容时是杀手锏。

什么情况下你会想换成裸链表?如果你对内存占用有极致的强迫症,一个std::list节点通常带两个指针和一个大小字段,配合桶数组里的空 list 对象,内存确实比一个next指针的单链表夸张。我当时也手写过一版裸节点链表,结果很快被内存释放的问题拖住了调试进度——插入、删除、异常路径都要自己记挂着节点的生命周期。后来换回std::list,一行splice解决迁移,代码干净一个量级。我的结论是:学习项目优先保证逻辑清晰,性能差距后面再谈。

桶数组的容量我刻意设计成 2 的幂。这样计算桶索引时可以用hash & (bucketCount - 1)代替hash % bucketCount,省掉一次除法。现代 CPU 对取模有硬件加速,这个省下来的时间在 1e6 规模下其实不明显,但它让代码至少看起来是"懂行"的。真正要注意的是:当容量是 2 的幂时,哈希值的高位变化会被丢弃,只取低位算索引。如果哈希函数质量差,低位分布差,就会引发集中冲突。为了对冲这个风险,我默认的哈希函数必须是扩散良好的,这点会在后面专门展开。

节点存储类型有个隐蔽的坑,提前说出来省得你踩:对于UnorderedMap,底层存储的Value是std::pair<const Key, V>。第一个模板参数带const,这是为了让外部无法通过迭代器修改键。但const成员也给构造带来了麻烦:你不能给pair<const K, V>直接赋值,只能构造。这意味着底层在插入新节点时,必须用"构造"而不是"先默认构造再赋值"的方式创建节点。std::list::push_back好就好在它是原地构造,传一个Value对象进去直接被完美转发到节点内存,天然适合pair<const K, V>这种不可赋值类型。

初始化时_buckets默认构造出一堆空 list。插入第一个元素前,我会顺手调用一次rehash(8)把桶数量预设出来,免得首次插入就扩容。这个细节不写出来不影响功能,但对性能影响挺实在——第一次插入就触发 rehash,等于白付一次搬迁成本。

3. 迭代器:哈希容器封装里最烧脑的一块

如果说哈希桶是整个项目的地基,那迭代器就是地基上的承重墙。我封装过程里百分之七十的功夫都花在这上面,所以单独拿出来讲。

先看迭代器需要维护什么状态。它至少得知道两件事:当前指向的是哪个桶里的哪个节点,以及哈希表本体在哪里(为了自增时找到下一个桶)。我设计的迭代器是这样的:

template <typename Table> class HTIterator { using Value = typename Table::value_type; using BucketIt = typename Table::BucketIt; public: HTIterator(Table* table, std::size_t bucketIndex, BucketIt cur) : _table(table), _bucketIndex(bucketIndex), _cur(cur) {} Value& operator*() const { return *_cur; } Value* operator->() const { return std::addressof(*_cur); } HTIterator& operator++() { ++_cur; if (_cur == _table->_buckets[_bucketIndex].end()) moveToNextNonEmptyBucket(); return *this; } private: void moveToNextNonEmptyBucket() { ++_bucketIndex; while (_bucketIndex < _table->_buckets.size() && _table->_buckets[_bucketIndex].empty()) { ++_bucketIndex; } if (_bucketIndex < _table->_buckets.size()) _cur = _table->_buckets[_bucketIndex].begin(); else _cur = typename Table::BucketIt{}; // 哨兵:指向末尾 } Table* _table; std::size_t _bucketIndex; BucketIt _cur; friend class HashTable<...>; };

注意operator++的逻辑:先把当前桶里的迭代器往后挪一格;如果挪到了当前桶的end(),就要跳到下一个非空桶。这里有一个微妙点——如果当前桶里有很多个元素,那么一轮自增只走一个元素,非常快;如果刚跳到的新桶是空的,就需要连续跳过多个空桶。所以哈希表迭代器的单次自增在最坏情况下是 O(桶数),平均下来仍是 O(1)。这也是桶式哈希迭代器的一个典型特征,你没法保证每次++都是常数时间。

我有一次在这里写出过一个经典的 bug:跳桶时忘了处理尾部哨兵,导致遍历到大桶数组末尾后_bucketIndex越界访问。调试的时候表现得很诡异——不是每次都崩,而是只有遍历完所有元素之后下一次++才崩。后来我养成了一个习惯:在这个moveToNextNonEmptyBucket里先把越界检查写满,再写跳桶逻辑,顺序绝对不能反。

再说const_iterator。我的做法是用模板参数抽象迭代器的"可变性"——把iterator和const_iterator归结为同一个模板的不同实例:

template <bool IsConst> class HTIterator { using BucketIt = typename std::conditional<IsConst, typename Table::BucketConstIt, typename Table::BucketIt>::type; ... };

这样iterator可以隐式转换成const_iterator,只要提供对应的转换构造函数:HTIterator<true>(const HTIterator<false>&)。为了避免const_iterator内部持有Table*却能修改表结构的问题,我在operator++里已经保证了只读_table->_buckets,不修改任何成员——也就是说const_iterator读到的表结构实际上来自一个const HashTable*。这一步的正确性,是在编译层面用const限定符保证的。

还有个容易忽略的细节:begin()必须跳过所有空桶,直接指向第一个非空桶的首元素;如果全是空桶,就返回end()。写的时候我建议把"找第一个非空桶"单独抽成一个私有函数,begin()和moveToNextNonEmptyBucket都能复用它。别嫌麻烦,这个函数你会在调试迭代器的时候反复看。

4. 一个底层两个容器:类型萃取与接口复用

底层哈希表准备好了,接下来才是标题里的重头戏:怎么用同一份HashTable封装出unordered_map和unordered_set两个长得不一样的容器。

核心思路是模板参数注入差异,而不是继承。HashTable的第三个模板参数KeyOfValue是一个函数对象类型,它的职责很简单:从存储对象里提取出键。对unordered_set来说,存储对象本身就是键,提取就是恒等函数;对unordered_map来说,存储对象是pair<const K, V>,提取就是取.first。

template <typename K, typename V, typename Hash = std::hash<K>, typename KeyEqual = std::equal_to<K>> class UnorderedMap { private: using Value = std::pair<const K, V>; struct Select1st { const K& operator()(const Value& kv) const { return kv.first; } }; HashTable<K, Value, Select1st, Hash, KeyEqual> _table; public: using key_type = K; using mapped_type = V; using value_type = Value; using iterator = typename decltype(_table)::iterator; // ... 接口转发 };

UnorderedSet那边更简单:

template <typename K, typename Hash = std::hash<K>, typename KeyEqual = std::equal_to<K>> class UnorderedSet { private: struct Identity { const K& operator()(const K& k) const { return k; } }; HashTable<K, K, Identity, Hash, KeyEqual> _table; // ... };

这套Select1st/Identity的命名,是从 SGI STL 里继承过来的老设计。它让我意识到所谓"封装"在这个语境下,更多是编译期的多态——所有差异在模板实例化那一刻被编译器展开,运行期零开销。对比"封装继承多态"里那种通过基类虚函数实现的运行时多态,这里用的是完全不同的思路:接口相同但行为在模板参数层面分岔。

接口转发阶段有几个坑。第一个是insert。unordered_map对外接口的value_type是pair<const K, V>,但用户写m.insert({1, 2})传进来的是一个pair<int, int>,两者的类型不同。底层的HashTable::insert接收的是Value即pair<const K, V>,所以顶层需要一个转换构造。好消息是std::pair提供了"从另一个 pair 转换构造"的模板构造函数,pair<const K, V>可以从pair<K, V>构造,所以直接转发就能过编译。

第二个坑是operator[]。它和insert的语义不同:如果键存在,返回已有值的引用;如果不存在,插入一个默认值再返回引用。你可能会想先find再决定插入,但这会触发两次查找。更高效的做法是借助底层insert的返回值——它返回一个pair<iterator, bool>,迭代器总是指向目标元素:

V& operator[](const K& key) { auto result = _table.insert(Value{key, V{}}); return result.first->second; }

注意一个问题:如果插入触发了扩容,扩容前的迭代器会失效,但insert返回的迭代器是新扩容后重新定位过的,所以return result.first->second是安全的。我最初为了省事,先find找不到再insert,结果在自定义类型上因为要求 V 可默认构造,反而绕了远路。直接用insert的返回值的方案,逻辑更短,还能顺便利用底层对重复键的检测。

第三个坑是erase的返回值。C++11 之前erase(iterator)返回 void,C++11 之后标准要求 unordered 容器的erase返回"被删除元素的下一个迭代器"。我们的底层用std::list实现,erase天然返回下一个迭代器,所以转发时只要把底层返回值原样透传即可。但如果你用的是手写单链表,这一步就得自己维护"下一个"指针——又费神又容易写错。这也是我推荐std::list做桶的又一个理由。

5. 哈希函数、相等比较与自定义类型支持

封装的顶层接口里,我把Hash和KeyEqual作为默认模板参数暴露给用户,默认值分别是std::hash<K>和std::equal_to<K>。这意味着如果你只是往UnorderedSet<int>里塞整数,什么都不用改。但实际的工程里,你大概率会遇到需要放进 unordered 容器的自定义类型。

这里涉及一个很容易被误解的点:为什么需要同时提供哈希函数和相等比较?哈希函数的职责是把键映射到桶索引;相等比较的职责是判断两个落在同一桶里的键是否真的相同。两者缺一不可——哈希表靠"必有一致"这条约定工作:只要两个键相等,它们的哈希值必然相同,所以它们必然落到同一个桶里。std::equal_to保证相等比较,std::hash配合桶索引保证"相同的键进相同的桶"。

如果这个约定被破坏,表里就会藏着永远不会被找到的元素。一个典型的反面例子:你给某个自定义类型写了哈希函数,却忘了重载operator==,结果equal_to退化成最朴素的按内存比较——两个逻辑上相等但字段顺序不同的对象进不了同一个槽,查找直接失败。我在开发时遇到过这种问题,当时查了很久才发现是operator==没写对。

先看自定义类型的哈希怎么写。假设有一个二维点结构:

struct Point2D { int x; int y; bool operator==(const Point2D& other) const { return x == other.x && y == other.y; } }; struct Point2DHash { std::size_t operator()(const Point2D& p) const { std::size_t seed = 0; seed ^= std::hash<int>{}(p.x) + 0x9e3779b9 + (seed << 6) + (seed >> 2); seed ^= std::hash<int>{}(p.y) + 0x9e3779b9 + (seed << 6) + (seed >> 2); return seed; } };

那个神秘常数0x9e3779b9是黄金比例在 32 位空间里的近似值,用在位混淆上能有效打散相近输入的哈希值。这个seed ^= h + golden_ratio + (seed<<6) + (seed>>2)的组合方式,就是赫赫有名的hash_combine类算法,它比简单地把两个子哈希异或靠谱得多。简单异或的问题在于:hash(a,b)和hash(b,a)得到一样的结果,而且如果多个子字段哈希值相同,异或会把它们抵消。hash_combine通过每次累加时做移位和加法,让 seed 充分吸收每个字段的贡献。

还有个小提示:自定义类型的operator==尽量写成 "member function" 或 "hidden friend"。只要能保证同类型对象间可以比较即可。哈希容器内部用的是KeyEqual()(a, b)这种函数对象调用形式,所以自定义的仿函数只要实现了operator()一样能用。

哈希函数质量这块,还有一个容易被忽视的问题:当你用 2 的幂作为桶数量时,桶索引只取哈希值的低位。如果自定义哈希返回的 size_t 天然集中在低 4 位有差异、高位全是零,那不管桶数量多大,所有键都挤在十几个桶里。所以我在默认设计里要求哈希函数至少做到"低位扩散"。对于标准库的std::hash<int>,它通常就是返回整数本身,质量本来就不算好,但它配合 STL 那些质数桶容量能缓解这个问题——而我们用的 2 的幂容量就吃这个亏。好在现在的编译器和库版本里std::hash实现普遍做了位混洗,实测下来随机 int 的分布是够用的。如果你的键是自己设计的高低位差异明显的整数,建议加一步hash ^= hash >> 16之类的位混淆。

6. 扩容、删除与迭代器失效:最容易翻车的地方

哈希桶容器里,insert和erase在特定条件下会改变桶的结构,处理不好就用出内存问题或语义错误。这一节把最容易翻车的三件事讲清楚。

6.1 触发条件与扩容过程

我实现的触发阈值是_elementCount / _buckets.size() >= _maxLoadFactor,默认_maxLoadFactor = 1.0。每次insert前检查一次,如果达到或超过阈值就扩容。容量增长策略是翻倍——从 8 到 16 到 32,保证始终是 2 的幂。

扩容过程的核心操作是splice。因为std::list的节点迁移是 O(1),而且不会触发元素拷贝和构造,整个过程都不会抛异常。我先把这一步写成伪代码再贴真实现:

void rehash(size_type newBucketCount) { Buckets newBuckets(newBucketCount); for (auto& bucket : _buckets) { for (auto it = bucket.begin(); it != bucket.end(); ) { auto next = std::next(it); std::size_t newIndex = hashOf(*it) & (newBucketCount - 1); newBuckets[newIndex].splice(newBuckets[newIndex].end(), bucket, it); it = next; } } _buckets.swap(newBuckets); }

newBuckets构造时一次性分配好全部空桶,这一步可能抛异常(内存不够)。但一旦进入节点迁移阶段,全程不分配内存、不拷贝元素,只是调整链表指针,所以不会抛异常。这意味着"扩容过程中途数据损坏"这种事被结构性地杜绝了——新桶数组要么分配成功,要么整个操作直接抛出异常,旧表纹丝不动。这个设计天然提供了很强的异常安全保证。

有朋友问过:为什么不用"构造一个全新的 HashTable 再 swap"这种更简单的方案?省事是省事,但它要把所有元素重新拷贝一遍,对非平凡类型是巨大的浪费。splice方案本质上是把元素所有权从一个 list 转移到另一个 list,一毛钱拷贝都没有。这个差距在元素是 string 或者自定义大对象时非常明显。

6.2 迭代器失效的语义边界

标准库对 unordered 容器的规定是:rehash会让所有迭代器失效,但元素的引用和指针不受影响。我们抄这个语义做:扩容后你手里旧的迭代器不能再++,但如果你提前保存了某个元素的Value*,它依然能访问——因为元素节点没动,只是换了个桶挂。

删除则更敏感。erase(iterator)只让被删元素的迭代器失效,其余全部健在。由于底层是std::list,这个语义是自动满足的。我曾经在这上面踩过一个坑:遍历容器时想边遍历边删除,用for (auto it = c.begin(); it != c.end(); ) { if (cond) it = c.erase(it); else ++it; },这个写法属于标准做法。但如果我偷懒写成c.erase(it++);,虽然合法,但可读性差一些,在代码审查里容易被质疑。建议统一用前者。

6.3 调试用的 sanity check

哈希容器做出来之后,我投入了大量时间在 bug 上。后来写了一个debugSanityCheck()函数,在每次插入、删除、扩容后调用,它验证三条不变式:

  • _elementCount等于所有桶的元素数量之和
  • 每个元素通过KeyOfValue取出的键,重新哈希后确实落在它当前的桶里
  • 满足相等条件的两个不同对象不会同时出现在同一个桶里(如果出现,说明哈希约定被破坏)

这个检查函数在 release 模式里会被宏关掉,在 debug 模式下帮了我大忙。有一次自定义类型的operator==写错了,导致重复元素被当成不同键存进同一个桶,就是靠第三条不变式揪出来的。写容器类时埋一个这样的内部自检钩子,实战价值极高。

7. 实测对比:自研容器与 std::unordered_map 的差距

代码写完不能只停留在"能跑",我用一个简单的基准测试评估了一下自家容器和标准库的差距。测试环境是 GCC 11 配合-O2,数据集是 1e6 个随机int,分别测插入、查找、遍历三个场景。

先说结论:自研UnorderedMap的时间复杂度量级和std::unordered_map完全一致,绝对耗时整体慢 10%~30%,差距主要来自几个方面。

第一是哈希函数。std::unordered_map底层在哈希值上做了额外的位混洗,而我们直接用std::hash<int>的原始结果配合低位掩码。随机 int 的场景下问题不大,但如果数据是连续的整数,低位分布其实还行,所以差距有限。真要说大差距场景,还是得回到"分布差的哈希"上去。

第二是桶结构。我们用std::list做桶,每个节点有两个指针的开销,遍历时缓存局部性差——一条链表上的节点在堆上随机散布,CPU 缓存命中率自然不如 STL 里那种更紧凑的节点布局。这也是链地址法普遍比开放定址法慢的内存层面的原因。

第三是operator[]的插入路径。自研版在find失败后走insert,而insert内部还要再做一次查找来确认是否重复。虽然可以优化成"一次查找同时完成定位与插入判断",但实现复杂度就上去了。目前这个版本能和标准库保持在同一个数量级,已经达到我封装这个项目时的预期。

内存占用方面,我们每个元素多出的开销是std::list节点的额外指针,在int这种小对象上尤其明显。标准库的 unordered 实现也保留了类似结构,只是节点组织方式更紧凑。这里提一个可能的方向:如果你对内存和数据局部性有硬性要求,可以考虑换用开放定址法的 flat hash map 系列——这是后话了。

最后说一个我在实测中发现的有趣现象:用同样的插入顺序,自研容器和std::unordered_map的遍历输出顺序完全不同。哈希表的遍历顺序本来就是由"哈希值对桶数取模"决定的,和插入顺序无关。所以写业务代码时永远不要依赖 unordered 容器的遍历顺序,这是用哈希容器的人最容易忽略的一条铁律。

回看整个封装过程,我最大的体会是:哈希桶项目真正难的地方不是"把哈希函数写对",而是"把迭代器边界和容器接口语义理顺"。当一个底层HashTable同时喂给UnorderedMap和UnorderedSet时,你会被迫把"容器通用逻辑"和"键值存储差异"拆得干干净净,这种抽象能力是刷多少算法题都换不来的。如果你也想动手写一遍,我的建议是先把迭代器的自增逻辑在纸上画出三个桶的状态图,标清楚end()的位置,再动手写代码——这一步做好了,后面整个项目会顺畅得多。

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

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

立即咨询