☰
C++手写哈希表:从冲突处理到扩容机制全解析
2026/10/8 9:47:30 网站建设 项目流程

先聊个场景。你用unordered_map存了几百万个键值对,查一次几乎感觉不到延迟,换成map可能就慢了一个量级。哈希表这个东西,平时被 STL 包得严严实实,很多人用了一两年都不知道它到底怎么把查找做到 O(1) 的。这篇帖子就做一件事:抛开封装,用 C++ 手写一个简单版本的哈希表——能插入、查找、删除、扩容,代码量很小,但每个细节都是真家伙。它不是工业级实现,却足够让你搞懂哈希表的核心机制。搞懂之后你再去看unordered_map的源码,会有种茅塞顿开的感觉。适合刚学完 STL 想深入一层的朋友,也适合面试前临时抱佛脚复习数据结构的人。

1. 哈希表的本质:一次跳转,而不是逐格寻找

1.1 查找问题的复杂度博弈

先想一个问题:给你一堆键值对,用什么数据结构存,查找最快?

数组最快,O(1),但要求你知道下标。链表慢,O(n),因为它只能从头一个个往后找。平衡二叉树是 O(log n),已经很快了,但它每查一次都要做大约 log n 次比较。而哈希表做的是一件更“暴力”的事:我不比较,我直接算。你给我一个 key,我用一个函数算出它应该放在哪个位置,然后跳过去取。

这个“函数”就是哈希函数。它的本质是把“任意类型的键”映射成一个“数组下标”。一旦映射完成,查找就退化成了一次数组访问。这就把问题从“搜索”变成了“计算”。

但天下没有免费的午餐。哈希函数不是完美的,两个不同的 key 可能算出同一个位置,这叫哈希冲突。冲突一旦出现,你就不能只访问一个格子了,得额外想办法处理。所以哈希表的真实复杂度不是严格的 O(1),而是“冲突很少情况下的 O(1)”。所有哈希表的设计,本质上都是在跟冲突做斗争。

1.2 好的哈希函数需要满足什么条件

写哈希之前,得先明确哈希函数的要求。

第一,确定性。同一个 key,任何时候调用,返回值必须一样。这是哈希能被当作数据结构基础的前提。

第二,分布均匀。一组 key 经过哈希之后,应该尽量散落在不同的桶里。如果 100 个 key 都被映射到同一个位置,那查找就退化成了在链表里找,O(n) 直接回来了。分布均匀这件事,一半靠函数设计,一半靠表的大小和取余方式。

第三,计算快。哈希函数本身不能太重。你算一个位置花的时间,如果比直接遍历还久,那哈希就失去意义了。C++ 里很多标准库实现会刻意选择简单运算,而不是复杂的加密哈希,就是因为这个原因。

第四,对不同类型友好。整数可以直接转,字符串就得设计一个进制展开式的算法,自定义类型就得让用户自己提供哈希逻辑。这一点在模板实现里尤为重要,后面代码部分你会看到我怎么处理。

2. 冲突处理方案选型:我为什么直接选链地址法

2.1 开放定址法与链地址法的对比

处理哈希冲突,业界就两招:开放定址法和链地址法。

开放定址法的思路是:既然这个位置被占了,我就在附近找下一个空位。这叫线性探测,更高级一点还有二次探测、双重哈希。它的优点是省内存,不用额外指针;缺点是冲突一旦聚集,后面的查找会连续踩到前面占过的位置,性能会雪崩。而且删除非常麻烦,不能直接删空,否则会断开探测链,需要打“墓碑”标记。这些细节写出来又长又容易错,不适合做教学演示。

链地址法的思路完全不同:每个桶不直接存数据,而是存一个链表的头指针。冲突了就往链表里挂。C++ 的unordered_map、Java 的HashMap、甚至 Linux 内核里很多表,用的都是这个思路。它实现直观,删除容易,负载因子控制合理的时候性能非常稳。

我做教学版的模拟实现,毫不犹豫选择链地址法。理由有三条:

  • 结构清晰,每一行代码都能对应到一个物理概念。
  • 删除操作是标准的单链表删除,这是每个 C++ 程序员都该熟练的基本功。
  • 后续你想升级成高效版本,链地址法的模型可以直接对接 STL 的实现思路。

