做开发时间长了,几乎人人都会被问到一句话:哈希表的查找时间复杂度是多少?答案背得滚瓜烂熟——O(1)。但真到让你手写一个哈希表,或者线上遇到某个接口突然变慢、一查发现是哈希表“退化”了的时候,很多人就开始含糊了。哈希表和哈希桶,这两个词听着像数据结构课上的基础概念,实际上从Redis的字典、Java的HashMap、C++的unordered_map,到数据库分区索引的底层实现,到处都有它的影子。这篇文章从一个能直接编译运行的C++实现出发,把哈希表是什么、哈希桶解决什么问题、代码怎么组织、性能为什么会退化、排查思路是什么,整个链条一次讲清楚。适合刚学完数据结构想动手写源码的学生,也适合工作了几年想补一下底层原理的工程师。
1. 哈希表是什么:从数组聊到O(1)查找
1.1 一个绕不开的问题:按值查找为什么会慢
先看一个最朴素的问题。数组按下标访问是O(1),这是硬件级别的随机访问能力,磁盘也好、内存也好,都能按地址直接定位到数据。但按值查找完全不是一回事。假设一个数组存了一堆用户ID,现在要判断某个ID是否存在,最直接的做法是遍历一遍,一个个比较,数组越大越慢,复杂度O(n)。如果数组是有序的,可以用二分查找,把复杂度压到O(log n),但前提是维持有序,每插入一个元素都可能要搬动后续数据。
哈希表换了一个思路:我不在已有的数据里挨个找,我想办法根据关键字直接计算出它应该在哪个位置。不管是1万个数据还是1千万个数据,计算位置的开销基本是固定不变的,这才是O(1)的来源。理解这一点比记住“哈希表快”这句话重要得多。
1.2 哈希表的核心思想:建立关键字到位置的映射
哈希表本质上是“数组 + 哈希函数”的组合。哈希函数接收一个关键字(key),经过计算输出一个整数值,再对数组容量取模,就得到了该关键字对应的存储位置(下标)。查找的时候同样把key丢进哈希函数,算出下标,直接去数组那个位置拿数据。整个过程没有一个循环去逐一比较数据本身,时间复杂度自然就压下来了。
这个思路可以类比成图书馆找书:书库很大,如果从第一排书架开始盲找,效率极低。但每本书都有一个索书号,系统根据索书号帮你定位到某一排某一层,你到那个位置直接取就行。这里的“索书号”就是哈希值,“根据索书号定位到具体位置”就是哈希函数配合取模的过程。
需要注意一点,哈希函数输出的“哈希值”不是最终下标。同一个哈希函数可能产生很大的数,远超过数组长度,所以必须取模落到当前容量的范围内。这一步虽然简单,却直接影响后续负载因子和扩容机制的设计,后面代码里会详细讲。
1.3 哈希表的局限性:理想很丰满,现实有碰撞
哈希表的时间复杂度能到O(1),前提是“不同的key经过哈希计算后,能映射到不同的数组位置”。但数组容量是有限的,而key的数量理论上可以无限增长,根据抽屉原理,当存放的元素个数超过数组容量时,必然出现两个不同的key映射到同一个位置的情况。这就是哈希冲突(也叫哈希碰撞)。
冲突一旦发生,就不能再简单地去那个位置直接取值了,因为那个位置已经住了别人。这时候就需要一套冲突解决策略:是往后找空位,还是在那个位置挂一条链表?哈希桶,正是为了解决冲突才出现在题目里的。
2. 哈希函数:决定哈希表命运的“分配员”
2.1 哈希函数的基本要求
哈希函数是哈希表的灵魂。它好不好,直接决定整张表是“快速查找”还是“线性扫描”。
对一个合格的哈希函数,业界有几个公认的要求:
- 确定性:同一个key,无论哈希函数被调用多少次,输出必须一模一样,这是哈希表正确性的底线。
- 均匀性:不同key尽量均匀地分散到数组的各个桶位,避免大量key扎堆到同几个位置。
- 高效性:哈希函数本身的计算不能太重,如果算一个哈希值要循环一万次,那所谓O(1)就名存实亡了。
这三点里,均匀性最难量化,也最容易被忽略。一个看起来随机性很强的哈希函数,放到特定数据分布下可能表现极差。我自己就踩过坑:某次用字符串的简单ASCII码之和做哈希,结果所有字符串长度相同、字符组合恰好同和,全部挤进同一个桶,哈希表活生生退化成了链表。所以哈希函数选型,不能只看“算得快”,还得结合数据特征看“分得散”。
2.2 常见哈希函数选型对比
不同数据类型适合不同的哈希策略。整数、字符串、自定义结构体,需要的哈希函数是不一样的。
| 数据类型 | 常见哈希方式 | 特点 | 注意事项 |
|---|---|---|---|
| int/long 等整数 | 直接用数值,或乘以一个大的奇数再进行位运算 | 简单快速,几乎不耗时 | 如果直接用key本身,容量为2^n时低位分布不好的数据会扎堆 |
| 字符串 | BKDRHash、DJBHash、FNV-1a | 计算量适中,分布较均匀 | 避免只用每个字符的ASCII码求和,分子相同是会碰撞的 |
| 自定义结构体 | 将各字段哈希值按权重混合 | 灵活,可按业务特征调整 | 必须保证相等的对象哈希值一定相等 |
BKDRHash是我用过的所有字符串哈希里性价比最高的一个,核心思想是“把一个字符串看成一个多项式”:
hash = (hash * 131) + (unsigned char)s[i]
乘的131是一个经验质数,有论文分析过这个常数配合很多实际数据集都能得到较好的分散效果。实际代码里不一定要求用131,乘一个较大的奇数也可以,关键是别用偶数——偶数乘出来哈希值的低位容易被抹平,导致取模后下标分布不均匀。
2.3 容量为什么常取2的幂
实现哈希表时,“容量取多少”是个经典设计决策。C++标准库unordered_map并没有强制要求容量为2的幂,但很多底层的哈希表实现,尤其是Java的HashMap,会把初始容量设置为16,扩容时翻倍,始终维持2的幂。
原因之一是位运算优化。当容量size是2的幂时,“hash % size”可以用“hash & (size-1)”直接替代。位运算比取模快一截,哈希表是非常高频的数据结构,这一处优化在数据量级大的时候能明显感觉到。
原因之二和扩容相关。容量翻倍后,元素在新数组中的索引只有两种可能:保持原索引,或者“原索引 + 旧容量”。因为size从2^n变成2^(n+1),新加的最高bit如果是0,位置不变;如果是1,位置偏移旧容量。这个规律允许rehash时做很多优化,减少计算量。Java的HashMap实现就利用了这个特性做高低位拆分迁移。
但2的幂不是银弹。如果哈希函数输出的低位信息很差,比如所有key的哈希值都落在同一个低8位区间,那么无论容量多大,都会映射到同一个桶里。Java HashMap因此在扰动函数上做了不少文章,把高16位和低16位异或,提升低位的随机性。手写C++实现时,如果使用的是std::hash且数据分布均匀,可以直接依赖底层;自定义哈希函数时就要自己考虑低位是否“够散”。
3. 哈希冲突与哈希桶:把“打架”变成“排队”
3.1 冲突的不可完全避免性
有人可能会想:哈希函数写得足够好,是不是就能完全避免冲突?答案是否定的。数组容量固定,元素数量超过容量时就必然有至少一个位置被分配到两个以上元素,这是抽屉原理决定的,跟哈希函数写得多好没关系。
也就是说,冲突是哈希表必然会遇到的情况。我们能做的只是“减少冲突”和“应对冲突”。减少冲突靠哈希函数、负载因子和扩容机制;应对冲突靠具体的冲突解决策略。哈希桶就是“应对冲突”这一层最经典的做法。
3.2 主流冲突解决策略盘点
哈希冲突解决策略主要有两大流派:开放寻址法和链地址法。
开放寻址法的核心是:如果目标位置被占了,就按一定规则继续向后探测空位,直到找到空位或确认不存在。常见的探测序列有线性探测(依次加1)、二次探测(按平方增量探测)、双重哈希(用第二个哈希函数计算步长)。它的优点是所有数据都存储在数组里,不需要额外的动态内存分配,缓存命中率高;缺点是删除元素比较麻烦(不能直接置空,否则会截断后续探测链),负载因子逼近1时效率断崖式下降。
链地址法的核心是:数组的每个位置不再是单个元素,而是一个桶(bucket)。桶里可以挂多个元素,通常用链表组织。插入时算出桶下标,把元素挂到对应链表中;查找时算出桶下标,在链表里线性扫描。这就是哈希桶名字的由来。它的优点是实现简单,删除灵活,对负载因子的容忍度比开放寻址法高得多,也是C++ unordered_map和Java HashMap采用的方案。
| 维度 | 开放寻址法 | 链地址法(哈希桶) |
|---|---|---|
| 存储位置 | 数据都在主数组内 | 桶数组 + 结点链表 |
| 删除操作 | 复杂,需懒惰删除 | 直接摘结点即可 |
| 负载因子容忍度 | 一般低于0.7,接近1时退化严重 | 可容忍到1.0以上,配合扩容机制 |
| 缓存友好性 | 较好 | 较差(结点内存不连续) |
| 内存管理 | 无额外分配 | 每次插入需分配结点内存 |
| 典型实现 | Redis dict、某些自研引擎 | C++ unordered_map、Java HashMap |
两种方案各有适用场景。如果追求极致的缓存效率和可预测性,开放寻址法有优势(Redis的dict就是在哈希表变满后用重新哈希加渐进式迁移的方式处理)。如果追求实现简洁、工程上不容易出错,链地址法是我的首选。文章后面基于哈希桶的实现,也更能讲清楚每个桶的行为。
3.3 哈希桶的工作原理:一个桶就是一条链表
哈希桶的方案可以用一句话概括:把冲突的多个元素,放到同一个“桶”里排队。
具体来说,底层是一个数组,数组的每个下标位置保存一个链表头指针(也可以理解成桶)。插入key时,先计算hash(key) % cap,得到桶下标,然后在对应链表中查找是否已有这个key:存在则更新值,不存在则把新结点挂到链表头部或尾部。查找时同样计算桶下标,再在链表中逐一比较key。
这里有一个工程上的改进点:当某个桶的链表非常长(通常指超过8个结点),链表的线性查找优势就不存在了。Java 8的HashMap在链表长度超过8且桶数组容量达到64时,会把链表转成红黑树,把最坏情况查找时间从O(n)降到O(log n)。C++的unordered_map没有做这个优化(标准库实现通常仍然是纯链表),但在某些高度竞争的高性能自研哈希表里也能看到类似的“升级机制”。
链表的插入方向也值得聊两句。Java 8之前,HashMap采用头插法——新结点插入链表头部,好处是代码简洁,不需要额外变量找尾部;坏处是并发扩容时可能出现循环引用(这是个经典面试题,Java团队因此把8之后的实现改成了尾插法+红黑树)。单线程的C++实现里,头插法完全没问题,代码也更简洁,下面的代码演示我用的是头插。但如果你写的代码要跑在并发场景,建议老老实实做同步,或者直接用并发安全的库。
4. 手写一个C++哈希表:从空文件到可运行
4.1 类的整体设计思路
光讲原理不动手写代码,等于看了十篇菜谱没下过一次厨。这一节我用C++完整实现一个基于哈希桶的哈希表,包含插入、查找、删除、扩容四个核心操作,代码量控制在150行左右,注释尽量说清楚每个关键点。
类的结构分成三层:
- 底层存储:使用std::vector<Node*>作为桶数组,每个元素是指向链表头结点的指针。
- 数据结点:Node结构体保存key、value和指向下一个结点的指针。
- 对外接口:insert、find、erase、size、bucketCount,跟标准库unordered_map的核心方法对齐。
这里我特意选择了泛型模板,让哈希表可以存储任意类型。模板参数是<K, V>,key的类型和value的类型分离,比直接写int到int的固定映射更具普适性。哈希函数默认使用std::hash ,这样对绝大多数内置类型都可以直接用,自定义类型则需要自行特化std::hash。
4.2 完整C++代码实现与逐段解析
下面是完整实现,直接附注释,建议读者开一个cpp文件跟着敲一遍:
#include <iostream> #include <vector> #include <string> #include <functional> template <typename K, typename V> class HashMap { 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; // 当前元素个数 float maxLoadFactor = 0.75f; // 负载因子阈值 size_t hashIndex(const K& key) const { return std::hash<K>{}(key) % buckets.size(); } void rehash(size_t newSize) { std::vector<Node*> newBuckets(newSize, nullptr); // 遍历所有旧桶,把每个结点从旧链表摘下来,重新哈希到新桶 for (Node* head : buckets) { while (head) { Node* cur = head; head = head->next; size_t idx = std::hash<K>{}(cur->key) % newSize; cur->next = newBuckets[idx]; newBuckets[idx] = cur; } } buckets.swap(newBuckets); } public: explicit HashMap(size_t capacity = 16) : buckets(capacity, nullptr) {} ~HashMap() { for (Node* head : buckets) { while (head) { Node* toDelete = head; head = head->next; delete toDelete; } } } void insert(const K& key, const V& value) { size_t idx = hashIndex(key); Node* cur = buckets[idx]; // 遍历当前桶的链表,如果key已存在则更新值 while (cur) { if (cur->key == key) { cur->value = value; return; } cur = cur->next; } // 不存在则头插新结点 Node* node = new Node(key, value); node->next = buckets[idx]; buckets[idx] = node; elementCount++; // 判断是否需要扩容 if (elementCount > buckets.size() * maxLoadFactor) { rehash(buckets.size() * 2); } } bool find(const K& key, V& value) const { size_t idx = hashIndex(key); Node* cur = buckets[idx]; while (cur) { if (cur->key == key) { value = cur->value; return true; } cur = cur->next; } return false; } bool erase(const K& key) { size_t idx = hashIndex(key); Node** cur = &buckets[idx]; while (*cur) { if ((*cur)->key == key) { Node* toDelete = *cur; *cur = (*cur)->next; delete toDelete; elementCount--; return true; } cur = &((*cur)->next); } return false; } size_t size() const { return elementCount; } size_t bucketCount() const { return buckets.size(); } };逐段说几个关键点。
插入操作里,第一步是遍历当前桶链表,检查key是否已存在。注意这里必须先搜索再决定是更新还是新建。如果把更新和新建混在一起,可能出现同一key被重复插入链表多次的情况,查出来就有两个相同key,语义就坏了。
头插法的新结点插入非常简洁:node->next = buckets[idx]; buckets[idx] = node;两行完成。头插的好处是不需要维护尾指针,也不需要对空链表做特殊判断。jvm面试里经常聊的头插法扩容成环问题,在单线程场景下不存在,但并发场景下面会讲。
删除操作用了一个很有意思的技巧:二级指针Node** cur指向当前结点的next指针的地址。这样在删除头结点时,不需要特殊if判断,cur就相当于“上一个结点的next”。删除后把cur更新为toDelete->next,链表结构自然接上。
4.3 运行测试:插入、查找、删除、扩容演示
写一段测试代码看看效果:
int main() { HashMap<std::string, int> map; map.insert("apple", 10); map.insert("banana", 20); map.insert("orange", 30); int value = 0; if (map.find("banana", value)) { std::cout << "banana -> " << value << std::endl; // 输出 banana -> 20 } map.insert("banana", 999); // 更新现有key map.find("banana", value); std::cout << "after update, banana -> " << value << std::endl; // 999 map.erase("apple"); if (map.find("apple", value)) { std::cout << "apple still exists" << std::endl; } else { std::cout << "apple removed" << std::endl; } for (int i = 0; i < 100; i++) { map.insert("key_" + std::to_string(i), i); } std::cout << "size = " << map.size() << ", buckets = " << map.bucketCount() << std::endl; return 0; }运行结果符合预期:先插入三个字符串key,能找到banana;更新后值变为999;删除apple后再查就找不到了;插入100个key之后,size是100。容量方面,初始化16个桶,插入的数据量超过16*0.75=12个元素时触发了扩容,所以最终桶数量变为64(16翻倍到32,再翻倍到64)。如果继续插入更多数据,容量还会继续翻倍。
4.4 实现里容易踩的坑
手写哈希表,Bug往往不在哈希逻辑本身,而在内存管理和模板约束上。
第一个坑是析构函数的内存释放。每个插入的结点都是new出来的,析构时如果不遍历每个桶释放所有结点,必然内存泄漏。很多练手代码简化了这块,导致程序退出时内存长时间被占用,我见过有人用unordered_map好好的,一换成自研哈希表进程常驻内存暴涨。
第二个坑是拷贝构造和赋值运算符。模板类默认的浅拷贝会带来严重问题:旧哈希表的桶数组直接拷贝一份指针给新对象,两个对象共享同一批结点,析构时同一个结点会被delete两次。要么实现深拷贝版本,要么显式禁用拷贝(C++11里可以delete拷贝构造)。大批量哈希表拷贝本身成本不低,很多工程场景干脆禁用了拷贝,只允许move。
第三个坑是自定义类型没有哈希函数。std::hash 如果不特化,编译直接报错。标准库对内置类型、string、智能指针都提供了哈希特化,但对自定义struct不提供。这时你有两条路:要么在自己的类里提供一个hash函数作为哈希表的模板参数,要么特化std::hash,文章后面会专门给出一个简单示例。
5. 负载因子与扩容机制:哈希表的“成长策略”
5.1 负载因子到底是什么
负载因子的定义很简单:元素个数 / 桶数组容量。它衡量的是“整张表被填满的程度”。负载因子越大,每个桶平均挂的元素越多,查找时链表遍历的平均长度越长;负载因子越小,桶越空闲,链表短,查找快,但内存浪费也严重。
所以负载因子调节的是时间换空间还是空间换时间的折中。工程实现中,负载因子不是无限增长的,代码里通常会设置一个阈值,超过阈值就触发扩容。常见语言里,C++ unordered_map的max_load_factor默认是1.0,Java HashMap的默认负载因子是0.75。我的示例代码把阈值设成0.75,更接近Java的取值。
5.2 为什么扩容时必须重新计算每个元素的索引
扩容的直观做法是“把旧数组里的元素搬到更大的数组里”。但这里有个非常关键的点:不能直接把旧桶的链表整体搬过去,而是必须对每个结点重新计算hashIndex。
原因是数组容量变了,取模运算的分母变了,同一个key在旧容量下算出的下标,和新容量下算出的下标很可能不一样。举个具体例子:hash值为101,旧容量16时101 % 16 = 5,新容量32时101 % 32 = 37,下标从5变成了37。如果不重新计算直接搬过去,查找时用新容量算出37,结果37的桶里没有这个元素,就永远找不到了。
这也是为什么扩容的rehash过程必须遍历旧表的所有结点,逐个摘下来重新计算索引并插入新表。如果错误地直接移动整条链表,或者仅仅在结尾追加而不重新散列,哈希表很快就会坏掉。我在教学时看过不少“看起来能跑,但数据随机丢失”的代码,八成都是rehash这里少了一步重算。
5.3 扩容的性能代价和优化空间
扩容本身是O(n)操作,因为要遍历所有元素重新哈希。但因为扩容不是每次插入都发生,而是触碰到阈值才进行一次,所以平摊下来每个插入操作的代价仍然是O(1)。这个摊还分析是理解哈希表时间复杂度的关键,也是面试中“为什么扩容后插入操作均摊还是O(1)”的标准答案。
扩容会带来一个明显的瞬间卡顿:当你的哈希表里已经有几百万个元素,某次insert触发了扩容,主线程会卡在那段时间重新哈希所有元素。Redis的dict为了解决这个问题,把rehash做成了渐进式,把“一次性重算几百万个元素”分摊到后续每次操作里,每次只搬迁一小部分桶。C++标准库的unordered_map没有默认做这个优化,所以高并发低延迟场景下自研哈希表时,渐进式rehash是一个值得考虑的方向。
负载因子阈值取多少也有讲究。0.75是Java官方给出的经验值,背后有泊松分布的数学推导:当负载因子为0.75时,桶内链表长度超过8的概率极低,说明哈希函数分布足够均匀的情况下,几乎不会出现长链表。如果把阈值调到1.0甚至更高,链表会变长,平均查找时间变长,但内存占用降低;调到0.5以下,查找极快但一半桶空置。工程上很少低于0.5,因为空间浪费太大,收益边际递减。
6. 常见问题排查与性能优化实录
6.1 症状:哈希表突然从O(1)退化成O(n)
最经典的问题:哈希函数分布不均匀,导致大量数据集中到一个或少数几个桶里,哈希表退化成链表遍历。表现是数据量不大但查询极慢,或者数据量增长后性能断崖式下跌,而不是线性缓慢变慢。
排查思路分三步。第一步,统计每个桶的链表长度分布,这个需要给哈希表加一个辅助方法,遍历所有桶统计链表长度。第二步,检查哈希函数是否对当前数据分布敏感。比如用字符串的第一个字符作为哈希值,那么所有首字母相同的字符串全部挤在一个桶里,其他桶全空着。第三步,考虑调低负载因子阈值或优化哈希函数的混合策略。
我这里分享一个实际案例。有个程序用字符串拼接业务ID作key,比如订单号加渠道号,字符串哈希用默认std::hash ,本来一切都好。某天渠道增多后,所有key的后缀变成一样的了,恰好std::hash 对字符串尾部的相同字符敏感度较低,大量key集中到少数桶。排查时一拉桶分布,发现最大桶链表长度超过1000,其他桶基本为空。后来改成把业务ID的那几段先拆出来分别哈希再混合,问题立刻消失。
6.2 内存泄漏:释放的节点去哪了
自研哈希表最容易出的内存问题有两个。一是忘记在析构函数释放所有节点,这个前面说过了。二是在erase之后忘记delete那个节点。递归遍历删除某个key时,很多人在链表里找到了结点,把指针关系改好了,但忘了delete,指向节点的指针就成了悬空指针,而new出来的内存永久泄漏。
排查内存泄漏没有捷径,用工具最靠谱。Linux下用valgrind或者AddressSanitizer都能快速定位。跑一遍测试用例,重点观察erase和析构之后的堆内存是否归零。如果你用的编译器支持AddressSanitizer,加上-fsanitize=address编译参数可以捕获很多内存错误,比肉眼盯代码高效得多。
6.3 并发场景的坑:为什么STL容器不能直接多线程用
标准库的unordered_map、vector这类容器,默认不是线程安全的。多个线程同时读是可以的,只要有线程在写,就必须加锁保护。这句话理论上谁都知道,但实际写代码时容易踩到一些隐蔽的场景:一个线程在读,另一个线程在扩容,扩容会修改桶数组,旧桶还可能正在被读取。这时没有同步机制的话,轻则读到旧数据,重则访问到已经被释放的内存直接崩溃。
解决思路有三个层次。最直接的是在类内部加一把互斥锁,把insert和erase包起来。但这个粒度太粗,读操作也会被锁住,并发高的时候性能不佳。好一点的是读写锁/共享锁:多个读线程可并发,写线程独占。更精细的分段锁/无锁设计,则是高性能数据库引擎才会去碰的领域,这里不展开讲。
有一个经验值:如果你的哈希表读多写少,一定优先考虑读写锁;写多读少,加普通互斥锁就够了,没必要花大力气设计无锁方案。具体场景具体分析,别为了炫技引入不必要的复杂度。
6.4 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 查找很慢,数据量不大 | 哈希函数分布差,多个key集中到同一个桶 | 更换哈希函数,检查桶长度分布 |
| 内存占用越来越大 | 析构或erase未释放节点 | 确保delete每个摘下的节点,用valgrind/ASan排查 |
| 插入元素后find找不到 | rehash时索引未重算,或rehash未触发 | 检查扩容逻辑,确认每个节点都重新取模 |
| 并发环境下偶发崩溃 | 多线程读写未加锁,扩容时读写冲突 | 加锁或使用线程安全容器 |
| 自定义struct无法编译 | std::hash未特化 | 提供自定义哈希函数或特化std::hash |
| 扩容时卡顿严重 | 一次性rehash数据量太大 | 考虑渐进式rehash,或增大初始容量减扩容次数 |
6.5 实战建议:什么时候用哈希表,什么时候换一种结构
哈希表不是万能的,它擅长的事非常明确:无序键值存储、近似O(1)的查找插入删除。但如果你需要有序遍历,哈希表做不到,这时候红黑树或跳表更合适;如果你的数据量很小,比如只有几十个元素,直接线性扫描有时反而更快——因为数组连续内存,缓存命中率高,而哈希表会出现哈希计算、取模、链表指针跳转的额外开销。
给自定义类型设计哈希函数时,有一个很实用的做法:把多个字段的哈希值各乘以一个不同的大质数再加起来。
struct Person { std::string name; int age; }; namespace std { template <> struct hash<Person> { size_t operator()(const Person& p) const { size_t h1 = std::hash<std::string>{}(p.name); size_t h2 = std::hash<int>{}(p.age); return h1 * 131 + h2; } }; }注意Person还必须有operator==,因为哈希表链表里搜索节点时需要判断key相等。写Person时顺手把==重载了,这样HashMap<Person, ...>才能正常编译运行。这一段代码虽然简单,但解决了“自定义类型怎么用哈希表”这个高频问题。
最后分享一个调优经验:如果业务里能预估数据量规模,建议初始化时就把容量设大。比如明确会有10万条数据要存,直接让桶数量在创建时就是131072(2的17次方),远大于负载因子0.75对应的37500个最小值,可以减少扩容次数,避免插入过程中的多次rehash卡顿。这个道理我在一个广告投放系统里实测过,设置合理初始容量后,批量导入阶段耗时减少了将近一半。哈希表这东西,原理不复杂,但要真正用好,细节决定成败。