拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

从原理到实现:C++手写哈希表与unordered_map实战指南

从原理到实现:C++手写哈希表与unordered_map实战指南

我最初接触C++的时候,总觉得"哈希表"是什么很高深的东西。直到我写了个需要快速查询用户信息的模块,用数组吧,key不连续根本没法当索引;用链表吧,几万条数据查一次要等半天;用map吧,明明知道是红黑树,还是O(log n)。后来我认真把从"哈希表是什么"到"手写一个能用的哈希表"这条路完整走了一遍,才意识到它遍地都是、原理也不难,难的是把那些工程细节想明白。这篇内容就按我自己的学习路径来写,从原理推导到完整实现,再到和标准库的差距,最后是实战里那些坑,希望对刚入门C++的朋友有一点帮助。

1. 哈希表到底牛在哪:从"数组下标"到"任意键查找"

1.1 数组的尴尬:我明明只需要查一个值

先想一个最简单的场景:班里50个学生,每个学生有一个学号,学号从1到50连续排列。这个时候用数组存,索引就是学号,查一个学生的时间是O(1),完美。

但现实哪有这么巧。假设学校一共有10000个学生,学号是5位数字,而你的班里只有30个学生,学号分布得很散。你要是还拿学号当数组下标,就得开一个长度10000的数组,其中9970个位置是空的。这还不算最糟的——如果key是"用户名"、是"身份证号"、是"订单号",甚至是一串字符串,数组下标这个思路直接就没法用了,因为数组下标要求的是一个非负整数,你总不能拿字符串当下标吧。

你当然可以把每个用户按顺序存在数组里,然后遍历去找,那时间复杂度是O(n)。数据量小还好,数据一多就完蛋。链表也一样,救人一命的说法是"灵活",但代价是查找要一个节点一个节点地走。

哈希表解决的就是这个问题:不管你的key是字符串、数字还是自定义对象,只要你能算出一串整数作为它的"指纹",我就能把这个整数映射到数组的一个位置,然后以O(1)的平均复杂度把数据查出来。

所以哈希表的本质,就是"数组"和"一个神奇的映射函数"的结合体。数组负责快速定位,函数负责把任意key变成合法的数组下标。

1.2 哈希函数:把"名字"变成"门牌号"的翻译官

这个"神奇的映射函数"就叫哈希函数。它的职责是接收一个key,输出一个size_t类型的整数。这个整数通常很大(在64位机器上最大能到2^64-1),所以我们不能直接拿它当数组下标,还得对桶的数量取一次模。

比如我定义了一个桶数组,长度是1000。key是字符串"student_9527",哈希函数算出来的值是123456789,那我把它放在123456789 % 1000 = 789这个位置上。以后要查找的时候,重新算一遍同样的哈希值,再取模,直接跳到789这个槽位就找到了。整个过程不管数据量有多大,都只做一次哈希计算加一次数组访问。

一个合格的哈希函数,要满足三个条件:

  • 确定性:同一个key,任何时候算出来必须是同一个哈希值。这是哈希表能工作的前提。
  • 均匀性:不同的key,尽量把哈希值散开,不要扎堆。扎堆了就会导致冲突,冲突多了性能就崩。
  • 高效性:计算哈希值本身的代价不能太大。如果算个哈希要遍历几个MB的字符串,那比直接遍历查找还慢,得不偿失。

C++标准库里提供了std::hash<K>这个函数对象,它对很多内置类型和常见类型都有特化。比如std::hash<int>对整数的实现通常就是返回这个整数本身(或者经过一个位混合),std::hash<std::string>则会把字符串逐字符处理成一个整数。在入门阶段,我们直接用std::hash<K>{}(key)就行。

1.3 冲突:两个不同键撞进同一扇门怎么办

均匀性只是理想情况,现实里两个不同的key算出相同的哈希值、取模后落进同一个桶,这种事情一定会发生。比如"abc"和"cba"完全可能算出同一个哈希值,毕竟哈希函数只是把无限多的key压进有限的范围,冲突无法避免。