2.2 负载因子决定表什么时候“撑不住”

负载因子(load factor)的计算公式是:已有元素个数 / 桶个数。

链地址法里,负载因子等于平均每条链的长度。负载因子 0.5,意思是平均每条链半个人,多数桶是空的;负载因子 2,平均每条链 2 个节点,查找要做两三次比较。这看起来也还好,但注意,哈希函数做不到绝对均匀,必然有桶的链特别长。负载因子越高,这种“特别长的链”出现得越频繁。

所以哈希表都会设置一个扩容阈值。我说个直觉值:链地址法一般把负载因子控制在 0.7 到 1.0 之间。JDK 的HashMap用 0.75,C++ 标准库通常也在这个量级。超过阈值,就翻倍扩容、重新哈希。

用整数运算判断负载因子过界也有一点讲究。不要写成浮点除法,直接用乘法:n * 10 / bucketCount > 7就等价于n / bucketCount > 0.7。整数运算快,还没有浮点误差。

3. C++手写哈希表:核心代码与逐行拆解

3.1 基础结构:节点、哈希仿函数、哈希表主体

先写节点。单链表节点就两个成员:一个键值对,一个 next 指针。

template <class K, class V> struct HashNode { pair<K, V> _kv; HashNode<K, V>* _next; HashNode(const pair<K, V>& kv) : _kv(kv), _next(nullptr) {} };

然后写哈希函数。这里我用仿函数而不是普通函数,核心原因是模板需要为不同类型提供特化入口。默认版本直接转size_t,整数类型都能用;string专门特化,用 BKDRHash 算法。

template <class K> struct HashFunc { size_t operator()(const K& key) { return (size_t)key; } }; // string 特化版本,BKDRHash template <> struct HashFunc<string> { size_t operator()(const string& key) { size_t hash = 0; for (size_t i = 0; i < key.size(); i++) { hash = hash * 131 + key[i]; } return hash; } };

这里有两个细节值得展开。

第一,为什么字符串哈希要乘 131?本质是把字符串当成一个 131 进制的大数来算,每一位字符的权重不一样,相同字符换位置之后哈希值也不一样,分布自然更均匀。131 是一个经验上效果很好的素数因子,还有 1313、31 等变种。乘完如果溢出也无所谓,因为size_t是无符号整数,溢出是循环取模,标准定义的行为,不是未定义行为。

第二,仿函数operator()的返回值是size_t,它和桶数组的%运算配合,才能算出下标。不要直接返回一个负数或者超出表范围的数,否则取余之后分布会很差。

最后是哈希表主体。成员就两个:一个桶数组,一个元素个数计数器。

template <class K, class V, class Hash = HashFunc<K>> class HashTable { public: using Node = HashNode<K, V>; HashTable() : _n(0) { _tables.resize(10); } ~HashTable() { Clear(); } void Clear() { for (size_t i = 0; i < _tables.size(); i++) { Node* cur = _tables[i]; while (cur) { Node* next = cur->_next; delete cur; cur = next; } _tables[i] = nullptr; } _n = 0; } private: vector<Node*> _tables; size_t _n; };

vector<Node*>就是桶数组,nullptr表示空桶。有人问为什么不直接用std::list做桶?因为手写单链表能让你看清内存是谁分配的、谁释放的。STL 封装好的容器不会告诉你这些,而哈希表的内存管理恰恰是最容易出事的地方。

3.2 插入:先查重,再头插

插入逻辑分三步:查重、检查负载因子、头插。

