做后端这些年,我写过不少被查找折磨的代码。有一回统计线上几千万条访问日志里的用户去重,链表、数组、二分全试了一遍,最后发现瓶颈根本不在排序,而在查找的复杂度上。那是我第一次认真地把哈希表从头到尾撸了一遍。哈希表这东西,说难不难,说简单也不简单,你在教科书上五分钟就能看完定义,但真要在 C/C++ 里手写一个能扛业务的版本,坑一个比一个深。
这篇文章我想换个角度聊哈希表,不背概念,直接从“为什么要用它”“C语言哈希表怎么落地”“C++ 里的哈希表为什么这么设计”入手,把整个设计思路、冲突处理、常见坑和排查手法一次讲透。无论你是正在复习数据结构准备面试的在校生,还是被 O(n) 线性查找折磨过的业务开发,这篇文章应该都能给你一点启发。
1. 哈希表到底在解决什么问题
1.1 一次查找带来的性能瓶颈
先回忆一个场景。你有一批用户 ID,需要判断某个 ID 是否在名单里。最朴素的做法是把所有 ID 存进数组,每次查询从第一个元素挨个比,查 n 条数据就是 O(n)。数据量小的时候无所谓,几千条也不慢,但数据量一上来,比如百万、千万级还在用线性扫描,哪怕一次查询只要几毫秒,放大到高并发接口上就是雪崩。
比线性查找快的是二分查找,但二分查找要求数据先排序,每次插入新数据都要维护有序性,插入成本 O(n)。很多业务场景是读多写少,可以忍受排序开销,但也有一类场景是高频写入加高频查询,数据量还大,这时候就需要一种“插入和查询都接近 O(1)”的结构。
哈希表就是为这个场景设计的。
1.2 哈希表核心思想:把“比较”换成“计算”
数组为什么查找快?因为你知道下标,直接通过内存地址偏移一步到位。哈希表的本质,就是在“键”和“数组下标”之间建立一种确定性映射,把一次查找从“遍历比较”变成“算一个下标,然后直接取”。
这个映射函数就是哈希函数。你给函数一个键,它算出一个整数,这个整数经过取模或者位运算,落进一个数组桶里。查询的时候同样算一遍,直接去那个桶里找。只要哈希函数算得够快、分布够均匀,理论上一次查找就是常数时间。
我第一次理解这个思想的时候,觉得特别像图书馆书架编号:你不知道某本书在哪一排,但你知道分类号,按分类号走过去就能锁定一个区间,而不是从第一本书开始一本一本翻。
1.3 数组、二分查找和哈希表怎么选
在实际项目里,结构选型从来不是“哪个最优”,而是“哪个最合适”。我把三者的特性整理成一张表,方便对号入座:
| 结构 | 查询复杂度 | 插入复杂度 | 有序性 | 适用场景 |
|---|---|---|---|---|
| 数组线性查找 | O(n) | O(1) 尾插 | 支持排序后访问 | 数据量小,逻辑简单 |
| 二分查找 | O(log n) | O(n) 维护有序 | 天然有序 | 读多写少,需要范围查询 |
| 哈希表 | O(1) 平均 | O(1) 平均 | 无序 | 高频读写,按键精确查找 |
有一类典型的哈希表误用场景值得提醒:如果你在做排行榜、区间统计、按时间范围扫描这类需求,哈希表帮不上忙,因为哈希表的桶之间没有大小关系,你没办法做范围遍历,这时候树形结构才是正确选择。哈希表的“快”是精确匹配的快,不是万能的快。
2. 哈希函数与冲突处理:决定哈希表性能的两个命门
2.1 哈希函数:不是随便取个余数就行
很多人一写哈希函数就是 return key % n,写业务代码图省事没问题,但一旦 key 分布有规律,这个简单取模就会翻车。
举个例子,如果你的 key 全是偶数,哈希表大小 n 也是偶数,那么取模结果永远只能是偶数桶,奇数桶全部空着,数据全部堆在一半的桶里。这就是典型的哈希分布不均匀。
一个合格的哈希函数要满足三个要求:计算快、分布均匀、确定性一致。业界常用的字符串哈希算法比如 djb2、FNV-1a、MurmurHash,都是通过位移、加法、异或的混合操作,把字符序列的特征充分打散。
djb2 的 C 语言实现只有几十行:
unsigned long djb2(const char *str) { unsigned long hash = 5381; int c; while ((c = *str++)) { hash = ((hash << 5) + hash) + c; } return hash; }核心在那一行((hash << 5) + hash) + c,等价于 hash * 33 + c。乘 33 比普通乘法更快,同时能把高位信息往下传播,避免短字符串之间的分布重叠。当然,这只是哈希函数的第一层,真正落到桶里还要再取模,取模之前还要考虑桶数量。
2.2 冲突处理:链地址法与开放寻址法
哈希函数再均匀,也无法保证不同键一定得到不同下标。两个不同的键算出来同一个桶,这就是哈希冲突。
处理冲突的主流方案有两类。第一类是链地址法,每个桶不是直接存数据,而是存一个链表头,冲突的元素全部挂到同一个桶的链表上。C++ 的 unordered_map、Java 的 HashMap 底层都是这个思路。它的优点是实现简单,删除方便,负载因子可以放宽到 1.0 以上。
第二类是开放寻址法,冲突发生时不引入额外空间,而是在桶数组里继续往后探测,找到下一个空位置。探测方式有线性探测、二次探测、双重哈希。它的优点是内存是连续的一块,缓存友好,但缺点是删除很难办,不能直接清空,否则会断掉探测链,所以一般用“墓碑标记”代替删除。
两种方案我用一个场景对比:假设哈希表大小是 8,键 12 和键 20 取模后都落在桶 4。链地址法会在桶 4 挂一条链表,两个元素都在链上;线性探测则会把 20 放到桶 5(如果桶 5 空)。链地址法在元素多时表现稳定,线性探测在元素少且哈希函数好时内存利用率更高。工程上为了省事,我绝大多数情况优先选链地址法。
2.3 负载因子和扩容
负载因子的定义是元素个数除以桶数量。负载因子越大,桶越挤,冲突概率越高,查询就越慢;负载因子越小,内存浪费越多。
链地址法的经验阈值一般在 0.75 到 1.0 之间。达到阈值就触发扩容,也就是把桶数组变大,一般是翻倍,然后把所有旧元素重新哈希到新桶里。这里有个细节很多人忽略:扩容后取模的模数变了,元素在旧表里的下标不能直接复用,必须重新计算,这个操作叫 rehash。
rehash 的时间复杂度是 O(n),虽然单次扩容很慢,但如果采用倍增策略,平均到每次插入上的代价就是一个常数,这就是均摊 O(1) 的来源。面试的时候经常问“哈希表明明是 O(1),为什么有时候会突然卡一下”,答案就是扩容触发了 rehash。
开放寻址法的负载因子要控制得更严格,一般不能超过 0.7,否则探测序列会迅速变长,效率断崖式下跌。如果你能预估数据量,强烈建议初始化时直接给足桶数量,尽量避免扩容。
3. 手写哈希表:C语言与C++的完整实现
3.1 C语言版:链地址法哈希表代码详解
C 语言没有现成的哈希表,所以想用就得自己写。我手写一个轻量版本,完整功能包括初始化、插入、查询、删除、释放。第一步是定义结构体:
#include <stdio.h> #include <stdlib.h> #include <string.h> #define DEFAULT_CAPACITY 128 typedef struct Node { char *key; int value; struct Node *next; } Node; typedef struct HashTable { Node **buckets; int capacity; int size; } HashTable;buckets 是一个指针数组,每个元素指向一条链表的头节点。这里用二级指针是为了让每个桶都能作为链表头被修改。紧接着是哈希函数和初始化函数:
unsigned long hash_key(const char *key) { unsigned long hash = 5381; int c; while ((c = *key++)) { hash = ((hash << 5) + hash) + c; } return hash; } HashTable *create_table(int capacity) { HashTable *table = (HashTable *)malloc(sizeof(HashTable)); table->capacity = capacity; table->size = 0; table->buckets = (Node **)calloc(capacity, sizeof(Node *)); return table; }注意 hash_key 返回的是 unsigned long,没有对 capacity 取模。取模放在索引计算这一层做,这样哈希函数与表的大小解耦,扩容时不需要改哈希函数。calloc 会把所有桶初始化为空指针,避免出现野指针。
接下来是插入和查询。我习惯把“根据键找前一个节点”的逻辑抽出来,方便插入、删除共用:
unsigned int get_index(HashTable *table, const char *key) { return (unsigned int)(hash_key(key) % table->capacity); } Node *find_prev(HashTable *table, unsigned int index, const char *key, Node **out) { Node *cur = table->buckets[index]; while (cur) { if (strcmp(cur->key, key) == 0) { *out = cur; return NULL; } cur = cur->next; } return NULL; } void put(HashTable *table, const char *key, int value) { unsigned int index = get_index(table, key); Node *cur = table->buckets[index]; while (cur) { if (strcmp(cur->key, key) == 0) { cur->value = value; return; } cur = cur->next; } Node *node = (Node *)malloc(sizeof(Node)); node->key = strdup(key); node->value = value; node->next = table->buckets[index]; table->buckets[index] = node; table->size++; }这里有个值得掰扯的点:新节点被插到了链表头部。因为新来的元素刚刚被访问过(写入本身就是一次访问),在链表的头部插入可以让最近插入的元素最先被找到,对缓存友好,而尾插需要每次都遍历到链表末尾,白白浪费时间。代码里 put 已经做了“找到相同 key 就更新,找不到就头插”的处理,这是一个完整的 upsert 语义,业务里很常用。
查询逻辑:
int get(HashTable *table, const char *key, int *value) { unsigned int index = get_index(table, key); Node *cur = table->buckets[index]; while (cur) { if (strcmp(cur->key, key) == 0) { *value = cur->value; return 1; } cur = cur->next; } return 0; } void remove_key(HashTable *table, const char *key) { unsigned int index = get_index(table, key); Node *cur = table->buckets[index]; Node *prev = NULL; while (cur) { if (strcmp(cur->key, key) == 0) { if (prev) { prev->next = cur->next; } else { table->buckets[index] = cur->next; } free(cur->key); free(cur); table->size--; return; } prev = cur; cur = cur->next; } }删除的时候要么改前一个节点的 next,要么改桶头指针。这里容易踩的坑是忘了释放 strdup 分配的 key,会造成内存泄漏。C 语言没有垃圾回收,每一块 malloc 出来的内存都要自己去还。
内存释放函数:
void free_table(HashTable *table) { for (int i = 0; i < table->capacity; i++) { Node *cur = table->buckets[i]; while (cur) { Node *tmp = cur; cur = cur->next; free(tmp->key); free(tmp); } } free(table->buckets); free(table); }这个版本没有自动扩容,我用的时候会在 put 里加一个“size 超过 capacity * 0.75 就扩容”的判断。扩容逻辑很简单,创建一个更大的桶数组,遍历旧桶,把每个节点重新挂到新表,最后替换指针。
3.2 C++ 工程实践:unordered_map 的底层与自定义哈希
C++ 开发里,哈希表的首选是标准库的std::unordered_map,它底层就是链地址法实现的哈希表。虽然不用你自己写哈希表,但理解它的行为边界很有必要。
std::unordered_map有几个关键行为参数:load_factor()返回当前负载因子,max_load_factor()返回扩容阈值,默认是 1.0。当元素个数超过桶数量时,它会自动 rehash。这里有个实际开发经常遇到的坑:如果你频繁插入大量元素,而容器不知道提前扩容,rehash 会反复发生,性能损耗非常大。解决办法是构造时用reserve(n)预留足够的桶数量:
#include <unordered_map> std::unordered_map<std::string, int> counter; counter.reserve(1000000);reserve的作用相当于提前 rehash,让桶数量足够容纳预期元素,后续插入不再触发扩容。用operator[]访问不存在的 key 时会自动插入一个默认值,所以如果只是想检查键是否存在,别用[],要用find:
auto it = counter.find("user_9527"); if (it != counter.end()) { // 存在 }如果你用了if (counter["user_9527"] > 0)这种写法,万一这个 key 不存在,它会先插入一条 value 为 0 的数据,污染了整个统计结果。这是我见过的最常见的 unordered_map 误用之一。
C++ 还允许你自定义键类型,但前提是你必须给这个类型提供哈希函数和相等比较函数。以结构体作为键为例,标准的写法是特化std::hash:
struct User { int uid; std::string name; bool operator==(const User &other) const { return uid == other.uid && name == other.name; } }; namespace std { template <> struct hash<User> { size_t operator()(const User &u) const { size_t h1 = hash<int>()(u.uid); size_t h2 = hash<string>()(u.name); return h1 ^ (h2 << 1); } }; }哈希值组合的时候用异或和移位,是为了避免两个字段的哈希值在组合后互相抵消。如果只是简单地把两个 hash 相加,遇到特定字段组合就会大量碰撞。
3.3 手写时的几个代码细节
strdup不是 C 标准函数,但在 POSIX 环境里基本都有,Windows 下可以自己封装一个。- 取模运算
%在 key 是全等分布时没问题,但别让容量正好等于 2 的幂,除非你的哈希函数做了高位混合。否则低位相同的 key 会扎堆。 - 删除时要同时处理结构内存和 key 字符串内存,漏一个就泄漏。
- 不要无限扩容,桶数组大到一定程度,rehash 本身会变成性能瓶颈,最终要考虑分库分表或者换一致性哈希做分布式分布。
4. 常见问题与排查技巧实录
4.1 哈希碰撞导致性能雪崩
有一回我给一个接口做压测,发现 qps 一上来,某条查询链路耗时从 1ms 飙到 800ms。用 perf 一看热点,函数栈全部集中在哈希表的查找逻辑上。检查数据后发现问题出在 key 的分布规律上:业务里所有 key 都是形如order_20240101_xxxx的字符串,前面固定前缀完全一样,最后四位才是变化位,而底层哈希表容量是 1024,是 2 的幂,默认哈希函数对 2 的幂取模时只看低位。结果大量 key 落在少量桶上,链表越来越长,查询退化成链表遍历。
这个案例给我两个教训:第一,哈希表容量是 2 的幂时,一定要确认哈希函数有足够的高低位混合;第二,线上排查性能问题,如果热点集中在哈希查找,第一反应不是换结构,而是看哈希分布是不是出了问题。排查方法也简单,写个小脚本统计每个桶的链表长度,如果有一个桶里挂着几百个元素,基本坐实了冲突。
4.2 删除操作与内存管理的坑
C 语言链地址法删除时,很多人会把桶内链表节点的内存 free 掉,却忘了free(node->key)。因为 strdup 出来的字符串是独立分配的,你不 free 它就泄漏。短时间看不出问题,跑个长稳测试,内存一路往上走,最后 OOM。
另一个坑是开放寻址法下的删除。我在自己写的线性探测版本里遇到过删除后找不到其他元素的问题:删除了一个中间位置的元素,没有留墓碑标记,导致后续 key 的探测链中断,明明存在的数据却查不到。解决思路是删除时不能简单置空,要么做一个 deleted 标记,要么干脆用链地址法,别在开放寻址上折腾。
4.3 自定义类型的键为什么编译不过
C++ 新手最容易遇到的编译错误是“使用了自定义类型作为 unordered_map 的键,却找不到 hash 函数”。报错信息一长串,核心意思其实是std::hash没法处理你的类型。
我见过有人为了省事,把自定义类型先序列化成字符串再用字符串做键,这类做法能用,但每次查询都要构造字符串,分配内存开销很大,在高频场景会白白浪费性能。正确做法是给类型特化一个std::hash,并且同时保证operator==存在。写特化的时候,组合两个成员的哈希值用上面的异或移位方式就行。
4.4 一个线上排查实例
最后还是分享一个印象最深的排查过程。一个统计服务,数据量大约两千万,启动后前几分钟一切正常,越往后越慢,最终单次查询需要几十毫秒。
第一次直觉是数据库慢,但查了慢日志发现数据库毫秒级就返回了,瓶颈在应用内存。进一步打点发现,主要时间花在一个 unordered_map 的 find 调用上。我立刻怀疑碰撞,把 key 拿出来做了一次分布分析,果然,key 是 18 位数字字符串,其中后 6 位区分度很低,而底层哈希函数对字符串做取模时大量元素撞到了同一个桶。最后方案是改了 key 的生成方式,引入更高区分度的字段,同时在初始化时调用 reserve 预留容量,问题直接消失。
那之后我得出一条经验:用哈希表不是写完 put/get 就完事了,上线之前最好把真实 key 采样出来,模拟算一遍哈希分布。这个步骤花不了几分钟,但能提前避免线上最尴尬的性能事故。
哈希表这个结构看起来简单,真正用好的人却不多。它的性能上限取决于你的哈希函数是否匹配真实数据分布,而不取决于代码本身写得多花哨。如果你正准备在自己的项目里使用哈希表,我的建议是:先想清楚 key 的分布特征,再选冲突策略和初始容量,最后才是动手写代码。