处理冲突有两大流派:

链地址法:每个桶不直接存元素,而是存一个链表的头指针。凡是哈希值落进同一个桶的元素,就挂在这个链表后面。查找的时候,先算出桶下标,再走进这个桶对应的链表,逐个比较key。这是C++std::unordered_map采用的方案(现代实现为了防攻击还会在链表过长时转成红黑树,但那是后话)。

开放寻址法:当发生冲突时,不在同一个位置硬挤,而是按照某种规则继续往后找空位。比如线性探测:位置被占了就去下一个位置看看,直到找到空位或者遇到一个标记为"已删除"的槽位。Python的dict就采用了这个方案。

打个比方,链地址法就像一栋公寓的信箱墙,每家一个信箱。有两封信地址写得有点模糊,都投到了同一个信箱,那物业就在这个信箱里加了个小隔层,两层都给你塞下。开放寻址法则像是停车位被占了之后,你在停车场里绕圈找下一个空车位。

入门阶段我强烈建议先吃透链地址法,因为它思路直白、代码好写,也好调试。等把链地址法玩明白了再回头看开放寻址法,会容易很多。下面就开始进入实现环节。

2. 手写第一版哈希表:链地址法是最容易上手的实现

2.1 结构设计:桶数组加链表,先画清楚再写码

动手写代码之前,先把结构在脑子里画清楚。一个链地址法哈希表由三部分组成:

  1. 一个桶数组,类型是std::vector<Node*>,每个元素是一个链表的头指针。
  2. 一个节点结构体,里面存key、value和指向下一个节点的指针。
  3. 一个元素计数器,记录当前总共存了多少个元素,用来判断要不要扩容。

模板设计我选择了template<typename K, typename V>,这样key和value的类型都可以自由替换,写起来更贴近标准库。

#include <vector> #include <functional> template <typename K, typename V> class MyHashMap { private: struct Node { K key; V value; Node* next; Node(const K& k, const V& v) : key(k), value(v), next(nullptr) {} }; std::vector<Node*> buckets; size_t elementCount = 0; size_t bucketCount = 0; float maxLoadFactor = 0.75f; size_t hashIndex(const K& key) const { return std::hash<K>{}(key) % bucketCount; } public: explicit MyHashMap(size_t initialBuckets = 16) : bucketCount(initialBuckets) { buckets.resize(bucketCount, nullptr); } };

这里我初始化了16个桶。hashIndex是核心辅助函数:先调用std::hash<K>算出哈希值,再对bucketCount取模得到桶下标。

有个细节值得注意:bucketCount必须和buckets.size()保持一致,否则扩容后hashIndex里的分母就错了。我干脆单独用一个变量存桶数量,虽然冗余了点,但是扩容时逻辑更清楚。

再补充一个点:当key是字符串时,std::hash<std::string>是能直接用的。但如果key是自定义结构体,比如一个struct MyKey { string name; int id; };,编译器会直接报错,因为标准库里没有对MyKey的特化。解决办法是给这个结构体写operator==并偏特化std::hash,这在后面章节会细讲。

2.2 插入与查找的完整实现

插入的逻辑一句话总结就是:算桶下标,在桶的链表里找key。找到了就更新value,找不到就头插一个新节点。

头插比尾插省事,不用遍历到链表末尾,而且还省时间。至于顺序问题,哈希表本身不承诺有序,头插完全没毛病。

