哈希表这个东西,我敢说每个写代码的人都用过,甚至天天在用,但要真让你说清楚它底层是怎么工作的,hash函数该怎么选、冲突怎么处理、为什么负载因子是0.75而不是0.99,很多人反而卡住了。我自己当年学数据结构时也是这样,链表、二叉树都能画图脑补出来,唯独哈希表总有种"知其然不知其所以然"的模糊感——明明用着unordered_map很顺手,但一遇到自定义类型做键就报错,或者一深究开放地址法就懵。这篇文章我就把哈希表从头到尾拆一遍,从它到底解决什么问题,到哈希函数怎么设计,再到冲突处理和C++实战手写一个能用版本,争取让你看完之后不光会用,还能讲明白。
1. 先搞明白哈希表到底解决了什么问题
1.1 数组和链表查找的痛点
我特别喜欢用一个类比来解释哈希表:你去一个巨大仓库找一件货,仓库管理员有两种管理方式。第一种是把所有货物按编号顺序摆在货架上,你知道编号就能直接走过去拿,这就是数组的随机访问,O(1)的时间。但问题是,如果货物不是按数字编号,而是按名字、按颜色、按日期来记录呢?你就得一个一个翻,最坏情况下翻遍整个仓库,这就是O(n)的线性查找。
那链表呢?链表更惨,它连"按编号直接走过去"都做不到,你只能从第一个节点开始顺着指针往下找。哪怕你知道要找的是第10000个节点,你也得从第1个走到第10000个,不能跳。
所以在哈希表出现之前,查找这件事的复杂度一直卡在O(n)或O(log n)——平衡二叉树确实能到O(log n),log n在数据量小的时候无所谓,但你有几百万条数据,log₂(1,000,000)约等于20,意思是查一次要比较20次,其实也挺快了。但哈希表的追求更极端:能不能做到无论数据多少,查找都是常数时间?
1.2 哈希表的核心理念:把"值"变成"位置"
数组能做到O(1)查找,根本原因是它有一个牛逼的映射:下标到内存地址。你告诉它下标i,它立刻算出地址 = 数组首地址 + i × 每个元素大小,直接跳过去。
哈希表想做的事情就是:把我关心的"值"(比如一个字符串、一个对象)也变成一个"下标"。这个从值到下标的映射函数,就叫哈希函数(hash function)。
比如我有一批学生的学号,范围是20240001到20249999,我想快速按学号查出学生信息。最蠢的办法是开一个长度为20249999的数组,用学号做下标——这当然能O(1)查找,但空间浪费太严重了,中间大部分位置是空的。哈希函数做的事情就是把这个大范围的值域压缩到一个小范围的槽位,比如把学号对10007取模(学号 mod 10007),得到0到10006之间的一个数,然后开长度为10007的数组来存。这样空间省了,查找速度还是O(1)。
不过聪明的读者马上发现问题了:20240001 mod 10007 和 20250008 mod 10007 可能得到同一个余数,这俩学号如果都要存,就冲突了。这就是哈希表最核心的矛盾:压缩映射必然带来冲突,而哈希函数设计和冲突处理,就是哈希表的两大命门。
1.3 哈希表的基本操作和复杂度
哈希表对外提供的核心操作就三个:插入(insert)、查找(find)、删除(erase)。它的工作流程是:
- 对键key调用哈希函数,得到哈希值h = hash(key);
- 对哈希值取模(或其他压缩方式),得到槽位下标idx = h % table_size;
- 在这个槽位上进行插入、查找或删除。
平均情况下,哈希表的插入、查找、删除都是O(1)。注意"平均情况"这个词——最坏情况,如果所有键都映射到同一个槽位,哈希表就会退化成一条链表,所有操作都变成O(n)。虽然这种极端情况在工程上几乎不会出现,但理解这个退化条件,才是真正搞懂哈希表的关键。后面讲到哈希函数和冲突处理时你会发现,所有设计都是在跟这个"最坏情况"作斗争。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 哈希函数:第一道关,也是最容易出问题的一关
2.1 哈希函数的基本要求
哈希函数不是随便写个数学公式就行的,它有三个基本要求:
确定性:同一个键必须总是映射到同一个槽位。这是哈希表能工作的前提。换句话说,哈希函数不能有随机性,不能依赖时间、随机数等外部状态。
均匀性:尽量让不同的键均匀分布在各个槽位上。如果哈希函数的输出不均匀,某些槽位就会堆积大量数据,导致冲突变多,性能下降。
高效性:哈希函数本身的计算必须很快。如果算一个哈希值要消耗相当于一次线性查找的时间,那哈希表的意义就没了。
反过来想,如果哈希函数设计得不好,最直接的后果就是——冲突过多,哈希表退化成链表,所有操作变成O(n)。比如你用"字符串长度"作为哈希函数,那所有长度为5的字符串都会挤在同一个槽位里,哈希表基本就废了。
2.2 除留余数法和质数选型的原理
哈希表里最经典的哈希函数就是除留余数法,公式是:
h(k) = k mod m
这里的k是整数键,m是哈希表的容量(槽位数)。这个函数够简单够快,但有一个关键细节:m的取值很有讲究。
先说结论:m取质数时,冲突分布最均匀。原因牵扯到数论:如果键的分布存在某种周期性或规律性,而m恰好是这些规律的倍数,那么模运算结果就会集中。举个例子,假设我们的键都是偶数,m选了8,那么所有键对8取模的结果只能是0、2、4、6这4个值,有一半的槽位永远空着。但如果m是质数,比如7,偶数对7取模就能覆盖0到6的全部余数,分布就均匀多了。
不只是理论上的规律,真实场景中很多键本身就有倍数关系——比如内存地址(通常按8或16字节对齐)、计数器值(通常按2的幂增长)。我见过一个真实案例:一张表用"消息ID对1024取模"做分区,消息ID是每64个消息一个批次递增的,结果就是只有16个分区有数据,其他分区全部空闲,负载严重不均。改成对1021(质数)取模之后问题立刻消失。
2.3 更进一步的散列策略:乘法散列和字符串哈希
除留余数法不是万能的,它有两个局限:一是对键的分布有假设,二是如果键是字符串或者其他复合类型,直接取模根本没法做。这时候需要更通用的哈希策略。
乘法散列的思路是:h(k) = floor(m × (k × A mod 1)),其中A是一个0到1之间的常数,通常取黄金分割比倒数 (√5−1)/2 ≈ 0.618。为什么取黄金分割比?因为它是无理数的近似,可以避免周期性。这个方法的巧妙之处在于:它不直接取k的倍数,而是把k乘以一个"足够无理"的数,再取小数部分,这样即使k的分布有规律,映射结果也能铺开。乘法散列在m取2的幂时依然有效,这是它对比除留余数法的一个优点,取模(位与)运算更快。
字符串哈希在工程里更常用。最简单的一个思路是把字符串看成一个多位数,每位是一个字符的编码,然后用一个质数做基数来计算:
h(s) = s[0] × base^(n-1) + s[1] × base^(n-2) + ... + s[n-1]
常见的两种实现:DJB2和FNV-1a。DJB2的C语言实现是这样的:
c复制unsigned long hash_djb2(const char *str) {
unsigned long hash = 5381;
int c;
while ((c = *str++)) {
hash = ((hash << 5) + hash) + c; // hash * 33 + c
}
return hash;
}
FNV-1a的实现是:
c复制unsigned long hash_fnv1a(const char *str) {
unsigned long hash = 1469598103934665603ULL;
while (*str) {
hash ^= (unsigned char)(*str++);
hash *= 1099511628211ULL;
}
return hash;
}
这两个函数都很简单,但质量比"把所有字符ASCII码加起来"高得多,因为它们考虑了字符的顺序——"ab"和"ba"的哈希值不同。很多初学者踩过的坑就是:"我写了个把字符码值相加的哈希函数,结果'ab'、'ba'、'aa'的哈希值一模一样,全部冲突了。" 这在任何需要处理字符串键的场景都是灾难。
2.4 关于"别自己发明哈希函数"的忠告
哈希函数是一个非常容易被低估的领域,看起来谁都写得出来,写出来的东西也"能跑",但均匀性、抗碰撞性、计算效率这些指标要在海量数据下才能体现差距。我的建议是:
- 在C++里,直接用
std::hash<T>,它能处理所有基础类型和常见标准库类型,编译器供应商已经帮你调教好了。 - 在Java里,用
hashCode()配合HashMap内部的扰动函数。 - 在Python里,直接用内置的
hash()。 - 只有当你需要自定义哈希函数(比如自定义类型做键)时,才需要自己动手,这时候也建议组合已有的
std::hash,而不是从零编一个数学公式。
用组合的方式实现自定义类型哈希,是C++里的标准做法,后面在C++实战章节我会给出一个可直接抄的模板。
3. 冲突处理:链地址法 vs 开放地址法
3.1 为什么冲突不可避免
就算哈希函数设计得再好,冲突也是不可避免的,原因是鸽巢原理:你有m个槽位,要存n个键,当n > m时必然至少有两只鸽子挤在同一个洞里。就算n ≤ m,因为哈希函数是把一个大集合映射到小集合,也存在多个不同键映射到同一槽位的可能性。
所以,哈希表设计的关键不在"避免冲突"——那是不可能的,而在"冲突之后怎么办"。主流方案就两个:链地址法(Chaining)和开放地址法(Open Addressing)。
3.2 链地址法:每个槽位挂一条链表
链地址法的思路很朴素:当多个键映射到同一个槽位时,就在这个槽位下面挂一条链表,每个新冲突的节点追加到链表尾部。
code复制槽位0: [ ] -> 节点A -> 节点B
槽位1: [ ]
槽位2: [ ] -> 节点C
查找的时候,先算出槽位,在链表中线性查找;插入的时候,先算槽位,插到链表头部(O(1));删除的时候,同样先算槽位,在链表中找到并摘除。
C++标准库的std::unordered_map就采用链地址法(但实现并不是单纯链表,某些实现用的是单向链表加数组,具体细节有差异)。Java的HashMap在JDK 8之后做了优化:当单个桶的链表长度超过8且表容量大于等于64时,会把链表转成红黑树,把最坏情况从O(n)优化到O(log n)。这说明链地址法的一个潜在问题:冲突多的时候链表会变得很长。
链地址法的优缺点很鲜明:
- 优点:实现简单,删除操作方便,支持负载因子大于1(也就是说表可以"超载"着用),对哈希函数质量的要求相对宽松。
- 缺点:每个节点需要额外的指针字段,内存开销大;链表节点的内存不连续,遍历时缓存命中率低。
3.3 开放地址法:在表内找空位
开放地址法的思路完全不同:所有数据都直接存在哈希表的槽位数组里,不额外挂链表。当冲突发生时,它在表内继续寻找下一个空位存放,而不是把数据挂在槽位外面。
寻找空位的方式有三种经典策略:
线性探测:发生冲突时,依次检查下一个槽位(idx+1, idx+2, ...),找到空位就放进去。查找时也按同样的顺序找,直到找到键或遇到空位(说明不存在)。线性探测的实现简单,但它有一个很著名的毛病:主聚集。一旦某个区域发生了多次冲突,就会形成一个连续的"占用块",后续所有映射到这个块的键都要往后探测一大段,块越滚越大,性能非线性恶化。
我给一个具体的例子:假设表大小是7,依次插入键10、17、11。它们对7取模的结果分别是3、3、4。10先落到槽3,17冲突后顺延到槽4,11对7取模也是4,但槽4已经被17占了,继续顺延到槽5。最终分布为:槽3 = 10,槽4 = 17,槽5 = 11。三个键映射到两个位置,却占了三个槽位,这个"连锁反应"如果发生在数据量大时,就是性能雪崩的起点。
二次探测:用步长的平方递增来探测,即探测位置是 idx+1², idx+2², idx+3²... 这样做的目的是让探测序列分散开,避免线性探测的主聚集。但它仍有一个问题叫二次聚集:所有从同一个槽位出发的键,探测序列完全相同,只是影响范围变小了。
双重散列:用第二个哈希函数来决定探测步长。比如探测位置是 idx + i × hash2(key),这里的hash2是另一个独立的哈希函数。因为步长跟键本身相关,不同的键即使初始槽位相同,探测序列也不同,可以较好避免聚集问题。双重散列是开放地址法中最健壮的方案,代价是每次探测要额外算一次哈希函数,计算开销略高。
3.4 两种方案的对比与选型建议
| 对比维度 | 链地址法 | 开放地址法 |
|---|---|---|
| 实现复杂度 | 较低 | 较高,删除和探测逻辑更绕 |
| 内存布局 | 节点分散在堆中,缓存不友好 | 连续数组,缓存命中率极高 |
| 删除操作 | 直接摘掉链表节点 | 需要墓碑标记,逻辑复杂 |
| 负载因子限制 | 可以大于1,但大于2基本就失控 | 必须远小于1,一般0.5~0.75 |
| 哈希函数质量要求 | 相对宽松 | 要求很高,哈希函数差了就是灾难 |
| 代表应用 | C++ unordered_map、Java HashMap | Python dict、Go map、Redis 哈希表 |
选型建议:如果你在写业务代码,直接用标准库的链地址法哈希表就行,够用了。如果你在写底层组件、追求极致的缓存友好性,或者内存非常珍贵,开放地址法值得使用——Python和Go的map都用开放地址法不是巧合,它们的性能表现就在那里。
4. 开放地址法最容易被坑的地方:删除与墓碑标记
4.1 为什么删除不能直接置空
开放地址法和链地址法最大的差异出现在删除操作上。链地址法删除时,从链表中摘除节点,干净利落。但开放地址法不行——不能把一个槽位直接置空。
为什么?假设我们往表里依次插入键A、B、C,A最初落在槽0,B冲突后探测到槽1,C冲突后继续探测到槽2。现在如果删除B,直接把槽1置空,那么查找C的时候,C从槽0开始探测:槽0是A,不匹配,继续;槽1是空位!按开放地址法的规则,遇到空位就说明"这个键不存在",于是查找C直接返回"不存在",但C明明就在槽2里。
这就是开放地址法删除的核心矛盾:空位是探测序列的终止条件,你把它置空了,等于把后面的键全部"弄丢"了。
解决办法是引入墓碑(tombstone)标记。删除一个键时不把槽位置空,而是标记为"已删除"。查找时遇到墓碑要继续往后探测,不能停下;插入时遇到墓碑可以复用这个位置。这样既不会中断探测序列,又能回收空间。
4.2 墓碑带来的性能隐患
墓碑解决了正确性问题,但带来了新问题:当删除操作很多时,表里会出现大量墓碑位置,它们实际没存数据,但查找时碰到它们不能停,必须继续往后扫。如果墓碑太多了,查找效率会明显下降,因为实话说,"知道这个键不存在"要扫过一串墓碑才能确认。
降低墓碑影响的办法有几个:
- 定期重建表,把墓碑清掉。
- 在插入时优先复用墓碑位置(需要额外记录墓碑位置)。
- 当墓碑数量超过一定比例时强制扩容。
这些策略说明了一个事实:开放地址法在删除密集型场景下不占优势。如果你的代码是高频删除的负载,链地址法更省心。
4.3 负载因子的意义:为什么必须在"满"之前扩容
负载因子的定义是:α = 已存元素数量 / 表容量。它直接反映哈希表的"拥挤程度"。
对链地址法来说,α 可以大于1,因为链表天然能处理"每个槽位平均挂多个节点"的情况。测试数据显示,α 从0到1.0,链地址法的查找时间会有缓慢上升,但到2.0之后性能会明显恶化。
对开放地址法来说,α 必须严格小于1,因为这个方法要求表里始终有空位。更精确地说,线性探测在 α=0.5 时,成功的查找平均探测约1.5次;α=0.7 时平均约2.0次;α=0.9 时平均约5.5次,而且这是平均值,最坏情况是灾难性的。所以工程上开放地址法的负载因子上限一般设在0.7左右,超过就扩容。你看到很多哈希表实现里默认负载因子是0.75,不是随便拍脑袋定的,是一个时间与空间平衡的取值:太高性能劣化,太低浪费空间。
4.4 扩容和rehash:为什么不能直接搬运
哈希表快满的时候必须扩容,扩容的操作是:分配一个更大的数组(通常是原大小的2倍),然后把所有旧数据重新插入新表。
注意,不是把旧数组的元素复制过去就行,因为每个键的槽位是通过 hash(key) % table_size 计算的,表大小变了,取模的模数也变了,槽位就全变了。必须对每个键重新计算哈希值、重新确定位置。这就是"rehash"的由来。
rehash的代价是O(n),但摊还下来,扩容一次相当于每个元素被多搬运一次,均摊后插入操作依然是O(1)的摊还复杂度。这和动态数组(vector)扩容的逻辑完全一样——向量扩容也是每次翻倍,分摊下来每个元素平均只被复制O(1)次。
实操中,如果你能提前预估数据规模,在初始化时直接指定容量可以避免大量中间扩容,省下很多时间。C++的unordered_map有reserve()接口,Python的dict没法直接预分配,但你可以通过传入初始字典的方式让底层一次性开够。
关于rehash还有一个重要提醒:扩容时所有迭代器都会失效。无论链地址法还是开放地址法,rehash之后元素的物理位置全变了,如果你持有一个旧迭代器继续用,是未定义行为。写代码时要注意,在迭代过程中不能触发扩容,否则轻则漏数据,重则崩溃。
5. C++实战:手写一个链地址法哈希表
5.1 需求设计和类型选型
现在到实战环节。我带你从零写一个能用的哈希表,参照std::unordered_map的核心行为,但不追求完全兼容标准库,重点是让你看到哈希表内部的完整逻辑。
设计需求:
- 支持泛型键K和值V,
insert、find、erase三个核心操作。 - 使用链地址法,底层存储为
std::vector<std::list<std::pair<K, V>>>。 - 哈希函数用
std::hash<K>。 - 负载因子达到阈值时自动扩容。
为什么选链地址法?因为代码实现比开放地址法直观得多,适合用来讲清楚原理,也适合作为后续自己扩展的起点。如果你理解了链地址法版本,再看开放地址法实现会更容易。
5.2 核心数据结构
cpp复制#include <vector>
#include <list>
#include <utility>
#include <functional>
template <typename K, typename V, typename Hash = std::hash<K>>
class MyHashMap {
public:
using key_type = K;
using mapped_type = V;
using value_type = std::pair<const K, V>;
using bucket_list = std::list<value_type>;
using iterator = typename std::list<value_type>::iterator;
using const_iterator = typename std::list<value_type>::const_iterator;
private:
std::vector<bucket_list> buckets_;
Hash hash_fn_;
size_t elem_count_ = 0;
float max_load_factor_ = 0.75f;
size_t bucket_index(const K& key) const {
return hash_fn_(key) % buckets_.size();
}
public:
MyHashMap(size_t bucket_count = 16, const Hash& hash_fn = Hash())
: buckets_(bucket_count), hash_fn_(hash_fn) {}
size_t size() const { return elem_count_; }
bool empty() const { return elem_count_ == 0; }
size_t bucket_count() const { return buckets_.size(); }
// ... 下面逐步实现 insert / find / erase
};
注意这里用std::list作为桶的容器,std::list的迭代器在插入和删除其他元素时不会失效,这在实现erase返回"下一个迭代器"时很省心。如果你用std::vector自己管理节点,要考虑迭代器失效问题。
另外,value_type用std::pair<const K, V>,跟标准库一致,保证你拿到的key不能随意修改。
5.3 insert和find的实现
cpp复制 std::pair<iterator, bool> insert(const value_type& value) {
if (need_rehash()) {
rehash(buckets_.size() * 2);
}
size_t idx = bucket_index(value.first);
auto& bucket = buckets_[idx];
for (auto it = bucket.begin(); it != bucket.end(); ++it) {
if (it->first == value.first) {
return {it, false}; // 键已存在,插入失败
}
}
bucket.push_back(value);
++elem_count_;
return {std::prev(bucket.end()), true};
}
iterator find(const K& key) {
size_t idx = bucket_index(key);
auto& bucket = buckets_[idx];
for (auto it = bucket.begin(); it != bucket.end(); ++it) {
if (it->first == key) {
return it;
}
}
return end();
}
const_iterator find(const K& key) const {
// 和上面逻辑一样,只是返回 const_iterator
size_t idx = bucket_index(key);
const auto& bucket = buckets_[idx];
for (auto it = bucket.begin(); it != bucket.end(); ++it) {
if (it->first == key) {
return it;
}
}
return cend();
}
这里有个细节,如果桶的大小为0——MyHashMap()默认初始化了16个桶,但如果你显式传0怎么办?需要在构造或bucket_index里处理:桶数为0时自动修正为至少1。实际工程中,标准库保证bucket_count() > 0。
还有一个值得注意的点:insert看起来先遍历链表确认是否已存在,再尾部插入,是O(链长)的操作。在平均情况下链长很短(负载因子控制在0.75),这个成本可接受。
5.4 erase和rehash的实现
cpp复制 size_t erase(const K& key) {
size_t idx = bucket_index(key);
auto& bucket = buckets_[idx];
for (auto it = bucket.begin(); it != bucket.end(); ++it) {
if (it->first == key) {
bucket.erase(it);
--elem_count_;
return 1;
}
}
return 0;
}
bool need_rehash() const {
return static_cast<float>(elem_count_) / buckets_.size() >= max_load_factor_;
}
void rehash(size_t new_bucket_count) {
if (new_bucket_count <= buckets_.size()) return;
std::vector<bucket_list> new_buckets(new_bucket_count);
for (auto& bucket : buckets_) {
for (auto& kv : bucket) {
size_t idx = hash_fn_(kv.first) % new_buckets.size();
new_buckets[idx].push_back(std::move(kv));
}
}
buckets_.swap(new_buckets);
}
rehash的逻辑就是把旧桶的所有元素取出来,对每个元素重新算桶下标,插入新桶。因为旧链表和新链表是独立的,所以可以用std::move转移元素,避免拷贝。这里push_back(std::move(kv))在C++11之后能正确调用移动构造,性能比拷贝好。
5.5 完整测试代码
cpp复制#include <iostream>
#include <string>
int main() {
MyHashMap<std::string, int> map;
map.insert({"alice", 25});
map.insert({"bob", 30});
map.insert({"charlie", 35});
auto it = map.find("alice");
if (it != map.end()) {
std::cout << "alice => " << it->second << "\n"; // 25
}
map.erase("bob");
if (map.find("bob") == map.end()) {
std::cout << "bob is gone\n";
}
std::cout << "size = " << map.size() << ", buckets = " << map.bucket_count() << "\n";
// 触发扩容
for (int i = 0; i < 100; ++i) {
map.insert({"user" + std::to_string(i), i});
}
std::cout << "size = " << map.size() << ", buckets = " << map.bucket_count() << "\n";
return 0;
}
这段代码如果跑起来,你会看到第一次输出alice => 25,bob被删掉,size和buckets会随着插入逐渐变化。整个流程覆盖了插入、查找、删除、扩容四个核心操作,你可以自己动手改一改,加深理解。
5.6 还可以怎么改进
这个实现已经能说明哈希表的核心原理,但离工程可用还有距离,几个可以改进的方向:
- 支持
operator[]:V& operator[](const K& key),不存在时自动插入默认值。 - 支持遍历接口:返回所有元素的begin/end迭代器,这时需要维护一个全局链表而不是每个桶一个链表,复杂度上升不少,但能让哈希表支持有序遍历之外的迭代。
- 支持自定义哈希函数模板参数:上面已经做了,
Hash是默认的std::hash<K>。 - 考虑哈希函数抛出异常的情况,用RAII保证异常安全。
这些改进标准库已经帮你做好了,实际工程中直接std::unordered_map就行。但自己实现一遍的价值在于,遇到性能问题、迭代器失效、hash冲突导致异常等场景时,你能更快定位问题根源。
6. 面试和工作中最常见的哈希表问题
6.1 为什么哈希表的查找时间复杂度是O(1)?
这是个高频面试题,但很多人答不好。核心逻辑链是这样的:在负载因子一定的前提下,哈希表的平均链长(或碰撞概率)是常数。插入n个元素、表容量m = n/α,则每个桶平均有α个元素,α是常数。查找时需要:先算哈希值O(1),然后在长度约为α的链表/探测序列里找目标O(α)=O(1)。所以平均时间复杂度是O(1)。
但必须强调"平均"两个字,最坏情况仍然是O(n)——所有键都冲突到一个桶里。这也是为什么哈希函数的质量这么重要,它直接决定了"平均情况"能否成立。
6.2 为什么负载因子默认是0.75?
网上流传的说法是"0.75是时间和空间的折中",这个说法对,但太笼统。具体来说,可以从两个角度理解。
从概率角度看,当负载因子α = 0.75时,一个随机键落在一个空槽位上的概率是1-α = 0.25,即插入一个新键时约1/4的概率会冲突。这个冲突概率不算高,但也不算太低,不会造成明显的性能退化。从空间角度看,如果α设为0.5,那么表里有一半空间是空着的,浪费明显;如果α设为0.9,冲突概率高,开放地址法的探测次数线性增长(刚讲过,α=0.9时平均探测5.5次),链地址法的长链表也影响缓存。0.75是多数实测下的一个不错平衡点。
顺带一提,Java的HashMap初始容量是16,默认加载因子0.75,扩容时翻倍——所以当元素达到12个时,HashMap会触发扩容。掌握这个数字,你在调优HashMap时就知道"为什么capacity要设成2的幂"这类问题了。
6.3 unordered_map的底层实现细节
C++标准库的std::unordered_map是链地址法实现,但它的桶容器不是简单的vector<list>,而是单向链表加节点数组的结构:每个桶元素保存"链表头指针",所有节点存储在连续或不连续的单向链表中。具体细节各标准库实现有差异,但共同点是一致的:键的hash值、取模定位、链表内线性查找、扩容时rehash、负载因子控制。
有一个容易被忽略的坑:unordered_map的迭代器顺序不稳定。它不是一个有序容器,遍历顺序取决于桶的数量和元素在桶内的分布,而桶数量会随rehash改变,所以不要依赖遍历顺序。如果有人跟你说"unordered_map遍历出来是插入顺序",那是碰巧,不是保证。
6.4 自定义类型做哈希键:一个必踩的坑
很多人在实际工作中遇到的最典型的哈希表问题是:我定义了一个struct,想用它作为unordered_map的键,结果编译报错,提示hash不是struct的成员。原因很简单:std::hash没有为你的自定义类型提供特化。
解决办法有两个:
- 给
std::hash写一个特化:
cpp复制struct Person {
std::string name;
int age;
bool operator==(const Person& other) const {
return name == other.name && age == other.age;
}
};
namespace std {
template <>
struct hash<Person> {
size_t operator()(const Person& p) const {
size_t h1 = hash<string>{}(p.name);
size_t h2 = hash<int>{}(p.age);
// 组合哈希值,注意别用简单的加法/乘法,容易碰撞
return h1 ^ (h2 << 1);
}
};
}
- 使用
unordered_map时显式传一个哈希函数对象:
cpp复制struct PersonHash {
size_t operator()(const Person& p) const {
size_t h1 = hash<string>{}(p.name);
size_t h2 = hash<int>{}(p.age);
return h1 ^ (h2 << 1);
}
};
unordered_map<Person, int, PersonHash> map;
组合哈希值有个经典套路:h1 ^ (h2 << 1)。这种左移再异或的方法能避免两个字段交换位置后哈希值不变的问题。还有更健壮的组合方式是把两个哈希值做乘法混合后再异或,但h1 ^ (h2 << 1)已经能覆盖绝大多数场景。
一个真实案例:我见过有人把多个字段的哈希值简单加起来做组合哈希,结果{"John", 25}和{"Johnn", 2}的哈希值是一样的(因为每个字符的ASCII码值差刚好抵消),导致大量冲突。组合哈希值不是简单加法,要考虑到字符串长度、字段顺序的影响。
6.5 哈希表的实际应用场景
哈希表是应用最广泛的数据结构之一,除了最常见的"根据键查值"场景,还有几个容易被忽视的使用方式:
- 去重:把数据全部插入哈希集合,利用"键不能重复"的性质,一趟完成去重,O(n)时间。
- 计数:键是元素,值是计数,遍历一遍,每个元素
map[key]++,O(n)完成频率统计。 - 缓存:用一个大小受限的哈希表存最近访问的数据,配合淘汰策略(如LRU),实现高效的缓存层。
- 索引:数据库的哈希索引、Redis的哈希类型底层都是这个结构。
- 空间换时间的典型:比如判断两个数组是否有交集,可以先把一个小数组放入哈希集合,遍历另一个数组时O(1)查匹配。
6.6 什么时候不该用哈希表
哈希表不是万能的,这些场景下它并不合适:
- 需要有序遍历:哈希表的顺序不稳定,
std::map或std::set更合适。 - 需要范围查询:比如"查所有价格在100到200之间的商品",哈希表做不了,树形结构(B树、跳表)擅长这个。
- 数据量极小:几十个元素的情况,线性查找比哈希查找更快,因为哈希函数计算本身还有成本,而线性查找的几次比较在CPU缓存里瞬间完成,哈希表反而因为计算hash、取模、跳内存而更慢。
- 极端要求最坏情况性能:如果你的服务不能容忍任何一次O(n)退化(比如实时系统),可能要考虑平衡树,它的最坏情况就是O(log n)。
这些边界场景判断在面试中也是加分项,能体现出你不仅仅是"背过哈希表",而是真的理解它适合什么问题。
7. 开放地址法手写版:用代码看看真正的删除逻辑
7.1 为什么单独写一份
刚才C++实战章节的链地址法版本逻辑清晰,但我在讲开放地址法时提到了"删除要用墓碑",这个知识点如果不写代码验证,光看文字很容易忘记,或者觉得"这不就是标记一下吗"。所以我再给你一份开放地址法的简化实现,用一组小代码演示墓碑是怎么解决删除问题的。
这个版本的键固定为int,值固定为int,容量固定,用线性探测。实际工程不会用这么简化的版本,但作为学习材料,它能让你把注意力全部集中在"探测"和"墓碑"这两件事上。
7.2 开放地址法最小实现
cpp复制#include <iostream>
#include <vector>
class OpenAddressingMap {
private:
enum class State { EMPTY, OCCUPIED, DELETED }; // 空、占用、墓碑
struct Entry {
int key = 0;
int value = 0;
State state = State::EMPTY;
};
std::vector<Entry> table_;
size_t elem_count_ = 0;
size_t tombstone_count_ = 0;
size_t hash(int key) const {
return static_cast<size_t>(key) % table_.size();
}
public:
OpenAddressingMap(size_t capacity = 16) : table_(capacity) {}
bool insert(int key, int value) {
if ((elem_count_ + tombstone_count_) * 10 >= table_.size() * 7) {
rehash(table_.size() * 2);
}
size_t idx = hash(key);
size_t first_tombstone = table_.size(); // 记录第一个可复用的墓碑
for (size_t i = 0; i < table_.size(); ++i) {
size_t pos = (idx + i) % table_.size(); // 线性探测
if (table_[pos].state == State::EMPTY) {
// 优先复用墓碑位置
size_t target = (first_tombstone < table_.size()) ? first_tombstone : pos;
table_[target] = {key, value, State::OCCUPIED};
++elem_count_;
return true;
}
if (table_[pos].state == State::OCCUPIED && table_[pos].key == key) {
table_[pos].value = value; // 键已存在,更新值
return true;
}
if (table_[pos].state == State::DELETED && first_tombstone == table_.size()) {
first_tombstone = pos;
}
}
return false; // 表满,但 rehash 逻辑保证这里不会发生
}
bool find(int key, int& value_out) const {
size_t idx = hash(key);
for (size_t i = 0; i < table_.size(); ++i) {
size_t pos = (idx + i) % table_.size();
if (table_[pos].state == State::EMPTY) {
return false; // 遇空即停
}
if (table_[pos].state == State::OCCUPIED && table_[pos].key == key) {
value_out = table_[pos].value;
return true;
}
// DELETED 继续往后探测,不能停
}
return false;
}
bool erase(int key) {
size_t idx = hash(key);
for (size_t i = 0; i < table_.size(); ++i) {
size_t pos = (idx + i) % table_.size();
if (table_[pos].state == State::EMPTY) {
return false; // 遇空即不存在
}
if (table_[pos].state == State::OCCUPIED && table_[pos].key == key) {
table_[pos].state = State::DELETED; // 打上墓碑标记
--elem_count_;
++tombstone_count_;
return true;
}
}
return false;
}
void rehash(size_t new_capacity) {
std::vector<Entry> old_table = std::move(table_);
table_.assign(new_capacity, Entry());
elem_count_ = 0;
tombstone_count_ = 0;
for (const auto& entry : old_table) {
if (entry.state == State::OCCUPIED) {
insert(entry.key, entry.value); // 重新计算位置
}
}
}
void print() const {
for (size_t i = 0; i < table_.size(); ++i) {
switch (table_[i].state) {
case State::EMPTY: std::cout << "[" << i << "] _\n"; break;
case State::DELETED: std::cout << "[" << i << "] X (tombstone)\n"; break;
case State::OCCUPIED: std::cout << "[" << i << "] " << table_[i].key << " => " << table_[i].value << "\n"; break;
}
}
}
};
7.3 用一个具体例子看查找为什么不能停
我构造一个复现"删除后查找失败"的例子:
cpp复制int main() {
OpenAddressingMap map(7);
map.insert(10, 100); // 10 % 7 = 3,放入槽3
map.insert(17, 200); // 17 % 7 = 3,冲突,线性探测到槽4
map.insert(24, 300); // 24 % 7 = 3,冲突,继续探测到槽5
map.erase(17); // 槽4变成墓碑
int value = 0;
bool ok = map.find(24, value); // 从槽3开始探测
// 槽3=10,不匹配,继续
// 槽4=墓碑,继续(如果没有墓碑标记,这里会停,24就找不到了)
// 槽5=24,匹配成功
std::cout << "find(24) => " << (ok ? std::to_string(value) : "not found") << "\n";
return 0;
}
如果删除17时直接把槽4置为EMPTY,查找24时走到槽4遇到空位就返回"不存在",但24实际在槽5。这个例子能让你直观理解为什么开放地址法的删除必须用墓碑。
你还可以把rehash里对墓碑的处理和insert里优先复用墓碑位置的逻辑连起来想:墓碑占用的位置在插入时可以被复用,但查找时不能提前终止,这就是开放地址法里"删除和插入不是对偶操作"的本质原因。链地址法没有这个问题,它的删除和插入是对称的——删掉就是真的从这个空间消失了。
8. 留给自己的经验总结
哈希表是少数几个"看着简单、用得好难"的数据结构。我这些年写代码,遇到过几种典型的哈希表问题:第一次是用字符串做键但忘了大小写归一化,导致"A"和"a"是两个键,查了半天查不到;第二次是自定义类型做键忘了写operator==,编译倒是过了,但查找永远不匹配;第三次是自己写了个简单的哈希函数做分布测试,发现数据全挤在几个桶里,性能惨不忍睹。这些坑都不是哈希表本身的错,而是对它的机制理解不到位。
如果你想深入验证自己对哈希表的理解,我建议做这几个小实验:一是给链地址法哈希表设置不同的负载因子阈值,插入100万条数据,对比不同阈值下查找的平均耗时;二是给开放地址法哈希表插入大量数据再删除一半,观察墓碑数量对查找性能的影响;三是写一个"故意制造冲突"的哈希函数,看看哈希表性能如何从O(1)退化到O(n)。做完这三个实验,你对哈希表的理解绝对超过大多数背了八股文的面试者。
哈希表很容易让人觉得"会用了就懂了",但它其实和排序算法、树结构一样,值得你花时间把底层机制吃透。希望这篇从原理到实战的拆解能帮你把哈希表真正"搞定",不只是会用,还能在面试、工作、性能调优中游刃有余。