bool Insert(const pair<K, V>& kv) { if (Find(kv.first)) { return false; } // 负载因子 >= 0.7 就扩容 if (_n * 10 / _tables.size() >= 7) { Rehash(); } size_t idx = _hash(kv.first) % _tables.size(); Node* newNode = new Node(kv); newNode->_next = _tables[idx]; _tables[idx] = newNode; _n++; return true; }

头插是新节点直接成为桶里链表的第一个节点。为什么不尾插?因为尾插需要先遍历到链表末尾,白白浪费一次扫描。头插 O(1) 搞定,而且对于哈希表这种无顺序要求的结构,头插完全够用。

查重这一步很多人会忽略。如果 key 已经存在,还无条件插入,会造成数据重复,后面查找的时候会返回两条记录,数据就脏了。标准容器里,insert遇到重复 key 是“插入失败但不覆盖”,这里我刻意做成重复返回false,简洁直观。

3.3 查找和删除:链表操作的细节活

查找很简单。先定位桶,再在链表里遍历。

Node* Find(const K& key) { size_t idx = _hash(key) % _tables.size(); Node* cur = _tables[idx]; while (cur) { if (cur->_kv.first == key) { return cur; } cur = cur->_next; } return nullptr; }

删除麻烦一点。单链表删除必须要有一个prev指针记着前一个节点,否则删掉当前节点后,你找不到链表入口。头结点的删除尤其要小心,它是特殊分支。

bool Erase(const K& key) { size_t idx = _hash(key) % _tables.size(); Node* cur = _tables[idx]; Node* prev = nullptr; while (cur) { if (cur->_kv.first == key) { if (prev == nullptr) { _tables[idx] = cur->_next; } else { prev->_next = cur->_next; } delete cur; _n--; return true; } prev = cur; cur = cur->_next; } return false; }

这里有个非常隐蔽的坑:删除之后_n必须减一,但_n减到低于阈值之后并不会触发“缩容”。哈希表的缩容是一个复杂话题,标准库一般不会主动缩容,避免反复增删造成性能抖动。所以千万别写“删除后检查负载因子并 resize”,实测下来会在大批量交替插入删除时慢得离谱。

4. 扩容与重哈希:性能拐点在哪,怎么正确搬移

4.1 为什么不能简单地把旧数据 copy 一遍

最直观的扩容方案是:申请一个更大的桶数组,然后把旧表里的所有元素一个个Insert进去。这逻辑没错,但性能是灾难。

老数据重新Insert,意味着对每个老节点都做一次新的内存分配和一次删除。一次扩容 N 个元素,额外产生 N 次 new 和 N 次 delete。扩容本身是低频操作,但一旦触发,这个顿挫感会直接把交互延迟拉高好几个数量级。

正确的做法是:搬节点,不新建节点。把旧桶链表上的节点摘下来,算好新位置,直接接到新桶的链表上去。指针搬运全程没有新的分配和释放,只是改了next指针的指向。

4.2 扩容的完整实现:新表 + 节点搬移

void Rehash() { vector<Node*> newTables; newTables.resize(_tables.size() * 2); for (size_t i = 0; i < _tables.size(); i++) { Node* cur = _tables[i]; while (cur) { Node* next = cur->_next; size_t newIdx = _hash(cur->_kv.first) % newTables.size(); // 头插到新桶 cur->_next = newTables[newIdx]; newTables[newIdx] = cur; cur = next; } _tables[i] = nullptr; } _tables.swap(newTables); }

这段代码的精华就在“先存 next,再改指针”。cur->_next在头插之后会被覆盖,如果不提前存下来,你连下一个节点在哪都不知道。这是链表操作里最高频的一个失误,面试手撕题考察链表题的时候,十有八九都栽在这。

搬移完成之后,旧表所有桶都置空,然后和新表交换。newTables的作用域结束就会自动释放资源,旧表的 vector 内存也被合适地转移走了。整个过程,节点本身一次都没有被重新分配。

4.3 实测观察:负载因子与查找性能的关系

我拿这个实现做过一个简单的性能测试:插入 100 万个随机整数,表初始 10 个桶,负载因子阈值 0.7,触发扩容后桶数大约到 131072 个。100 万条数据分布到 13 万个桶,平均每条链长度也就 7 到 8 左右。查找 100 万个随机 key 的时候,绝大多数访问只需要比较一次。

如果把阈值调成 2.0,相同数据下平均链长就到了 15,查找耗时明显上涨。但如果调成 0.3,查找虽然更快,内存空桶数量翻倍,内存占用很高。这个取舍就是哈希表调优的核心:你是在为速度花内存,还是在为内存牺牲速度。0.7 到 0.75 这个区间,是工程上无数次验证过的平衡点。所以标准库默认值不是拍脑袋定的,是一整套取舍逻辑。

5. 真实开发中的常见问题与避坑清单

5.1 非整数类型怎么支持:string 和自定义类型的哈希

这一节回答一个高频问题:为什么我用HashFunc<string>特化能正常编译,自定义类型怎么搞?

先说结论。你的代码里如果写了HashTable<string, int>,模板参数默认是HashFunc<string>,而HashFunc<string>因为特化存在,可以直接调用。如果你写HashTable<pair<int,int>, int>,默认HashFunc<pair<int,int>>会走通用模板,把 pair 强转成size_t,编译直接报错。这时候你得自己提供一个仿函数作为第三个模板参数。

struct PairHash { size_t operator()(const pair<int, int>& p) { return (size_t)p.first * 131 + (size_t)p.second; } }; HashTable<pair<int, int>, int, PairHash> table;

这就是模板第三个参数存在的意义。STL 里的unordered_map也一样,支持你在声明容器的时候传入自定义哈希函数对象。理解了这个机制,你以后看到高级用法就不会觉得玄学了。

5.2 内存管理:谁负责释放那些 new 出来的节点

哈希表里所有节点都是new出来的,vector本身只管存指针,不会替你去释放这些堆内存。如果你不写析构函数,程序退出时那些节点全部泄漏。

这个问题在测试阶段根本发现不了,因为操作系统会回收进程内存,看起来风平浪静。但如果你把哈希表对象放在一个循环里反复创建销毁,内存就会肉眼可见地涨上去,最后触发 OOM。

所以我的建议跟很多教材不一样:哪怕是玩具代码,也把析构函数和 Clear 写上去。这不是过度设计,这是让你形成肌肉记忆——自己 new 出来的资源,必须自己负责释放。以后接触更复杂的 C++ 代码,这种意识能救你很多次。

5.3 迭代器和 delete 交叉使用的危险

我这份简单实现没有提供迭代器,因为迭代器一旦失效规则没想清楚,会带来巨大的心智负担。但真实项目里你迟早要面对:unordered_map的迭代器,在插入触发扩容后,全部失效;在删除时,只有被删元素的迭代器失效,其他迭代器不受影响。

如果你自己在哈希表上封迭代器,最容易踩的坑是:遍历到某个节点,调用erase删除它,然后继续迭代器自增。因为节点已经被 delete,自增操作访问的是悬空指针,直接崩。解法是遍历时提前保持 next 指针,删除当前节点后再把迭代器指向 next。这就是前面Rehash里“先存 next 再操作”的同一个原则。

这块多写两句:凡是链式结构,操作前先备份后继指针,是保命准则。

5.4 哈希分布不均:素数表、重哈希和“伪装”的慢查询

还有一类问题不是代码写错,而是哈希函数选得不对。某个游戏公司用unordered_map<string, int>存了大量玩家 ID,某天突然发现某个桶挂了上万条数据,其他桶基本空着,查找从 O(1) 退化到 O(n)。原因就是玩家 ID 的字符分布有规律,而默认的哈希函数对这种规律不够敏感。

排查这种问题的笨办法:打印每个桶的链表长度。如果最大桶长度超过平均值的 10 倍,哈希函数大概率不合格。解决方案有两个方向:换一个更强的哈希函数,或者把桶数量调整成素数。素数取余能把模运算的周期性打散,减少因子重叠带来的聚集。这也是某些库把扩容后的桶数固定成一串素数(比如 2、3、5、7、11、13)的原因。

到最后,我想说一下调试哈希表的一个笨办法。别靠肉眼读代码,写一个DebugPrint函数,把每个桶的索引和链长打印出来,插 100 个数据进去看一遍分布,很多问题立刻现原形。这比盯着代码猜半天高效太多。你真正跑一遍,就能直观感受到链地址法“均匀分散”这个目标到底有多重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询