void insert(const K& key, const V& value) { // 插入前先判断负载因子,超过阈值就扩容 if (static_cast<float>(elementCount + 1) / bucketCount > maxLoadFactor) { rehash(); } size_t index = hashIndex(key); Node* current = buckets[index]; // 先看链表里有没有这个 key while (current) { if (current->key == key) { current->value = value; // 已有 key,更新 value return; } current = current->next; } // 没找到,头插新节点 Node* newNode = new Node(key, value); newNode->next = buckets[index]; buckets[index] = newNode; ++elementCount; }

查找的套路和插入的前半段几乎一样:

bool find(const K& key, V& value) const { size_t index = hashIndex(key); Node* current = buckets[index]; while (current) { if (current->key == key) { value = current->value; return true; } current = current->next; } return false; }

返回bool而不是直接返回V,是因为C++没法方便地表达"空值"。标准库用迭代器指向end()来表示没找到,但自制哈希表先返回bool最快的做法。

这里我想强调一个容易忽略的点:比较链表节点用的是current->key == key,也就是说我们的哈希表依赖key类型重载了operator==。如果key是基本类型,没问题;如果key是自定义结构体,你就得自己写operator==。哈希函数负责"定位",operator==负责"确认",两个缺一不可。定位只能把你带到正确的桶,桶里可能有多个不同key,必须逐个比key才能确定最终有没有找到。现在很多带引号的"哈希表教程"只讲哈希函数不讲相等比较,导致新手写出来一堆诡异bug,这个坑一定要避开。

2.3 删除操作的隐藏陷阱

删除比插入和查找都麻烦一点,因为要维护链表结构。最容易被坑的就是:如果要删的是链表的第一个节点,头指针得更新;如果删的是中间节点,前一个节点的 next 得绕过待删节点。

一个不容易漏的写法是用"二级指针"或"指向指针的指针",可以直接拿到头指针的地址,统一处理头删和中间删除:

bool erase(const K& key) { size_t index = hashIndex(key); Node** current = &buckets[index]; while (*current) { if ((*current)->key == key) { Node* toDelete = *current; *current = toDelete->next; // 把当前节点的 next 交给上一个节点的 next delete toDelete; --elementCount; return true; } current = &((*current)->next); } return false; }

这段代码第一次看可能觉得别扭,但它是处理头删最优雅的方式。Node** current指向的是"上一个节点里存的next指针"的地址,初始时就是&buckets[index]。当找到目标节点时,*current就是当前的节点指针,我把它替换成toDelete->next,等于直接改了上一个节点的 next 或者桶的头指针,不需要区分是不是头节点。

不要忘了delete toDelete和--elementCount。在我的第一版实现里就漏了--elementCount,结果负载因子越算越小,内存逐渐失控。这种小错误平时不炸,要到内存和性能出问题了才追悔莫及。

我再顺手补一个析构函数,否则每个节点new出来没人管就要内存泄漏:

~MyHashMap() { for (Node* head : buckets) { while (head) { Node* next = head->next; delete head; head = next; } } }

到这里,一个能用的哈希表已经成型了。插入、查找、删除都是O(1)平均复杂度。但这只是"能用",距离"能打"还差很远。下一节把扩容、负载因子和哈希函数这些真正决定哈希表性能的细节补上。

3. 让哈希表真正"能打":扩容、负载因子与哈希函数选型

3.1 负载因子0.75是怎么来的

负载因子(load factor)定义很简单:元素个数 / 桶数量。它反映的是桶的拥挤程度。

负载因子太高,意味着每个桶里挂的链表越来越长,查找时链表遍历的成本上升,哈希表从O(1)慢慢退化成O(n)。负载因子太低,桶大量闲置,内存浪费严重,性能没有本质提升,纯粹是空间换了个寂寞。

那取多少合适?很多教材张嘴就是"0.75",这个数字主要来自Java的HashMap,C++标准库std::unordered_map的默认max_load_factor其实是1.0。0.75和1.0的差异没有想象中那么大,核心思想都是"别把桶塞得太满"。C++选择1.0作为默认,一个重要原因是链地址法下每个桶多挂一两个节点并不会造成灾难性退化,不如省点内存。如果你想更保守,自定义时设成0.75也完全合理。

插入前我判断的是elementCount + 1除以bucketCount,为什么加一?因为这是插入后的预估状态。如果等插入完发现超标再去扩容,新元素已经待在了一个让它违法的桶里,还得再移动一次,纯属浪费。

3.2 rehash扩容:这步做不好直接翻车

当负载因子超过阈值,就得做扩容,哈希表里这个动作叫rehash。为什么叫rehash?因为桶数量变了,所有元素之前算出来的桶下标全部失效。你拿原来的下标访问新数组,根本不对位。唯一的办法是把每个元素拿出来,用新的桶数量重新算下标,再放进新桶里。

void rehash() { size_t newBucketCount = nextPrime(bucketCount * 2); std::vector<Node*> newBuckets(newBucketCount, nullptr); for (Node* head : buckets) { while (head) { Node* next = head->next; size_t newIndex = std::hash<K>{}(head->key) % newBucketCount; head->next = newBuckets[newIndex]; newBuckets[newIndex] = head; head = next; } } buckets.swap(newBuckets); bucketCount = newBucketCount; }

这里有个细节:扩容时我复用了原来的Node节点,只是把它们的next指针重新串了一遍,没有重新new、delete。这一步省了很多时间,也避免了不必要的内存分配。所有节点在旧桶数组里被拆下来,再按新下标挂到newBuckets上。

为什么新桶数量要用质数?打个比方,如果你的桶数量是16(2的4次方),而你的哈希值恰好是"低位相同、高位不同"的模式,取模的结果就会被“吃”掉高位,容易扎堆。质数能缓解这种规律性冲突。当然,现代std::hash通常已经对输入做了充分的位混合,对2的幂取模问题也没那么大,但传统哈希表为了稳妥,还是倾向于用质数。

那nextPrime怎么实现?最省事的写法是预置一个质数表,还不够就翻倍再找:

size_t nextPrime(size_t start) { // 这里为了演示只给一个简版:从 start 开始找下一个奇数,逐个试除 if (start <= 2) return 2; if (start % 2 == 0) ++start; while (true) { bool isPrime = true; for (size_t d = 3; d * d <= start; d += 2) { if (start % d == 0) { isPrime = false; break; } } if (isPrime) return start; start += 2; } }

rehash的复杂度是O(n),单次看很吓人,但均摊到每次插入上其实是O(1)。这就像你出门旅游,平时零钱够用,偶尔去银行取一次大额现金,平均下来每天的消费节奏没变。

3.3 std::hash之外的哈希函数:什么时候需要自己写

std::hash<std::string>能用,但不同标准库实现对字符串哈希的实现千差万别。有的实现比较朴素,对大量相似字符串可能分布不够均匀;有的实现干脆就是FNV或MurmurHash变体,性能很好。工程上,如果你对哈希分布有更高要求,可以自己写一个稳定的哈希函数。

我比较常用的是FNV-1a,实现简单、速度极快、分布也不错:

size_t fnv1a(const char* data, size_t len) { size_t hash = 1469598103934665603ULL; for (size_t i = 0; i < len; ++i) { hash ^= static_cast<unsigned char>(data[i]); hash *= 1099511628211ULL; } return hash; }

用字符串做key的场景,可以直接用它替代std::hash。

但更常见的情况是:你自己定义了一个结构体想做key。比如:

struct UserKey { std::string name; int id; bool operator==(const UserKey& other) const { return name == other.name && id == other.id; } };

光有operator==还不够,你还得告诉哈希表怎么给UserKey算哈希。这时候可以给std::hash做偏特化:

namespace std { template <> struct hash<UserKey> { size_t operator()(const UserKey& k) const noexcept { size_t h1 = hash<string>{}(k.name); size_t h2 = hash<int>{}(k.id); return h1 ^ (h2 << 1); } }; }

这个组合哈希的技巧很多老手都在用:两个子哈希异或,再把其中一个左移一位,避免不同字段凑出来相同的组合。当然这不是终极方案,但足够应付大多数场景。

还有一点需要注意:operator==和hash必须保持一致。如果哈希函数只算了id,但operator==还比较name,就会发生诡异问题:明明id相等但name不同的两个key落进同一个桶,然后operator==告诉你不相等,于是哈希表里同时存在两个“逻辑上应该冲突的key”,这是自找麻烦。

4. 和std::unordered_map对比:我的代码到底差在哪

4.1 查漏补缺:我缺的那些成员函数

手写版本主打一个"能用",但和标准库一比,差距是全方位的。std::unordered_map至少有这些我第一版没有的东西:

  • operator[]:map[key]可以直接读取,如果key不存在还会插入一个默认构造的value。这个语法糖在写缓存、计数器时太方便了。
  • at():和operator[]一样是访问,但key不存在时抛出out_of_range,更安全。
  • 完整的迭代器接口:begin()、end()、++it、for (auto& [k, v] : map)这种范围遍历,对使用者来说太重要了。
  • emplace():原地构造,避免临时对象拷贝。
  • reserve():提前分配好桶数量,减少rehash次数。
  • max_load_factor()/load_factor():查看和调整负载因子。

其中我特别建议自己实现一下迭代器。迭代器的本质是"在桶数组和链表之间来回穿梭":遍历时先走到第一个非空桶,取链表头节点;链表走完了,再往后找下一个非空桶。这个逻辑听起来简单,但边界条件极多,写一遍能加深你对容器内存布局的理解。

4.2 性能实测:自定义哈希表 vs STL

我在自己的机器上跑了一个简单基准:分别用自写哈希表和std::unordered_map插入并查找100万个int到int的键值对,结果大概是这样(仅作参考,不同编译器、平台差异很大):

操作自写哈希表std::unordered_map
插入100万int键约320ms约250ms
查找100万int键约180ms约150ms
删除100万int键约190ms约160ms

STL比我写得快,核心原因有三个:

第一,STL的rehash策略和桶增长策略更精细,它一般不会每次只翻一倍,而是通过reserve和max_load_factor的组合,让rehash次数更少。

第二,STL的节点分配有时候会用特殊的内存池,配合allocator避免每次new/delete都走慢速的内存管理。

第三,STL在unordered_map的实现里,每个桶挂的可能不只是链表,C++11以后的标准允许采用"桶内红黑树"等更复杂的结构,在大规模冲突时能保住底线。

但这不意味着手写没有意义。恰恰相反,手写一遍能让你理解:为什么reserve能提升性能?因为它预分配了桶,避免了多次rehash。为什么移动语义能提升性能?因为插入时不会拷贝一个大的字符串key。这些微观机制,不亲手敲一遍代码是真体会不深的。

4.3 哈希表和字典的关系:别再问是不是一回事

网上搜"哈希表和字典的区别"的人非常多,这里我明确说结论:字典是抽象数据类型,哈希表是底层实现方式之一。它们不是同一个维度的概念。

"字典"指的是这样一种容器:给定一个key,能取回对应的value,不支持按下标顺序访问。至于底层是哈希表、红黑树、跳表还是二叉搜索树,那是另一回事。

C++里std::map是红黑树实现的"有序字典"——内部按key排序,遍历时有序,但操作是O(log n)。std::unordered_map是哈希表实现的"无序字典"——遍历时顺序无意义,但平均O(1)。Python的dict和Java的HashMap也都是哈希表实现的字典。

所以如果有人问你"哈希表和字典什么区别",你可以告诉他:字典是一类接口,哈希表是一种实现。就像"车"和"电动车"的区别,电动车是车的一种,哈希表是字典的一种。

我这里有个切身经历:刚学C++那会儿看到unordered_map这个长名字,第一反应是"好丑,为什么不叫Dictionary"。后来换了Python写了几行d = {"a": 1},又觉得"这map到底和dict差在哪"。其实它们都是哈希表,只是语言给的名字不一样。站在数据结构的角度看,学会了C++的实现,Python的dict和Java的HashMap对你来说都是"换了个马甲的熟人"。

5. 实战中踩过的坑和保命技巧

5.1 迭代器失效:rehash之后指针全废

有一次我写一个消息去重模块,要在遍历一个unordered_map的过程中,对每个元素判断是不是需要把它附近的一组key也插进去。代码如下:

for (auto it = msgMap.begin(); it != msgMap.end(); ++it) { // 处理 it if (needMore(it->first)) { msgMap[someKey] = someValue; // 触发了 rehash } }

结果程序时不时崩溃,而且崩溃点非常随机。查了半天,最后定位到:我在遍历过程中插入了新元素,导致unordered_map内部rehash,所有迭代器全部失效。it指向的节点已经被搬到新桶里,但迭代器里还存着旧的指针,用它++it相当于操作一个悬空指针。

这个坑几乎所有写哈希表的人都踩过,而且自写版本更容易踩。标准库至少还会在debug模式下给你一个断言提示,自写版直接随机崩溃。

保命技巧有三条:

  • 遍历和插入分开做:先收集需要插入的key,遍历结束后再统一插入。
  • 如果一定要在遍历中插入,先调用reserve提前分配足够的桶,尽量避免rehash。
  • 删除时使用erase(it)的返回值,它返回下一个有效迭代器,这是标准库保证的:
for (auto it = msgMap.begin(); it != msgMap.end();) { if (needErase(it->first)) { it = msgMap.erase(it); } else { ++it; } }

5.2 string做key时最容易被忽视的性能黑洞

还有一个我见过很多次的性能事故:拿很长的字符串当key,频繁查询。哈希计算需要遍历整个字符串,比如指纹校验场景里,一条消息可能几KB,每个key查一次就得把整条消息从头到尾过一遍。如果每天请求量几百万,光是哈希计算时间就非常可观。

解决办法不是换哈希函数,而是换key类型。如果字符串的长度固定,或者能拆成几个结构化字段,用结构体做key往往比用长字符串便宜得多。如果字符串确实没法避免,就考虑用string_view配合允许悬空的存储方案——注意生命周期,字符串底层的存储必须比哈希表活得久,否则就是悬空引用的灾难。

另外,用std::string做key时还有一个隐性问题:operator[]插入默认值时,会发生一次临时字符串的构造和拷贝。如果value是shared_ptr、vector这种带堆分配的重量级对象,成本更明显。用emplace代替operator[]能省掉这一层拷贝:

// 不推荐:可能多一次构造和拷贝 map[key] = complexObject; // 推荐:原地构造 map.emplace(key, complexObject);

5.3 哈希冲突滥用:从一个极端案例说开去

最后说一个相对进阶的坑:如果哈希函数太简单,攻击者可以构造出一批"哈希值相同但内容不同"的key,让它们全部塞进同一个桶,哈希表从平均O(1)直接退化成O(n)。这就是早年互联网上常说的哈希碰撞拒绝服务攻击的思路来源。

std::unordered_map在主流标准库实现中已经加入了随机化防御:每次创建容器时用随机种子初始化哈希状态,使攻击者难以预测哈希值。但自写哈希表如果直接用固定哈希函数,就有这种脆弱性。做内部工具、面试作品,不暴露给不可信输入,问题不大。但如果你做的服务和网络请求相关,还是老老实实用标准库或者成熟的哈希算法。

另有一个实用建议:不要迷信"计算量越大的哈希函数越好"。好的哈希函数应该在"速度和分布"之间取平衡。像CRC32、FNV-1a这种轻量哈希在非对抗场景下完全够用;只有在安全敏感场景才需要更贵的算法。衡量哈希函数的唯一标准是实测:同样的数据,换不同哈希函数跑一遍,看插入查找时间、看桶分布是否均匀,数据说话,别靠资料吹。

最后分享一个小技巧:如果你预知要插入的元素数量,开局就reserve到位,能有效避免多次rehash的卡顿。我在跑批量任务时经常顺手写一句map.reserve(expectedSize * 2),效果立竿见影,内存没多多少,时间省一截。哈希表这东西,用好了是一把快刀,用不好就是性能刺客。理解原理,动手实现,再回头用标准库,三个阶段走一遍,你心里就彻底有底了。

返回列表