文章目录
- [C++ 哈希表深度解析:删除为什么必须打标记,负载因子为什么卡 0.7](#C++ 哈希表深度解析:删除为什么必须打标记,负载因子为什么卡 0.7)
-
- [一、哈希的本质:用 key 算位置,冲突是常态](#一、哈希的本质:用 key 算位置,冲突是常态)
-
- [1.1 直接定址法:key 范围集中时最香](#1.1 直接定址法:key 范围集中时最香)
- [1.2 哈希冲突:映射到 [0, M) 就躲不开](#1.2 哈希冲突:映射到 [0, M) 就躲不开)
- [1.3 负载因子:哈希表的"拥挤度"](#1.3 负载因子:哈希表的"拥挤度")
- [二、哈希函数:把 key 均匀撒开](#二、哈希函数:把 key 均匀撒开)
-
- [2.1 除法散列法:key % M,M 的取值是学问](#2.1 除法散列法:key % M,M 的取值是学问)
- [2.2 乘法散列法和全域散列法(了解)](#2.2 乘法散列法和全域散列法(了解))
- [2.3 key 不是整形怎么办](#2.3 key 不是整形怎么办)
- 三、开放定址法:所有数据都放在表里
-
- [3.1 三种探测规则](#3.1 三种探测规则)
- [3.2 数字实例:同一组 key,三种探测怎么落座](#3.2 数字实例:同一组 key,三种探测怎么落座)
- [3.3 删除为什么必须打标记](#3.3 删除为什么必须打标记)
- [3.4 开放定址法完整实现](#3.4 开放定址法完整实现)
- 四、链地址法:冲突的数据挂成链表
-
- [4.1 思路:表里存指针,冲突挂链](#4.1 思路:表里存指针,冲突挂链)
- [4.2 完整实现](#4.2 完整实现)
- [4.3 极端场景:某个桶特别长怎么办](#4.3 极端场景:某个桶特别长怎么办)
- 五、两种实现怎么选:数学说了算
-
- [5.1 探测次数的定量分析](#5.1 探测次数的定量分析)
- [5.2 全维度对比:决策总表](#5.2 全维度对比:决策总表)
- [5.3 STL 的选择:从源码看佐证](#5.3 STL 的选择:从源码看佐证)
- 六、实战
- 七、总结
C++ 哈希表深度解析:删除为什么必须打标记,负载因子为什么卡 0.7
unordered_map 的查找是 O(1),这件事人人都知道。面试往下问一层就露馅:删除为什么不能直接把位置清空?负载因子为什么开放定址法卡在 0.7,链地址法却放到 1? 这两个问题答不上来,说明哈希表只学了个接口。
哈希表的难点从来不是查找------查找就是一次取模加若干次比较。难的是冲突发生之后怎么处理。处理冲突有两条技术路线:开放定址法和链地址法,两条路的删除策略完全不同,性能天花板也差得极远。从 key % M 出发,把两条路的原理、坑、完整实现一路拆到底。
一、哈希的本质:用 key 算位置,冲突是常态
1.1 直接定址法:key 范围集中时最香
哈希(hash)又称散列,本质一句话:通过哈希函数把 key 和存储位置建立映射。存的时候用 key 算出位置放进去,查的时候用同一个 key 算出同一个位置取出来。
最朴素的映射是直接定址法------key 的值本身就是下标:
- key 都在
[0, 99]:开一个 100 元素的数组,key 直接当下标; - key 都是小写字母:开一个 26 元素的数组,
key - 'a'当下标。
LeetCode 387「字符串中的第一个唯一字符」就是这个套路:
cpp
int firstUniqChar(string s)
{
int count[26] = {0}; // 字母的 ascii - 'a' 直接当下标:key 范围集中,无需处理冲突
for (auto ch : s)
count[ch - 'a']++;
for (size_t i = 0; i < s.size(); ++i)
if (count[s[i] - 'a'] == 1)
return i;
return -1;
}
1.2 哈希冲突:映射到 [0, M) 就躲不开
直接定址法的死穴是 key 一分散就废:key 范围 [0, 9999] 但实际只有 N 个值,直接定址要开一万个空间,大部分空着,浪费甚至不够用。
于是引入哈希函数 hf:把 key 映射到 [0, M) 范围(M 是表大小,一般 M ≥ N)。问题随之而来------两个不同的 key 可能算出同一个位置,这就是哈希冲突(碰撞)。
key 的取值空间往往远大于 M,任何确定的散列函数都必然存在大量碰撞的 key 对。冲突不是函数没写好,是数学上绕不开的。所以哈希表要同时解决两件事:设计好函数减少冲突 + 设计好方案处理冲突。前者是第二节,后者是第三、四节的主角。
1.3 负载因子:哈希表的"拥挤度"
表里存了 N 个值、表大小是 M,那么:
负载因子 = N / M
它衡量表挤不挤,背后是空间和时间的权衡:
| 负载因子 | 冲突概率 | 空间利用率 |
|---|---|---|
| 越大 | 越高 | 越高 |
| 越小 | 越低 | 越低 |
这个比值直接决定两种实现的天花板:开放定址法要求它必须小于 1(实际约 0.7),链地址法可以大于 1(实际约 1)。为什么差这么多?第五节用探测次数公式算给你看。
二、哈希函数:把 key 均匀撒开
设计目标是让 N 个 key 等概率、均匀地散列到 M 个空间。实际做不到完美,但方向是明确的。
2.1 除法散列法:key % M,M 的取值是学问
最常用的方法是除留余数法:h(key) = key % M。
第一个关键细节:M 不要取 2 的幂、10 的幂。 M = 2^X 时,key % 2^X 本质就是保留 key 二进制的后 X 位------后 X 位相同的 key,哈希值全一样。
{63, 31} 两个看起来毫无关联的值,M = 16(2^4)时:63 的二进制后 4 位是 1111,31 的后 4 位也是 1111,双双算出 15,撞了。M = 100(10^2)时更直白:{112, 12312} 保留十进制后两位,都是 12。
所以除法散列法建议 M 取一个不太接近 2 的幂的质数。 质数没有"规整"的因子,key 里按二进制位、按十进制位对齐的规律全部被打乱,分布才均匀。
实践里抓本质、不死读书。Java 的 HashMap 就用 2 的幂做 M,图的是位运算比取模快。但它不是单纯取低位,而是先
key' = key >> 16,再用key ^ key'作为哈希值------让 key 的所有位都参与计算,弥补"只取低位"的缺陷。两条路目标一致:均匀。
2.2 乘法散列法和全域散列法(了解)
乘法散列法对 M 没要求,两步:key 乘常数 A(0 < A < 1)取小数部分,再用 M 乘这个小数向下取整:
cpp
h(key) = floor(M × ((A × key) % 1.0))
关键是 A 的取值,Knuth 认为黄金分割点 A = (√5 - 1) / 2 ≈ 0.618 效果好。
全域散列法防的是另一类攻击:散列函数公开且确定,恶意对手可以故意构造一批全撞同一个位置的数据,让哈希表退化成 O(N)。解法是给函数加随机性------构造一个函数族,初始化时随机选一个,之后固定用它:
cpp
h(key) = ((a × key + b) % P) % M
P 取足够大的质数,a 随机取 [1, P-1],b 随机取 [0, P-1]。"初始化选一次、之后固定"是铁律------插入用一个函数、查找换一个,插进去的 key 就永远找不到了。
2.3 key 不是整形怎么办
key 是 string、Date 这类类型时不能取模,需要给哈希表加一个仿函数,把 key 转成可取模的整形。要求是:key 的每个部分都参与计算,不同 key 转出的整数尽量不同。
string 做 key 极常见,特化一个。直观想法是把字符 ascii 相加------不行,"abcd" 和 "bcad" 转出来一模一样,字符的位置丢了。用 BKDR 哈希的思路:用上次的结果乘以一个质数(31、131 常见)再加新字符,位置信息就参与了计算:
cpp
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key; // 能转整形的类型直接转
}
};
// string 特化:BKDR 哈希
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto e : key)
{
hash *= 131; // 乘质数让字符位置参与:交换字符顺序,结果就不同
hash += e;
}
return hash;
}
};
三、开放定址法:所有数据都放在表里
3.1 三种探测规则
开放定址法的思想:所有元素都放在哈希表内。key 算出的位置冲突了,就按某种规则探测下一个空位。
线性探测:从冲突位置依次向后探测,走到表尾回绕到表头。因为负载因子小于 1,表里必然存在空位,最多探测 M-1 次一定能找到。
cpp
hc(key, i) = (hash0 + i) % M,i = {1, 2, 3, ..., M-1}
二次探测:从冲突位置开始,按二次方左右跳跃探测,往右到头回绕、往左到头也回绕。
cpp
hc(key, i) = (hash0 ± i²) % M
二次探测的探测序列(hash0 ± i²)最多覆盖约一半的表位------要保证一定能探到空位,负载因子得压到 0.5 以下,这是它在实践中更少被用的原因之一。
双重散列:第一个函数冲突了,用第二个函数算一个和 key 相关的偏移量,步长随 key 变化。
cpp
hc(key, i) = (hash0 + i × h2(key)) % M
双重散列要求 h2(key) 与 M 互质,数学原因很硬:若 p = gcd(M, h2(key)) > 1,探测序列只能寻址 M/p 个位置,大半个表摸不到。举例:初始位置 1、偏移量 3、M = 12,只能落在 {1, 4, 7, 10}------因为 gcd(12, 3) = 3,12/3 = 4,12 个位置只有 4 个能被访问。保证互质有两种简单取值:M 是 2 的幂时,h2 任取一个奇数;M 是质数时,h2(key) = key % (M-1) + 1。
三种探测规则的决策总表:
| 探测方式 | 步长 | 堆积问题 | 实现难度 | 实践采用 |
|---|---|---|---|---|
| 线性探测 | +1 | 严重:连续占用区只增不减 | 最简单 | 教学/简单场景 |
| 二次探测 | ±i² | 较轻:步长递增,跳过占用区 | 中(负载因子要压到 0.5) | 较少 |
| 双重散列 | i × h2(key) | 最轻:步长随 key 变化 | 中(需保证与 M 互质) | 较少 |
开放定址法整体在实践里就不如链地址法用得多(数据全挤在一个表里互相影响),所以线性探测足够,本文实现也选它。
3.2 数字实例:同一组 key,三种探测怎么落座
光看公式没感觉,走三遍真实数据。
线性探测 :{19, 30, 5, 36, 13, 20, 21, 12},M = 11。
| key | hash0 | 探测过程 | 落点 |
|---|---|---|---|
| 19 | 8 | 直接放 | 8 |
| 30 | 8 | 8 冲突 → 探测 9 | 9 |
| 5 | 5 | 直接放 | 5 |
| 36 | 3 | 直接放 | 3 |
| 13 | 2 | 直接放 | 2 |
| 20 | 9 | 9 冲突 → 探测 10 | 10 |
| 21 | 10 | 10 冲突 → 回绕到 0 | 0 |
| 12 | 1 | 直接放 | 1 |
插完之后表的状态:[21, 12, 13, 36, 空, 5, 空, 空, 19, 30, 20]。
注意 8、9、10 三个位置连续占用------这就是堆积。往后任何 hash0 落进 8、9、10 的 key,探测时都会扎进这个连续区并把它继续拉长。集群像滚雪球,越滚越大。二次探测就是来治这个的。
二次探测 :{19, 30, 52, 63, 11, 22},M = 11。这组数据故意让前四个全撞 8(19%11 = 30%11 = 52%11 = 63%11 = 8):
| key | hash0 | 探测过程 | 落点 |
|---|---|---|---|
| 19 | 8 | 直接放 | 8 |
| 30 | 8 | i=1:8+1 = 9 | 9 |
| 52 | 8 | i=1:9 已占;i=2:8+4 = 12,12%11 = 1 | 1 |
| 63 | 8 | i=1:9 已占;i=2:1 已占;i=3:8+9 = 17,17%11 = 6 | 6 |
| 11 | 0 | 直接放 | 0 |
| 22 | 0 | i=1:1 已占;i=2:0+4 = 4 | 4 |
四个全撞 8 的 key 被撒到 8、9、1、6 四个方向。同样的数据用线性探测,会把 8、9、10 连成一片------步长递增跳过了已占用区,这就是二次探测缓解堆积的原理。
双重散列 :{19, 30, 52, 74},M = 11,设 h2(key) = key % 10 + 1。前三个照样全撞 8:
| key | h2 | 探测过程 | 落点 |
|---|---|---|---|
| 19 | 10 | 直接放 | 8 |
| 30 | 1 | i=1:8+1×1 = 9 | 9 |
| 52 | 3 | i=1:9 已占;i=2:8+2×3 = 14,14%11 = 3 | 3 |
| 74 | 5 | i=1:9 已占;i=2:3 已占;i=3:8+3×5 = 23,23%11 = 1 | 1 |
步长随 key 变化(10、1、3、5),冲突的 key 各走各的路,分布最均匀。
3.3 删除为什么必须打标记
开放定址法删除不能把位置清空,这是它和链地址法最本质的差异,也是面试高频题。用一个场景推演一遍。
h(19) = 8,h(30) = 8,19 先占到位置 8,30 冲突后线性探测到 9。又来一个 20,h(20) = 9,9 被 30 占着,继续探测到 10。此时表里:
位置: 8 9 10
数据: 19 30 20
删除 30。如果直接把位置 9 清空成 EMPTY,会发生什么?查找 20 时:从 hash0 = 9 出发,一看位置是空的,停,返回"不存在"。但 20 明明在 10 待着。
根因:开放定址法里,一个 key 的实际位置可能离它的 hash0 很远,中间隔着的是因为冲突被挤到同一条路上的 key。清掉其中一个,就把后来者的查找路径斩断了。
解法是给每个位置加状态标记:
cpp
enum State
{
EXIST, // 有数据
EMPTY, // 从来没有任何 key 来过
DELETE // 来过,但走了
};
删除时不清数据,只把状态改成 DELETE。查找的循环条件因此必须是遇到 EMPTY 才停:
cpp
while (_tables[hashi]._state != EMPTY) // DELETE 不能停:后面可能还挂着被挤走的 key
{
if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key)
return &_tables[hashi];
hashi = (hash0 + i) % _tables.size();
++i;
}
写成 == EXIST 就前功尽弃------遇到 DELETE 也停,等于变相清空。
EMPTY 和 DELETE 的本质区别:EMPTY 表示从没有任何 key 的探测路径经过这里,所以再往后不可能有 key;DELETE 表示曾经有过。查找只信 EMPTY,不信 DELETE。
Insert 遇到 DELETE 可以直接占用(循环条件写 == EXIST,遇到 DELETE 停)。这不破坏正确性:新 key 占了这个坑,查找它的路径走到这里命中;查找别的 key 走到这里,状态是 EXIST 且值不等,继续往后------和占用一个全新空位没有任何区别。
3.4 开放定址法完整实现
负载因子控制在 0.7,超过就扩容;扩容后表大小必须还是质数,用 SGI-STL 那张近似 2 倍关系的质数表:
cpp
namespace open_address
{
enum State { EXIST, EMPTY, DELETE };
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
// SGI-STL 同款:28 个近似 2 倍关系的质数,扩容后桶数从表里取
inline unsigned long __stl_next_prime(unsigned long n)
{
static const unsigned long prime_list[28] =
{
53, 97, 193, 389, 769, 1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433, 1572869, 3145739,
6291469, 12582917, 25165843, 50331653, 100663319,
201326611, 402653189, 805306457, 1610612741, 3221225473,
4294967291
};
for (unsigned long p : prime_list)
if (p >= n) return p;
return prime_list[27];
}
HashTable()
{
_tables.resize(__stl_next_prime(0)); // 初始 53 个位置
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first)) return false; // 查重:开放定址法不支持重复 key
// 负载因子 > 0.7 就扩容。必须交叉相乘:
// 直接写 _n / _tables.size() >= 0.7 是错的------两边都是整形,
// 整数除法在 _n < size 时恒为 0,条件永远不成立,表永远不扩容
if (_n * 10 / _tables.size() >= 7)
{
HashTable<K, V, Hash> newHT;
newHT._tables.resize(__stl_next_prime(_tables.size() + 1));
for (size_t i = 0; i < _tables.size(); i++)
if (_tables[i]._state == EXIST)
newHT.Insert(_tables[i]._kv); // 全部重新映射到新表
_tables.swap(newHT._tables);
// 扩容后负载因子直接腰斩(0.7 → 0.35),不会连续触发下一次扩容
}
Hash hash;
// 必须在扩容之后计算:表大小变了,取模的分母变了,扩容前算的 hash0 全部作废
size_t hash0 = hash(kv.first) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
while (_tables[hashi]._state == EXIST) // 遇到 DELETE 直接占用
{
hashi = (hash0 + i) % _tables.size(); // 线性探测
// 二次探测就变成 +- i*i
++i;
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
HashData<K, V>* Find(const K& key)
{
Hash hash;
size_t hash0 = hash(key) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
while (_tables[hashi]._state != EMPTY) // 只有 EMPTY 能终止查找
{
if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key)
return &_tables[hashi];
hashi = (hash0 + i) % _tables.size();
++i;
}
return nullptr;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret == nullptr) return false;
ret->_state = DELETE; // 假删除:只改状态,数据留着挡住查找路径
--_n;
return true;
}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0;
};
}
三个容易写错的点,全是"为什么"级别:
- 负载因子的整数陷阱 。
_n * 10 / _tables.size() >= 7是_n / size >= 0.7的交叉相乘形式。两边都是size_t,直接和 0.7 比较,整数除法在_n < size时恒等于 0,0 >= 0.7永远不成立------表永远不扩容,冲突淹到天上。 - hash0 必须在扩容之后算。取模的分母变了,扩容前算的 hash0 在新表里全是错的。代码里扩容块放在计算 hash0 之前,顺序不能倒。
- Find 的循环条件是
!= EMPTY,Insert 的是== EXIST。两个条件故意不对称:查找要穿过 DELETE 继续走,插入要停在 DELETE 占坑。写成一样就错。
四、链地址法:冲突的数据挂成链表
4.1 思路:表里存指针,冲突挂链
开放定址法的先天问题是所有数据挤在一个表里,删一个都要打标记、互相影响。链地址法(拉链法、哈希桶)换思路:哈希表里不再存数据,存指针。没有 key 映射到的位置,指针为空;多个 key 映射到同一个位置,挂成链表吊在桶下。
链地址法的负载因子没有小于 1 的限制------冲突再多不过是链表长一点。STL 的 unordered_xxx 把最大负载因子控制在 1,超过就扩容,本文实现同样。
数字实例:{19, 30, 5, 36, 13, 20, 21, 12, 24, 96},M = 11。注意 96 % 11 = 8,和 19、30 同桶:
| 桶号 | 链表(头 → 尾) |
|---|---|
| 1 | 12 |
| 2 | 24 → 13 |
| 3 | 36 |
| 5 | 5 |
| 8 | 96 → 30 → 19 |
| 9 | 20 |
| 10 | 21 |
8 号桶挂了 3 个节点,查找成本就是一次取模加三次比较------冲突的代价被关在单个桶里,不波及其他桶。这是两种方法气质的分水岭。
4.2 完整实现
cpp
namespace hash_bucket
{
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) {}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
HashTable()
{
// __stl_next_prime 与开放定址版相同(28 个质数的表),此处从略
_tables.resize(__stl_next_prime(0), nullptr);
}
~HashTable()
{
// 数据在堆上,表只是指针数组,必须逐桶释放
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next; // 先存 next 再 delete,否则解引用已释放的内存
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
bool Insert(const pair<K, V>& kv)
{
Hash hs;
// 负载因子 == 1 就扩容。扩容必须赶在算桶号之前:
// swap 之后表大小变了,之前算的 hashi 在新表里就是错的桶
if (_n == _tables.size())
{
vector<Node*> newtables(__stl_next_prime(_tables.size() + 1), nullptr);
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
size_t hashi = hs(cur->_kv.first) % newtables.size();
// 直接搬旧节点到新桶:改 _next 指向,每个节点 O(1)
// 新建节点再插入的写法要拷贝构造加析构,纯浪费
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newtables);
}
size_t hashi = hs(kv.first) % _tables.size(); // 扩容之后才算桶号
// 查重:链地址法不查重就会把同一个 key 挂成两条
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == kv.first) return false;
cur = cur->_next;
}
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi]; // 头插 O(1)
_tables[hashi] = newnode;
++_n;
return true;
}
Node* Find(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur) // 走桶内链表
{
if (cur->_kv.first == key) return cur;
cur = cur->_next;
}
return nullptr;
}
bool Erase(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* prev = nullptr; // 双指针摘节点
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
if (prev == nullptr) // 删的是桶头
_tables[hashi] = cur->_next;
else // 删的是中间节点
prev->_next = cur->_next;
delete cur; // 真删除:不用打标记
--_n;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
private:
vector<Node*> _tables; // 指针数组:每个元素是一个桶的链表头
size_t _n = 0;
};
}
和开放定址法对照着看,三处设计差异全是"为什么":
- 删除是真删除。链地址法里 key 的桶号只由 hash0 决定,删掉节点不影响任何其他 key 的查找路径------不存在"斩断路径"的问题,自然不需要 DELETE 标记。
- 扩容搬节点而不是建节点 。旧链表的节点直接改
_next指向挂到新桶,每个节点 O(1)。若走"新建节点 + 插入"的老路,每个节点要拷贝构造再析构,纯浪费。 - 桶号必须在扩容之后算 。这是手写链地址法最高频的 bug:
hashi在扩容前算好,swap之后表大小变了,节点进错桶,随后 Find 必失败。小数据测不出来------压根不触发扩容;数据一多,第一次扩容就挂。开放定址版把取模放在扩容之后,链地址版必须一样。
4.3 极端场景:某个桶特别长怎么办
偶然情况下某个桶很长(不是被攻击),查找退化成遍历长链表。Java 8 的 HashMap 的做法是桶长度超过阈值(8)就把链表转成红黑树,查找回到 O(log 桶长)。正常情况下不断扩容,单桶过长的概率很低,本文实现不搞这么复杂,思路了解即可。真被恶意构造的数据针对,上全域散列法,让攻击者摸不着规律。
五、两种实现怎么选:数学说了算
5.1 探测次数的定量分析
两种方法的负载因子阈值(0.7 vs 1)不是拍脑袋定的,是探测次数公式算出来的。均匀散列模型下的结果(Knuth,《计算机程序设计艺术》卷三):
| 负载因子 α | 线性探测·成功 | 线性探测·失败 | 链地址·成功 | 链地址·失败 |
|---|---|---|---|---|
| 0.5 | 1.50 | 2.5 | 1.25 | 0.5 |
| 0.7 | 2.17 | 6.06 | 1.35 | 0.7 |
| 0.8 | 3.00 | 13.0 | 1.40 | 0.8 |
| 0.9 | 5.50 | 50.5 | 1.45 | 0.9 |
公式本身:
- 线性探测:成功约
½(1 + 1/(1-α)),失败约½(1 + 1/(1-α)²) - 链地址:成功约
1 + α/2,失败约α
盯住线性探测的失败列:α 从 0.7 到 0.9,探测次数从 6 飙到 50------分母 (1-α)² 在 α 逼近 1 时二次爆炸。0.7 正是失败次数还没失控、空间浪费(三成)还能接受的拐点。再往上,每次扩容间隔缩短的速度,赶不上探测次数暴涨的速度。
链地址那一列平得像直线:失败次数就是 α 本身,线性增长。α = 1 时失败也只要 1 次------这就是链地址法敢把负载因子放到 1 的底气。
一句话概括这张表的形状:线性探测那两列是二次曲线,α 越接近 1 越陡;链地址那两列近似直线。开放定址法的性能对负载因子平方级敏感,链地址法线性敏感------两种实现负载因子阈值差这么多的全部原因就在这。
5.2 全维度对比:决策总表
| 维度 | 开放定址法 | 链地址法(哈希桶) |
|---|---|---|
| 数据位置 | 全在表里 | 表存指针,数据在堆上节点 |
| 负载因子 | 必须 < 1(实际 0.7) | 可 > 1(实际 1) |
| 冲突处理 | 线性/二次/双重探测 | 桶内挂链表 |
| 删除 | 假删除(打 DELETE 标记) | 真删除(摘节点) |
| 缓存友好性 | 好(数据连续存数组) | 差(节点散落堆上) |
| 最坏情况 | 堆积 → 探测链很长 | 单桶过长 → 退化成链表 |
| 扩容代价 | 全量重新散列 | 搬节点(改指向) |
| 性能对负载因子敏感度 | 平方级(见 5.1 表) | 线性 |
| 工程采用 | 较少 | STL unordered_xxx 用这个 |
工程一边倒选链地址法:不存在数据互相挤占、删除干净利索、负载因子上限宽松、扩容只改指向。开放定址法胜在缓存友好,适合规模可预估、冲突少的场景。
5.3 STL 的选择:从源码看佐证
SGI-STL 的 hashtable 就是链地址法的工业级形态,把 4.2 的实现换成标准库包装:
- 桶数组 :
_M_buckets,行为等同vector<Node*>,桶数永远取自那张 28 个质数的表; - 节点 :
_Hashtable_node只带一个_M_next,单链表------所以 unordered 容器的迭代器是单向的,没有--; - 扩容策略:元素数超过桶数(负载因子 > 1)就扩容,新桶数取质数表中的下一个;
- 字符串特化 :
hash<string>同样是乘积累加的思路,和 2.3 的 BKDR 同源。
结构决定行为:unordered_map 遍历无序(位置由哈希值定)、迭代器单向(节点只有 _M_next)、rehash 之后迭代器失效(桶数组整块换掉)------这些接口层面的"反直觉",底层一看就明白。在这套结构上怎么包出 map 和 set 两个容器,是泛型封装层面的问题,用的还是 KeyOfT 仿函数那套手法。
六、实战
直接定址法:LeetCode 387「字符串中的第一个唯一字符」(代码见 1.1)。key 的范围(26 个字母)比数据量还集中,直接定址就是最优解,上哈希表反而重。
自己实现的哈希表 :词频统计,topK 的前置步骤。用 4.2 的 hash_bucket::HashTable<string, int>:
cpp
hash_bucket::HashTable<string, int> ht;
for (auto& word : words) // words 是切分好的单词序列
{
if (ht.Find(word))
ht.Find(word)->_kv.second++; // 已存在,计数加一
else
ht.Insert(make_pair(word, 1)); // 不存在,插入计数 1
}
Find 返回的是节点指针,直接改 _kv.second 就是改值------均摊 O(1) 完成"字符串到次数"的映射。同样的需求用 map 也能做,但那是 O(logN),数据量上来就是数量级差距。
七、总结
| 问题 | 一句话答案 |
|---|---|
| 哈希的本质 | key 经哈希函数算出存储位置,冲突不可避免 |
| 负载因子 | N/M;空间与时间的权衡,直接决定两种实现的阈值 |
| M 为什么取质数 | 打乱 key 按位、按十进制位对齐的规律,分布才均匀 |
| 线性探测的问题 | 堆积:连续占用区只增不减,新 key 扎堆涌入 |
| 删除为什么打标记 | 清空会斩断被挤走的 key 的查找路径;只有 EMPTY 能终止查找 |
| 负载因子 0.7 vs 1 | 线性探测对 α 平方级敏感,链地址线性敏感(见 5.1 表) |
| 为什么工程选链地址法 | 不互相挤占、删除干净、扩容改指向、上限宽松 |
| string 怎么做 key | BKDR:乘 131 累加,让字符位置参与计算 |
全文压成五条:
- 哈希表的两大件:好哈希函数减少冲突 (M 取质数、key 每个部分都参与计算)加冲突解决方案(开放定址 / 链地址)。
- 开放定址法的灵魂是状态标记:EMPTY 才能终止查找,DELETE 只能路过 。Find 的循环写成
== EXIST是最常见的错。 - 链地址法把冲突关在单个桶里,删除真删、扩容搬节点,是工程和 STL 的共同选择。
- 阈值不是玄学:探测次数公式算出来,0.7 是开放定址的拐点,1 是链地址的舒适区。
- 原理和两种实现吃透之后,"怎么封装成 unordered_map/set"是再往上的一层------KeyOfT 仿函数、迭代器跨桶、operator\[\],解决的问题和本文不同,但地基都在这两套实现上。