从零实现C++哈希表:封装unordered_set与unordered_map的底层原理

📅 2026/8/2 5:15:22
从零实现C++哈希表:封装unordered_set与unordered_map的底层原理
1. 项目概述与核心价值最近在社区里看到不少朋友在讨论C STL中unordered_set和unordered_map的底层实现也有不少面试官喜欢问这块的内容。说实话光看标准库的接口文档总觉得隔着一层纱知其然不知其所以然。我干了十多年C深知一个道理想真正掌握一个容器最好的办法就是自己动手封装一个。这不只是应付面试更是为了在遇到一些诡异的内存问题或者性能瓶颈时你能一眼看穿问题的本质。今天我们就来干一件“造轮子”的硬核事儿从零开始用一个我们自己实现的哈希表来封装出功能完整的unordered_set和unordered_map。我们会叫它们MyUnorderedSet和MyUnorderedMap。这个过程会涉及到模板编程、迭代器设计、哈希冲突解决我们选用开链法、内存管理以及STL容器的接口规范。通过这个项目你不仅能彻底理解无序关联容器的运作机制还能大幅提升对C核心特性的运用能力比如模板、智能指针、RAII等。无论你是想夯实基础的中级开发者还是准备冲击大厂面试的求职者这个“轮子”都值得你亲手拧一遍。2. 底层哈希表的结构设计与选型在动手封装上层容器之前我们必须先打好地基——设计一个健壮、高效的哈希表。这是整个项目的核心引擎。2.1 为什么选择开链法Separate ChainingC标准库对unordered_set/map的实现并没有做强制规定但主流的实现如GCC的libstdc、Clang的libc在哈希冲突解决策略上普遍采用了开链法。我们选择它是基于以下几个务实的考量实现复杂度与稳定性相比开放定址法如线性探测、二次探测开链法的逻辑更直观。冲突的元素直接被链接到同一个桶bucket里形成链表或其它结构如红黑树在冲突严重时。这避免了探测带来的“聚集”问题也简化了删除操作在开放定址法中删除需要特殊标记否则会破坏查找链。负载因子Load Factor管理更灵活开链法对负载因子的容忍度通常更高。即使负载因子超过1即元素数量大于桶数量性能也是逐渐劣化而非像某些开放定址法那样在达到某个阈值后性能断崖式下跌。这让我们在实现rehash逻辑时有更大的缓冲空间。与标准库行为一致为了让我们的封装尽可能贴近标准库的行为包括迭代器失效规则、异常安全保证跟随主流实现选择开链法是更稳妥的策略。标准库要求插入操作只会在元素被实际插入时使迭代器失效而开链法在非rehash的情况下能很好地满足这一点。注意在极端情况下当某个桶的链表过长时标准库实现如libstdc会将其转换为一颗小型红黑树以保证最坏情况下的时间复杂度。作为教学和深度理解的项目我们初期可以先用链表实现后期可以将其作为一个高级优化点来扩展。2.2 哈希表节点与桶结构设计我们的哈希表HashTable将是一个类模板它需要存储任意类型的键值对。对于unordered_set值就是键本身对于unordered_map值是键和关联数据的组合pairconst Key, T。为了统一我们让哈希表的核心存储pairconst Key, Value。但unordered_set的Value其实就是Key。// 哈希表节点一个单向链表节点 template class T // T 对于set是Key对于map是pairconst Key, V struct HashNode { T _data; HashNodeT* _next; HashNode(const T data) : _data(data) , _next(nullptr) {} }; // 哈希表主体 template class K, class T, class KeyOfT, class HashFunc class HashTable { public: // 类型别名方便后续使用 typedef HashNodeT Node; // ... 成员函数 private: std::vectorNode* _tables; // 桶数组每个元素是一个链表头指针 size_t _size 0; // 存储的有效元素个数 };关键设计解析std::vectorNode*_tables 这是哈希表的“桶数组”。我们使用vector而不是原生数组是为了方便地利用其自动管理内存和size()、capacity()等成员函数简化rehash操作。size_t _size 记录当前哈希表中存储了多少个元素。注意这和_tables.size()桶的数量是不同的概念。负载因子 _size / _tables.size()。模板参数KeyOfT仿函数 这是一个关键抽象。因为对于setTKey我们需要从T中取出Key直接返回本身对于mapTpairconst K, V我们需要从pair中取出first即Key。通过传入一个仿函数哈希表的Find、Erase、Insert等逻辑可以统一处理无需关心T的具体类型。这是STL设计中常用的“策略模式”。模板参数HashFunc仿函数 哈希函数对象。用于计算任意类型Key的哈希值并将其映射到桶的索引通常通过hash(key) % _tables.size()。我们需要为内置类型如int、string和用户自定义类型提供特化或重载。2.3 哈希函数与桶索引计算哈希函数的选择直接影响性能。我们需要一个默认的哈希仿函数并允许用户自定义。// 默认哈希仿函数模板 templateclass K struct DefaultHash { size_t operator()(const K key) { return static_castsize_t(key); // 对于整型等直接转换 } }; // 针对std::string的特化常用 template struct DefaultHashstd::string { size_t operator()(const std::string str) { // 一个简单的字符串哈希算法BKDR Hash的变种 size_t hash 0; for (auto ch : str) { hash hash * 131 ch; // 乘数131是一个经验值 } return hash; } };在哈希表内部计算索引的函数可能如下size_t GetBucketIndex(const K key) { HashFunc hf; KeyOfT kot; // 1. 通过KeyOfT仿函数从数据T中提取出Key (kot(node-_data)) // 2. 通过HashFunc仿函数计算Key的哈希值 (hf(kot(node-_data))) // 3. 对桶数取模得到索引。注意桶数可能为0需要判断。 size_t hash hf(kot(key)); return hash % _tables.size(); }实操心得取模运算%在桶数_tables.size()为2的幂次方时可以优化为位运算 (_tables.size() - 1)效率更高。许多高性能哈希表实现会强制桶数量为2的幂并在扩容时也按2的幂增长。我们可以在rehash函数中实现这一点。3. 哈希表核心接口的封装实现有了基本结构接下来实现哈希表的几个核心操作插入、查找、删除和扩容。这些是上层unordered_set/map的基石。3.1 插入Insert操作与重复键处理插入操作需要处理以下几个关键点检查键是否已存在不允许重复键。检查是否需要扩容rehash。创建新节点并插入到对应桶的链表中通常采用头插效率高。std::pairiterator, bool Insert(const T data) { KeyOfT kot; HashFunc hf; // 1. 检查是否需要扩容 if (_size _tables.size()) { // 如果桶数为0则初始化为一个较小值如10否则扩容为约2倍 size_t newSize _tables.size() 0 ? 10 : _tables.size() * 2; // 可以在这里优化将newSize调整为下一个2的幂 _ReHash(newSize); } // 2. 计算索引并查找是否已存在 size_t index hf(kot(data)) % _tables.size(); Node* cur _tables[index]; while (cur) { if (kot(cur-_data) kot(data)) { // 键已存在返回该节点的迭代器和false return std::make_pair(iterator(cur, this, index), false); } cur cur-_next; } // 3. 键不存在执行插入头插 Node* newNode new Node(data); newNode-_next _tables[index]; // 新节点指向原链表头 _tables[index] newNode; // 桶头指针指向新节点 _size; // 4. 返回新节点的迭代器和true return std::make_pair(iterator(newNode, this, index), true); }扩容_ReHash的实现细节扩容是哈希表性能的关键。它需要创建一个新的、更大的桶数组然后将所有旧节点重新哈希rehash到新数组中。void _ReHash(size_t newSize) { // 1. 创建新的桶数组 std::vectorNode* newTables; newTables.resize(newSize, nullptr); // 2. 遍历旧表的所有桶 for (size_t i 0; i _tables.size(); i) { Node* cur _tables[i]; while (cur) { Node* next cur-_next; // 保存下一个节点因为cur的_next即将被修改 KeyOfT kot; HashFunc hf; // 3. 重新计算每个节点在新表中的位置 size_t newIndex hf(kot(cur-_data)) % newSize; // 4. 将当前节点头插到新桶中 cur-_next newTables[newIndex]; newTables[newIndex] cur; // 5. 处理旧桶中的下一个节点 cur next; } // 旧桶处理完毕将其置空节点已转移 _tables[i] nullptr; } // 6. 交换新旧表newTables离开作用域会自动释放旧内存但节点已转移vector本身内存不大 _tables.swap(newTables); }注意事项_ReHash的异常安全性。如果在new Node或重新哈希过程中抛出异常比如拷贝构造函数异常我们需要保证哈希表的状态不被破坏。一个简单的方法是先完成所有节点的转移最后再执行swap。swap操作通常是不抛异常的且能保证原子性。这样要么完全成功要么完全失败回滚。3.2 查找Find与删除Erase操作查找操作相对直接就是根据键计算桶索引然后在对应的链表中进行线性查找。iterator Find(const K key) { if (_tables.size() 0) { return end(); } KeyOfT kot; HashFunc hf; size_t index hf(key) % _tables.size(); Node* cur _tables[index]; while (cur) { if (kot(cur-_data) key) { return iterator(cur, this, index); } cur cur-_next; } return end(); }删除操作需要小心处理链表指针的维护并且要更新_size。bool Erase(const K key) { if (_tables.size() 0) { return false; } KeyOfT kot; HashFunc hf; size_t index hf(key) % _tables.size(); Node* cur _tables[index]; Node* prev nullptr; while (cur) { if (kot(cur-_data) key) { // 找到要删除的节点 if (prev nullptr) { // 要删除的是桶的第一个节点 _tables[index] cur-_next; } else { // 要删除的是中间或末尾节点 prev-_next cur-_next; } delete cur; --_size; return true; } prev cur; cur cur-_next; } return false; // 未找到 }3.3 迭代器Iterator的设计与实现为了让我们的MyUnorderedSet和MyUnorderedMap能够像STL容器一样使用范围for循环并与算法库协同工作我们必须为其提供迭代器。哈希表的迭代器是前向迭代器Forward Iterator。哈希表迭代器的难点在于当遍历完当前桶的链表后需要能够跳到下一个非空的桶。因此迭代器内部需要持有当前节点的指针Node*。哈希表本身的指针HashTable*用于访问桶数组。当前所在的桶索引size_t方便定位和向后跳转。// 前置声明 templateclass K, class T, class KeyOfT, class HashFunc class HashTable; templateclass K, class T, class KeyOfT, class HashFunc struct __HashIterator { typedef HashNodeT Node; typedef HashTableK, T, KeyOfT, HashFunc HashTable; typedef __HashIteratorK, T, KeyOfT, HashFunc Self; Node* _node; // 当前节点 HashTable* _pht; // 指向哈希表的指针用于遍历桶 size_t _bucketIndex; // 当前节点所在的桶索引 __HashIterator(Node* node, HashTable* pht, size_t bucketIndex) : _node(node), _pht(pht), _bucketIndex(bucketIndex) {} // 解引用操作符 T operator*() { return _node-_data; } T* operator-() { return _node-_data; } // 前置操作符核心逻辑 Self operator() { if (_node-_next) { // 情况1当前桶内还有下一个节点 _node _node-_next; } else { // 情况2当前桶的链表已遍历完需要寻找下一个非空桶 _bucketIndex; while (_bucketIndex _pht-_tables.size()) { if (_pht-_tables[_bucketIndex]) { _node _pht-_tables[_bucketIndex]; return *this; } _bucketIndex; } // 情况3后面没有非空桶了迭代器置为end() _node nullptr; _bucketIndex -1; // 或一个无效值 } return *this; } bool operator!(const Self it) const { return _node ! it._node; } // ... 其他必要的操作符如 };然后在HashTable类中定义iterator和const_iterator别名并实现begin()和end()。class HashTable { public: typedef __HashIteratorK, T, KeyOfT, HashFunc iterator; // const_iterator 类似需要额外设计 iterator begin() { // 找到第一个非空桶 for (size_t i 0; i _tables.size(); i) { if (_tables[i]) { return iterator(_tables[i], this, i); } } return end(); } iterator end() { return iterator(nullptr, this, -1); } // ... };踩坑记录迭代器操作是哈希表迭代器实现中最容易出错的地方。一定要处理好_node为nullptr时即end()迭代器的行为通常标准库要求对end()迭代器进行是未定义行为。在我们的实现中如果_node为空operator的逻辑可能会出错因此要确保begin()和end()的正确性并在文档中说明。4. 封装unordered_set与unordered_map现在我们有了功能完备的HashTable就可以用它作为底层容器来封装MyUnorderedSet和MyUnorderedMap了。这层封装主要是为了提供符合STL标准的接口并隐藏底层哈希表的实现细节。4.1 提取键KeyOfT仿函数的定义这是连接上层容器和底层哈希表的关键桥梁。// 针对 unordered_set 的 KeyOfT templateclass K struct SetKeyOfT { const K operator()(const K key) { return key; // 对于set数据就是key直接返回 } }; // 针对 unordered_map 的 KeyOfT templateclass K, class V struct MapKeyOfT { const K operator()(const std::pairconst K, V kv) { return kv.first; // 对于map需要从pair中提取出key } };4.2 MyUnorderedSet 的封装MyUnorderedSet的value_type、key_type都是Key。它内部持有一个HashTable实例并将大部分操作转发给这个实例。templateclass K, class HashFunc DefaultHashK class MyUnorderedSet { private: // 底层哈希表类型定义 // T K, KeyOfT SetKeyOfTK typedef HashTableK, K, SetKeyOfTK, HashFunc HashTableImpl; HashTableImpl _ht; // 唯一的成员变量 public: // 迭代器类型直接从底层哈希表继承 typedef typename HashTableImpl::iterator iterator; typedef typename HashTableImpl::const_iterator const_iterator; // 构造函数、析构函数等使用编译器生成的默认版本即可RAII // 容量 size_t size() const { return _ht.Size(); } bool empty() const { return _ht.Empty(); } // 迭代器 iterator begin() { return _ht.begin(); } iterator end() { return _ht.end(); } const_iterator begin() const { return _ht.begin(); } const_iterator end() const { return _ht.end(); } // 修改操作 std::pairiterator, bool insert(const K key) { return _ht.Insert(key); // 直接转发 } iterator find(const K key) { return _ht.Find(key); } bool erase(const K key) { return _ht.Erase(key); } // 其他接口如clear(), bucket_count(), load_factor()等也转发给_ht // ... };4.3 MyUnorderedMap 的封装MyUnorderedMap的value_type是pairconst K, V。它的封装与set类似但需要额外实现一个非常重要的特性operator[]。operator[]的行为是如果键存在返回其对应值的引用如果键不存在则插入一个以该键为键、以V()值初始化的键值对并返回其值的引用。这为map提供了非常方便的插入和修改语法。templateclass K, class V, class HashFunc DefaultHashK class MyUnorderedMap { private: // 底层哈希表类型定义 // T pairconst K, V, KeyOfT MapKeyOfTK, V typedef HashTableK, std::pairconst K, V, MapKeyOfTK, V, HashFunc HashTableImpl; HashTableImpl _ht; public: typedef typename HashTableImpl::iterator iterator; typedef typename HashTableImpl::const_iterator const_iterator; // ... 容量、迭代器、find、erase等接口与set类似转发给_ht // 核心operator[] 的实现 V operator[](const K key) { // 1. 尝试插入一个以key为键以默认构造的V为值的pair // Insert返回一个pairiterator, bool std::pairiterator, bool ret _ht.Insert(std::make_pair(key, V())); // 2. 返回这个pair中值的引用 return ret.first-second; // ret.first是迭代器-second是pair的第二个元素(V)的引用 } // insert 接口允许插入pair std::pairiterator, bool insert(const std::pairconst K, V kv) { return _ht.Insert(kv); } };operator[]的工作原理深度解析ret _ht.Insert(std::make_pair(key, V()));这一行是精髓。如果key已存在Insert会返回一个指向已存在节点的迭代器和false。此时V()是临时的会被忽略不影响已存在的值。如果key不存在Insert会创建新节点存储pairconst K, V(key, V())并返回指向新节点的迭代器和true。无论哪种情况ret.first都是一个有效的迭代器指向包含key的节点。ret.first-second就拿到了这个节点中pair的value部分的引用。因此map[key] value;这样的语句就能正常工作先通过operator[]获取到值的引用若不存在则创建然后对其进行赋值。5. 性能测试、对比与常见问题排查自己实现的容器必须经过测试才能放心使用。我们需要验证其功能正确性并与标准库的版本进行性能对比。5.1 功能正确性测试编写测试用例覆盖基本操作和边界情况。void TestMyUnorderedMap() { MyUnorderedMapstd::string, int wordCount; // 测试插入和operator[] wordCount[apple] 1; wordCount[banana] 2; wordCount.insert({cherry, 3}); assert(wordCount[apple] 1); assert(wordCount.find(banana) ! wordCount.end()); assert(wordCount.find(date) wordCount.end()); // 不存在的键 // 测试修改 wordCount[apple] 10; assert(wordCount[apple] 10); // 测试迭代 for (const auto kv : wordCount) { std::cout kv.first : kv.second std::endl; } // 测试删除 assert(wordCount.erase(banana) true); assert(wordCount.find(banana) wordCount.end()); assert(wordCount.size() 2); std::cout MyUnorderedMap basic tests passed! std::endl; } void TestMyUnorderedSet() { MyUnorderedSetint numSet; for (int i 0; i 100; i) { numSet.insert(i % 20); // 插入一些重复值 } // 集合应去重 assert(numSet.size() 20); assert(numSet.find(5) ! numSet.end()); assert(numSet.find(20) numSet.end()); std::cout MyUnorderedSet basic tests passed! std::endl; }5.2 性能对比分析我们可以设计一个简单的性能测试对比std::unordered_map和我们自实现的MyUnorderedMap在大量插入和查找时的耗时。#include chrono #include unordered_map void PerformanceTest() { const int NUM 1000000; std::vectorint keys(NUM); std::generate(keys.begin(), keys.end(), std::rand); // 测试标准库版本 { auto start std::chrono::high_resolution_clock::now(); std::unordered_mapint, int stdMap; for (int key : keys) { stdMap[key] key * 2; } for (int key : keys) { volatile int val stdMap[key]; // volatile防止被优化掉 (void)val; } auto end std::chrono::high_resolution_clock::now(); auto duration std::chrono::duration_caststd::chrono::milliseconds(end - start); std::cout std::unordered_map time: duration.count() ms std::endl; } // 测试自实现版本 { auto start std::chrono::high_resolution_clock::now(); MyUnorderedMapint, int myMap; for (int key : keys) { myMap[key] key * 2; } for (int key : keys) { volatile int val myMap[key]; (void)val; } auto end std::chrono::high_resolution_clock::now(); auto duration std::chrono::duration_caststd::chrono::milliseconds(end - start); std::cout MyUnorderedMap time: duration.count() ms std::endl; } }预期结果与分析在Release优化模式下我们自实现的版本性能可能与标准库版本有差距但不应相差数量级。如果性能差很多需要排查哈希函数是否为字符串等复杂类型提供了高效的哈希劣质的哈希函数会导致冲突剧增链表变长。扩容策略我们的_ReHash是否频繁触发标准库可能有更平滑的扩容策略如质数大小的桶数组或更智能的负载因子判断。内存分配频繁的new Node和delete可能成为瓶颈。标准库的实现可能使用了内存池等优化。编译器优化确保测试是在优化编译如g -O2下进行。5.3 常见问题与排查技巧实录在实现和使用过程中你可能会遇到以下问题问题现象可能原因排查与解决思路插入后查找不到元素1. 哈希函数计算错误导致插入和查找时计算的桶索引不一致。2.KeyOfT仿函数实现错误导致从数据中提取的Key不对。3. 扩容(rehash)后迭代器或查找逻辑有bug未正确映射到新桶。1. 打印哈希值对比插入和查找时的计算过程。2. 检查SetKeyOfT和MapKeyOfT的实现。3. 单步调试_ReHash函数观察节点是否被正确转移。内存泄漏1.Erase操作中delete了节点但链表指针未正确更新。2. 析构函数未正确释放所有节点内存。1. 使用Valgrind或AddressSanitizer等工具检测。2. 在HashTable的析构函数中遍历所有桶释放每个链表的所有节点。迭代器操作崩溃或死循环1.operator中未正确处理_node为nullptr即end()迭代器的情况。2. 在迭代过程中容器发生了rehash导致所有迭代器失效这是标准行为但用户代码继续使用失效迭代器。1. 仔细检查operator逻辑确保在找不到下一个非空桶时能正确返回end()。2. 明确文档说明插入操作可能导致rehash使所有迭代器失效。避免在迭代过程中进行可能引发rehash的插入。operator[]无法修改值MyUnorderedMap的iterator的operator-返回的指针类型错误导致second成员不是可修改的引用。检查__HashIterator中T的类型。对于mapT是pairconst K, Voperator-应返回pairconst K, V*但const K保证了键不可修改V应该是可修改的。确保iterator不是const_iterator。负载因子已很高但性能未下降测试数据过于理想如连续整数哈希函数为直接取模冲突极少。使用随机数据或特定的冲突密钥进行测试。可以尝试让哈希函数返回一个常数强制所有元素进入同一个桶来测试最坏情况下的链表性能。一个高级优化点桶数量使用质数标准库的unordered_map在扩容时通常会选择一组质数作为桶大小的序列如53, 97, 193...。这是因为对质数取模能更好地分散哈希值减少因哈希函数与桶大小存在公因子而导致的不均匀分布。你可以在_ReHash函数中实现一个GetNextPrime(size_t num)函数来获取下一个质数作为新的桶大小这能在一定程度上提升哈希表的均匀性。从头实现一遍unordered_set/map是一个工程量不小但收获巨大的练习。它强迫你去思考哈希表的每一个细节内存布局、冲突解决、迭代器遍历、异常安全、API设计。当你再使用STL的容器时你会对它的行为有更精准的预测对可能出现的性能问题有更敏锐的直觉。更重要的是你亲手搭建的这个“轮子”会成为你理解更复杂数据结构比如C17的unordered_map的节点拼接API的坚实基础。下次面试官再问你哈希表的相关问题你大可以自信地从开链法讲到迭代器失效规则这背后的底气就来源于这次深入的实践。