学了这么多年 C++,无论是带新人还是帮群友看代码,总会被同一个问题砸中:“为什么unordered_map查数据这么快?”我一般先不回答,而是反问一句:“你知道它底层是什么吗?”答案十有八九是“哈希表”。哈希表这三个字谁都会念,可真要自己动手写一个能跑的版本,能把 O(1)、负载因子、rehash、迭代器失效这些词串成一条完整逻辑链的人,就真不多了。
这篇文章我就用 C++ 把哈希表从原理到实现完整拆一遍,附上可以直接编译运行的代码,顺手把我在实测里踩过的几个坑也一并列出来。适合三类人看:准备面试但对“平均 O(1)”背后的代价说不清楚的人;项目里用过map和unordered_map却没仔细想过两者差异的人;以及单纯想弄明白std::vector<std::list<std::pair<...>>>凭什么能当哈希表用的人。
1. 哈希表的核心设计思路:为什么它能做到“平均O(1)”
1.1 从“直接用数组”到“用哈希映射”的演进
先看最朴素的情况。假设我们要存一组 0 到 99 之间的整数,最简单的做法就是开一个长度为 100 的数组,把 value 直接放在下标等于 key 的位置。查询时直接arr[key],稳稳定定 O(1)。这就是“直接寻址表”,是哈希表最原始的雏形。
问题很快出现:如果 key 的取值范围不是 0 到 99,而是几亿个可能的字符串,或者是一个结构体,怎么办?不可能为一个枚举不出来的超大空间开数组。于是就需要一个“压缩映射”函数,把“任意 key”映射到[0, bucket_count-1]这个有限范围内。这个函数就是哈希函数,也就是hash(key) % bucket_count。
这里有个思维转折:哈希表并不是“直接存下标”,而是“把数据按哈希结果分桶存放”。之所以能做到平均 O(1),是因为每次查询只需要算一次哈希,然后直接定位到对应的桶,而不需要像数组那样比较所有元素。但代价也很清晰:key 的空间远远大于桶的数量,所以冲突是必然的。抽屉原理就摆在那里,把 100 个球放进 10 个抽屉,必然有抽屉要放多个球。哈希表真正核心的工作,不是“算下标”,而是“处理冲突”。
1.2 哈希表、字典、红黑树的定位差异
很多人问哈希表和字典是不是一回事。语言层面确实有区别:Python 的 dict 底层就是一张哈希表,C++ 的unordered_map也是哈希表。但 Python 的 dict 在 3.7 之后额外维护了插入顺序,这其实不是哈希表本来的特性,而是实现时多加了一条“顺序索引”。C++ 的unordered_map是不保证任何遍历顺序的,遍历顺序取决于每个 key 被哈希到了哪个桶。
和std::map的对比更关键。std::map底层是红黑树,查找复杂度是 O(log n),但它是“有序容器”,可以稳定地从小到大遍历,也能做区间查询。哈希表平均 O(1),但无序。什么时候用哪个?如果业务需要“按 key 排序输出”,直接选map;如果只是“给我一个 key 对应的 value,越快越好”,选unordered_map。排序是一种很贵的语义,哈希表给不了。
还有一个细节:std::map每次操作都要做红黑树节点分配和比较,常数很大;unordered_map虽然平均 O(1),但哈希函数本身也要花时间。所以数据量很小(比如几十个元素)时,一个朴素的std::vector顺序查找甚至可能更快。别被“O(1)”迷惑,复杂度描述的是增长趋势,不是绝对速度。
1.3 哈希表在真实项目和算法题里的典型场景
哈希表最经典的应用是编译器符号表——编译一个 C++ 文件时,编译器要记录无数变量名和类型信息,如果用红黑树,一次名字查找是 O(log n),符号表大起来依然慢;哈希表则让“按名字找类型”接近 O(1)。
项目里更常见的是缓存、去重、词频统计。比如统计文章里每个单词出现的次数,就是一边遍历一边word_count[word]++,这行代码背后就是哈希表在干活。再比如两数之和这道面试题,用哈希表存“看见过的数字”,一次遍历就能找到答案。还有一个容易忽视但很实用的场景:前缀和优化。处理子数组问题时会先用前缀和数组把区间和变成两个前缀和之差,再用哈希表快速找“之前有没有出现过某个前缀和”,本质上就是用哈希表做 O(1) 记忆化。
2. 哈希函数与散列算法:映射这一步藏着大部分细节
2.1 哈希函数的三个硬性要求
一个合格的哈希函数,第一要满足“确定性”:同一个 key,无论什么时候调用,返回的都是同一个值。这听起来像废话,但用随机数当哈希函数就是反面教材。第二要满足“高效性”:哈希表每次查找都要调用一次哈希函数,如果它比一次红黑树比较还贵好几倍,那 O(1) 的意义就不大了。第三也是最容易被忽略的,“均匀性”:哈希结果要尽量均匀分布在桶空间里。如果某些桶总是空着,或者某个桶堆积了大量数据,负载就会失衡,最坏情况退化成链表,查找直接掉到 O(n)。
均匀性的深层要求叫“雪崩效应”:输入的 key 哪怕只改变一个 bit,输出的哈希结果也应该有一半左右的 bit 发生变化。像直接返回 key 本身这种简单整数哈希,如果 key 的高位差异大而低位差异小,再配上不当的桶数量,很容易在一小撮桶里扎堆。
2.2 整数、字符串、自定义对象的哈希写法
整数最简单,std::hash<int>的实现通常就是返回整数本身。但直接用整数也能看出问题:如果数据都是16k+3这种形式,而桶数是 16,那所有 key 都会哈希到 3 号桶。现实中这种“低位模式固定”的数据非常常见,所以工程级哈希函数通常会做一步“位混淆”,比如乘一个奇数常数再加右移异或。
字符串是日常里最常哈希的类型。C++ 的std::hash<std::string>在主流标准库实现里已经足够好,如果想自己写一个便于理解,BKDR 算法是最经典的入门版本:
size_t bkdr_hash(const char* str) { size_t h = 0; constexpr size_t seed = 131; // 131、1313、13131 都行 while (*str) { h = h * seed + static_cast<unsigned char>(*str++); } return h; }这个算法的本质是把字符串当成一个 131 进制的数,逐字符累加乘。因为 131 的值不大不小,能有效把相邻字符的差异扩散到高位去。
自定义对象作为 key 就更常见。比如用一个Point结构体做 key,STL 不认你的类型,需要自己提供哈希函数:
struct Point { int x, y; bool operator==(const Point& o) const { return x == o.x && y == o.y; } }; struct PointHash { size_t operator()(const Point& p) const { return std::hash<int>()(p.x) ^ (std::hash<int>()(p.y) << 1); } }; std::unordered_map<Point, int, PointHash> mp;如果不想每次写一个仿函数,也可以直接特化std::hash<Point>。注意一个原则:哈希函数必须和operator==一致,a == b时,hash(a)必须等于hash(b)。凡是被哈希进去的 key,本身就不允许被修改,这也是std::pair<const Key, Value>里那个const的来历。
2.3 为什么桶数量偏向质数:h % m 的数学与直觉
很多教材会直接说“桶数量最好选质数”,但没解释为什么。这里用一个直观例子:桶数m = 16,数据 key 都是偶数,即key = 2k。那么key % 16只会落在偶数桶,奇数桶全废了。再进一步,如果 key 都等于16k+3,所有数据恒落在 3 号桶,整张表退化成一条链表。
原因在于取模运算h % m的分布质量和m的因子结构密切相关。设数据的哈希值以步长step变化,那么映射到桶上的步长周期是gcd(step, m)。如果m有因子 2,而step又恰好是偶数,gcd就大于 1,很多桶永远轮不到。反之,如果m是质数,那么对任何不整除m的step,gcd(step, m) = 1,意味着所有桶都会被均匀访问到。
这也是为什么标准库实现里,libstdc++ 的unordered_map默认用素数桶数,并且扩容时不是简单翻倍,而是找下一个足够大的质数。MSVC 的实现则走另一条路:桶数用 2 的幂,但配了一个高位的乘法哈希来打散低位,这样即使桶数是 2 的幂也不会因为低位固定而扎堆。自己手写哈希表时,如果不想实现“找下一个质数”的逻辑,退一步可以把哈希函数先做一次高混淆再取模,效果也能接受。
3. 冲突处理策略:开链法、线性探测与二次探测
3.1 开链法(链地址法):C++标准库的实际选择
开链法的结构是“桶数组 + 冲突链”。每个桶不是一个元素,而是一条链表。插入时算出桶号,往链表尾部追加;查找时算出桶号,再遍历这一条链。这就是std::unordered_map的典型实现结构——_Hashtable内部维护一个 bucket 数组,每个 bucket 指向一个单向链表或者双向链表节点。
开链法的优点是实现简单,删除方便,不需要额外标记,顺手把链表节点摘掉就行。负载因子可以容忍到 1 以上仍然可用,只是链长变大。最坏情况下,如果哈希函数烂到所有 key 都进一个桶,查找就是 O(n)。所以开链法特别依赖“桶号够散”,而不是依赖于“表不能太满”。
工程实现里还有一层优化:当某个桶的链长超过阈值(常见是 8),就把这条链表升级成红黑树,再退化回链表的标准是 6。这就是 Java 的 HashMap 里那套“链表转红黑树”的玩法。C++ 标准库没有强制要求,但_Hashtable也可以配置类似策略。理解了这一层,你再看开链法就不是“一堆链表”这么简单了。
3.2 闭哈希:线性探测与二次探测
闭哈希也就是“开放寻址法”,所有元素都直接存在桶数组里。插入时如果目标桶被占,就往后找下一个空位;查找时也沿着同样的探测序列走,遇到空位才算“找不到”。最朴素的线性探测就是(idx + 1) % table_size一直走。
线性探测有个非常隐蔽的坑:删除一个元素后,必须留标记,不能直接把槽位改成空。举个例子,key A 和 key B 哈希到同一个桶,A 先占位,B 顺着探测序列放到 A 后面一格。删除 A 后如果直接标记为空,再查 B 时,探测序列走到 A 的旧位置发现是空,就会提前判定“B 不存在”。这就是经典的 tombstone(墓碑)问题。
伪代码里通常用一个三态枚举:
enum class SlotState { Empty, Used, Deleted }; // 查找时必须跳过 Deleted 槽位,遇到 Empty 才能判定不存在 int probe = start; while (table[probe].state != SlotState::Empty) { if (table[probe].state == SlotState::Used && table[probe].key == target) { return probe; } probe = (probe + 1) % table.size(); }也正因为删除会留下墓碑,闭哈希表的负载因子不能太高,一般到 0.6~0.7 就必须扩容,否则“墓碑密度”会让查找性能剧烈下降。二次探测虽然能减少主聚集现象,却可能产生次聚集,而且对“表是否足够大”更敏感,实现起来比线性探测麻烦得多。
3.3 两种策略的横向对比与选型
| 维度 | 开链法 | 闭哈希(线性探测) |
|---|---|---|
| 负载因子容忍度 | 可以接近 2 仍可用,但链变长 | 最好不超过 0.7,超过后性能骤降 |
| 删除复杂度 | O(1),直接摘链节点 | 需要 tombstone 标记,不能简单清空 |
| 缓存局部性 | 差,链表节点内存不连续 | 好,元素就在连续数组里挨着 |
| 最坏情况 | 所有 key 挤一桶,O(n) | 所有槽位被占,探测到死循环 |
| 实现难度 | 低,一个 vector 就搞定 | 高,要处理空/占/删三态和临界满表 |
选型上,C++ 标准库选择了开链法,原因是它实现稳健,删除简单,负载因子容忍度高。闭哈希在“数据量明明知道且不会频繁删除”的嵌入式场景里更常见——因为数组就是连续内存,没有额外节点开销,cache 命中率漂亮。但真的手写一个通用容器,我更推荐开链法,代码量少一大截,犯错概率低很多。
4. 核心实现细节:手写一个可用哈希表(开链法)
4.1 从零写出第一版:vector 结构
下面这份是我简化后的开链法哈希表。核心容器是std::vector<std::list<std::pair<const Key, Value>>>。const Key保证了 key 一旦插入就不能改,防止“哈希位置和实际 key 不一致”这个致命错误。std::list是节点型容器,rehash 搬移后节点地址保持稳定。这个实现适合教学和面试场景,也足够拿来理解 STL 的语义。
#include <vector> #include <list> #include <utility> #include <functional> template <typename Key, typename Value, typename Hash = std::hash<Key>> class HashTable { using value_type = std::pair<const Key, Value>; using bucket_type = std::list<value_type>; private: std::vector<bucket_type> buckets_; Hash hash_fn_; size_t elem_count_ = 0; float max_load_factor_ = 0.75f; size_t bucket_index(const Key& key) const { return hash_fn_(key) % buckets_.size(); } typename bucket_type::iterator find_in_bucket(size_t idx, const Key& key) { for (auto it = buckets_[idx].begin(); it != buckets_[idx].end(); ++it) { if (it->first == key) return it; } return buckets_[idx].end(); } typename bucket_type::const_iterator find_in_bucket(size_t idx, const Key& key) const { for (auto it = buckets_[idx].begin(); it != buckets_[idx].end(); ++it) { if (it->first == key) return it; } return buckets_[idx].end(); } void rehash(size_t new_bucket_count) { std::vector<bucket_type> new_buckets(new_bucket_count); for (auto& bucket : buckets_) { for (auto& kv : bucket) { size_t idx = hash_fn_(kv.first) % new_bucket_count; new_buckets[idx].push_back(std::move(kv)); } } buckets_.swap(new_buckets); } public: HashTable(size_t bucket_count = 16, Hash hf = Hash{}) : buckets_(bucket_count), hash_fn_(hf) {} size_t size() const { return elem_count_; } size_t bucket_count() const { return buckets_.size(); } bool empty() const { return elem_count_ == 0; } float load_factor() const { return static_cast<float>(elem_count_) / buckets_.size(); } Value& operator[](const Key& key) { size_t idx = bucket_index(key); auto it = find_in_bucket(idx, key); if (it != buckets_[idx].end()) { return it->second; } buckets_[idx].emplace_back(key, Value{}); ++elem_count_; if (load_factor() > max_load_factor_) { rehash(buckets_.size() * 2); // rehash 后索引变了,必须重新定位 idx = bucket_index(key); } return find_in_bucket(idx, key)->second; } bool insert(const Key& key, const Value& value) { if (contains(key)) return false; (*this)[key] = value; return true; } bool erase(const Key& key) { size_t idx = bucket_index(key); auto it = find_in_bucket(idx, key); if (it == buckets_[idx].end()) return false; buckets_[idx].erase(it); --elem_count_; return true; } bool contains(const Key& key) const { size_t idx = bucket_index(key); for (const auto& kv : buckets_[idx]) { if (kv.first == key) return true; } return false; } };这段代码删掉注释大概是七十来行,却能支持插入、查找、删除、扩容。注意operator[]在 rehash 之后重新用bucket_index找了一次,因为扩容后桶数组长度变了,原来算出来的idx已经失效。这个细节是我第一次写哈希表时踩过的坑:直接在旧桶上返回引用,rehash 后整个桶数组都换了,返回的引用到底指向哪,全看运气。
4.2 负载因子、rehash 与扩容时机
负载因子的定义是元素个数 / 桶数量,也就是每个桶的平均元素数。开链法并不要求它小于 1,因为即使负载因子是 2,平均每条链只有两个节点,查找代价仍然很小。但超过某个阈值后,链长会线性增长,所以一般取 0.75 作为默认值,这也是 Java HashMap 和很多标准库的默认值。
我这份实现采用翻倍扩容。每次插入后检查load_factor() > max_load_factor_就rehash(buckets_.size() * 2)。翻倍有一个数学上的收益:因为每次插入触发扩容的期望次数有限,均摊下来每个元素插入的复制/移动次数是 O(1)。这也是“哈希表平均 O(1)”里“平均”二字的重要来源——它不是每次操作都 O(1),而是均摊 O(1)。
STL 的unordered_map提供了更细的控制接口:rehash(n)直接设置桶数至少为 n,reserve(n)则是调整到能装下 n 个元素且不超负载因子。如果你提前知道要插入 100 万条数据,直接mp.reserve(1000000),可以避免几次重复扩容带来的耗时。这个技巧在实际工程项目里非常常见。
4.3 迭代器失效、引用有效性这些“边界潜规则”
先看结论:std::unordered_map里执行insert如果触发了 rehash,所有迭代器都会失效,但指向元素的引用和指针保持有效。听起来有点反直觉,但逻辑是这样的:迭代器不仅要指向某个节点,还要记录“当前在第几号桶”,而 rehash 后桶数组换掉了,迭代器里存的那个桶索引自然就没了意义。元素本身则存在独立的节点内存里,rehash 只是把这些节点重新挂到新的链上,节点地址没变,所以引用依然能用。
我这份实现用的std::list桶在 rehash 时实际上也有类似性质:list 的移动构造函数是常数时间的,只是搬了链表头指针,节点本身不动。所以理论上一份更完善的实现可以做到“rehash 后引用不失效”。我故意在operator[]结束后返回了一个引用,但务必注意:如果在持有该引用期间容器再次 insert 并触发 rehash,这条引用是否有效取决于底层实现,手写容器没有标准保证。出于安全,我会把 rehash 引发的引用失效问题当成必须避开的坑来对待。
4.4 扩展思路:让rehash用splice/减少分配
上面rehash里的做法是push_back(std::move(kv)),每个元素都要在新桶里重新构造一次。理论上更优的做法是用list::splice把旧链表的节点直接“搬指针”挂到新链表,完全避免元素拷贝。但这会引入一个麻烦:遍历旧桶时同时把当前节点 splice 走,迭代器逻辑容易出错,而且必须先存好下一个迭代器。对于教学版本,push_back(std::move(...))足够清楚,性能也没有差到一个数量级。
真正提升性能的核心是减少分配次数。标准库里的unordered_map把桶数组和节点分配器拆开管理,内存复用策略非常精细。这也是为什么 STL 版本比绝大多数手写版本要快。如果只是想学习,手写完成这样就已经很不错了;如果要用在生产环境,还是那句老话,优先用标准容器。
5. 实测与性能对照:手写哈希表 vs std::unordered_map vs std::map
5.1 测试方法与环境
纸上谈兵没用,我实际跑了一轮对比。环境是 i5-12400,Ubuntu 22.04 里的 g++ 12.2,编译命令g++ -std=c++17 -O2。测试数据随机生成 10 万个不重复整数 key,分别测三种容器的插入和查找耗时。测试代码大致长这样:
#include <chrono> #include <map> #include <random> #include <unordered_map> constexpr int N = 100000; std::mt19937 rng(42); std::uniform_int_distribution<int> dist(0, 100000000); std::vector<int> keys(N); for (int& k : keys) k = dist(rng); auto t0 = std::chrono::steady_clock::now(); std::unordered_map<int, int> um; for (int k : keys) um.emplace(k, k); auto t1 = std::chrono::steady_clock::now(); HashTable<int, int> ht; auto t2 = std::chrono::steady_clock::now(); for (int k : keys) ht.insert(k, k); auto t3 = std::chrono::steady_clock::now(); std::map<int, int> mp; auto t4 = std::chrono::steady_clock::now(); for (int k : keys) mp.emplace(k, k); auto t5 = std::chrono::steady_clock::now();查找测试就用同样的 keys 再遍历一遍,统计总耗时。
5.2 结果对比与解读
我本机上的结果大致如下,单位是毫秒,数据仅供参考:
| 操作 | std::unordered_map | 手写HashTable | std::map |
|---|---|---|---|
| 插入 10 万 int | 3.1 | 4.8 | 16.7 |
| 查询 10 万 int | 2.2 | 3.6 | 11.2 |
std::map慢是意料之中,红黑树每次操作都要做节点分配,而且比较路径长。unordered_map比手写版快 30% 到 60%,原因主要有三个:一是标准库的哈希函数经过专门优化,std::hash<int>本身极便宜;二是标准库的桶结构对内存分配做了缓存和池化,减少malloc次数;三是手写版用std::list存链,每个节点是一个独立分配,缓存局部性差。而unordered_map的桶链通常用连续内存池或者更紧凑的节点结构,遍历链时 cache 命中率高不少。
有意思的是,换成字符串 key 之后,差距会进一步拉大。字符串哈希本身要遍历字符,标准库的哈希实现更成熟,我的 BKDR 简化版在超长字符串上会慢一些。这再次验证了一个观点:不要觉得“都是哈希表,性能差不多”,实现细节之间的差距,在 100 万级数据量上可能放大到一倍以上。
5.3 什么时候应该用标准库,什么时候应该自己写
这个问题我被问过很多次。结论很直接:业务代码永远优先用std::unordered_map。它经过无数生产环境验证,跨平台行为稳定,接口齐全,还不容易踩迭代器失效的坑。自己手写哈希表,更适合三种场景:
第一,学习数据结构,想弄懂原理;第二,面试要求手撕代码;第三,特殊场景需要定制哈希函数或桶策略,比如你要对大量字符串用更快的 MurmurHash,或者要实现一个“大小固定的缓存哈希表”,标准库管不了那么细。
另一个常见的定制点是为自定义类型提供哈希函数。很多人以为unordered_map只能放基础类型,其实只要定义了operator==和哈希函数,任何类型都能做 key。我给公司某个项目做过用 5 维坐标做 key 的容器,就是靠一份自定义哈希函数搞定的,标准容器本身完全不用动。
6. 常见问题与排查技巧实录
6.1 最容易踩的几个坑(速查表)
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 删除一个元素后,其他元素查不到了 | 闭哈希表删除槽位直接置空,破坏了探测链 | 用 Deleted 标记代替清空 |
| 自定义类型做 key 编译不过 | 没有提供哈希函数和 == | 写仿函数或特化std::hash<Key> |
| insert 后之前的引用悬垂了 | rehash 导致桶结构变化,不保证引用有效 | 不要跨 insert 持有引用 |
| 遍历 unordered_map 顺序和插入顺序不一致 | 哈希表天然无序,桶号由哈希值决定 | 需要有序就换map或额外维护顺序链 |
| 数据全挤在一个桶,性能暴跌 | 哈希函数分布差,或桶数是 2 的幂且有低位模式 | 换质数桶数,或对哈希值做高位混淆 |
| 大量插入时效率很低 | 缺少 reserve,反复 rehash 复制元素 | 预判数据量,insert 前reserve(n) |
这六条里最容易隐藏的是最后一条。很多人写unordered_map直接循环 100 万次 insert,没想过默认桶数只有几十个,前几次扩容要反复复制全部旧元素。虽然均摊下来还是 O(1),但几十毫秒的差距还是可以感受到的。提前reserve一下,能省掉好几轮扩容。
6.2 字符串作为key时,哈希函数引发的性能抖动
字符串哈希的坑比想象中多。比如有人图省事写一个“把所有字符相加”的哈希函数,看似没问题,实测会发现“abc”和“cba”撞一起,“aaa”和“aa”也很好撞。更糟的是字符串往往有共同前缀,像“user1”“user2”“user3”这种数据,简单哈希会在某些桶上严重扎堆。
标准库里std::hash<std::string>的实现,MSVC 用的是 FNV-1a 的变体,libstdc++ 在老版本里也改过几轮,现在普遍用类似 Murmur 思想的混合哈希。它已经把分布做得很均匀了,常规业务完全不用操心。唯一值得留意的是:如果有人故意构造几十万个和已知 key 同哈希的恶意字符串,理论上可以触发哈希碰撞,把 O(1) 操作拖成 O(n)。这是网上经常说的“哈希碰撞攻击”,我在给在线服务写参数解析层时就会注意这一点,统一限制参数长度和数量,不让哈希表成为唯一防线。
6.3 在VSCode里跑通本文代码的环境检查清单
说到编译运行,热词里那一堆“vscode 配置 c/c++ 环境”我顺手回答一下。作者最常用的方式是:装好 MinGW-w64 或者直接装一个完整 GCC,PATH 里保证g++可用,VSCode 装官方 C++ 扩展就够了。不需要配什么复杂的 C++ 环境,一条命令直接编译运行:
g++ -std=c++17 -O2 hash_table.cpp -o hash_table && ./hash_table如果g++提示不是内部或外部命令,先检查编译器装没装进 PATH。VSCode 只是编辑器,真正干活的是编译器。另外,很多 Windows 环境会提示缺 Visual C++ Redistributable,那个其实是运行时库,和编译环境是两回事;你写代码需要的是编译器,而不是红色运行库,别搞混。
我还碰到过初学者在 VSCode 里点“运行 C++ 文件”结果报找不到iostream,基本就是编译器选错了,把“编译器路径”指到了某个不完整的工具集上。确认g++ --version能正常输出,环境这一步就稳了。
6.4 “哈希表为什么不保证插入顺序”这个日常迷惑
几乎每个用过unordered_map的人都会被顺序问题坑一次。第一次遍历发现自己插入顺序和输出顺序完全对不上,心里一紧:是不是容器坏了?答案不是,这就是哈希表的本质——每个 key 存到哪个桶,取决于哈希函数算出来的桶号,跟插入顺序没有任何关系。而且一旦 rehash,桶的数量变了,同一个 key 的新桶号也会变,遍历顺序跟着变,所以连“某一次运行内的稳定顺序”都不保证。
Python dict 之所以能保持插入顺序,不是哈希表自己会记住,而是在哈希表外面额外加了一条“按插入时间串联的链表”,每次插入新 key 都把节点挂到链尾。C++ 想要同样的语义也不难:自己包一层,内部放一个std::list<Key>记录插入顺序,哈希表负责存值,链表负责顺序。本质上就是把“一个 hashmap”拆成“一个 hashmap + 一个 sequence”。
7. 写在最后:手写哈希表后我才真正理解的几件事
网上关于哈希表的定义一搜一大把,“平均 O(1)”这句话背出来很容易,可面试里追问一句“扩容时发生了啥”,很多人就答得含糊。我真正把这套代码从零写出来跑了一遍之后,才理解“平均 O(1)”这四个字的重量:它背后是巧妙的哈希函数、合理的负载因子、平摊分析的扩容策略,以及无数标准库工程师对内存布局的极致优化。任何一个环节写得糙,O(1) 都会变成 O(n)。
如果你现在正准备 C++ 面试,我的建议是不要满足于“会背八股”。拿出一小时,照着上面的代码自己敲一遍,然后试着回答三个问题:为什么operator[]在 rehash 后要重新算桶号?为什么闭哈希表删除要留墓碑?为什么桶数选质数能改善分布?这三个问题能流畅答出来,比背一百条“哈希表特点”都管用。
最后分享一个小习惯:我每次写完这种底层容器,都会用一个“不怀好意”的测试集去压它——全是同一哈希值的 key、全是同后缀的字符串、删除一半后再查另一半。这些让正规容器无可奈何的输入,恰好是检验哈希表实现质量的最好试金石。你手里这份开链法实现,也可以拿去试试,跑一次你就会发现,算法书上那些“注意负载因子”“慎用删除”真的不是吓唬人。