ARTICLE DETAIL

资讯详情

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

从手写哈希表到C++实战:一次讲透冲突处理与性能优化

从手写哈希表到C++实战:一次讲透冲突处理与性能优化 做后端这些年我写过不少被查找折磨的代码。有一回统计线上几千万条访问日志里的用户去重链表、数组、二分全试了一遍最后发现瓶颈根本不在排序而在查找的复杂度上。那是我第一次认真地把哈希表从头到尾撸了一遍。哈希表这东西说难不难说简单也不简单你在教科书上五分钟就能看完定义但真要在 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_mapstd::string, int counter; counter.reserve(1000000);reserve的作用相当于提前 rehash让桶数量足够容纳预期元素后续插入不再触发扩容。用operator[]访问不存在的 key 时会自动插入一个默认值所以如果只是想检查键是否存在别用[]要用findauto it counter.find(user_9527); if (it ! counter.end()) { // 存在 }如果你用了if (counter[user_9527] 0)这种写法万一这个 key 不存在它会先插入一条 value 为 0 的数据污染了整个统计结果。这是我见过的最常见的 unordered_map 误用之一。C 还允许你自定义键类型但前提是你必须给这个类型提供哈希函数和相等比较函数。以结构体作为键为例标准的写法是特化std::hashstruct User { int uid; std::string name; bool operator(const User other) const { return uid other.uid name other.name; } }; namespace std { template struct hashUser { size_t operator()(const User u) const { size_t h1 hashint()(u.uid); size_t h2 hashstring()(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 的分布特征再选冲突策略和初始容量最后才是动手写代码。
返回列表