STL 哈希表原理与模拟实现
在深入 unordered_map 和 unordered_set 时,我们了解到它们拥有 O(1) 的查找、插入和删除效率。这种高效性能的背后,是哈希表(Hash Table)这一数据结构在支撑。今天我们就来拆解一下哈希表的底层原理,并尝试用 C++ 模拟实现它。
一、哈希概念
哈希(Hash),又称散列,是一种将关键字映射到存储位置的数据组织方式。简单来说,就是通过一个哈希函数,把 Key 和数组下标建立映射关系。查找时,直接计算 Key 对应的下标即可定位数据,从而实现快速访问。
注意:虽然'哈希'是常用译名,但'散列'更能体现数据分布的样貌。在实际开发中,两者通用。
二、直接定址法
如果关键字的范围比较集中,最直接的方法就是直接定址法。比如关键字都在 [0, 99] 之间,我们可以开一个大小为 100 的数组,关键字的值直接作为下标。
这种方法直观且高效,增删查都是 O(1)。但它有个致命缺陷:只适用于整型且范围集中的情况。对于浮点数、字符串或者范围巨大的整数,直接定址法会浪费大量内存甚至无法实现。
三、哈希冲突
当使用哈希函数将不同的 Key 映射到同一个位置时,就发生了哈希冲突(Collision)。理想情况下我们希望避免冲突,但在实际场景中,冲突是不可避免的。因此,设计优秀的哈希函数以减少冲突频率,以及设计合理的冲突解决策略,是哈希表的核心。
四、负载因子
负载因子(Load Factor)反映了哈希表的填充程度。计算公式为:
[ \text{负载因子} = N / M ]
其中 N 是已存储元素个数,M 是哈希表大小。负载因子越大,冲突概率越高,空间利用率也越高;反之则相反。通常我们需要控制负载因子在一定范围内,以保证性能。
五、将关键词转为整数
哈希函数通常基于整数运算。如果 Key 不是整数(如 string),我们需要先将其转换为整数。例如,可以将字符串每个字符的 ASCII 码参与运算,生成一个唯一的哈希值。后续讨论中,除非特别说明,Key 均指转换后的整数值。
六、哈希函数
一个好的哈希函数应让关键字均匀分布。常见的构造方法有以下几种:
6.1 除法散列法
最常用的是除留余数法:h(key) = key % M。
关键点:M 最好取一个不太接近 2 的整数次幂的质数。如果 M 是 2 的幂,key % M 相当于保留低 x 位,容易导致低位相同的 Key 发生冲突。同理,也应避免 M 为 10 的幂。
6.2 乘法散列法
对 M 没有特殊要求。思路是先计算 key * A 的小数部分,再乘以 M 并向下取整:
[ h(key) = \lfloor M \times ((A \times key) % 1.0) \rfloor ]
其中 A 通常取黄金分割比 0.618...。这种方法对 M 的选择更灵活。
6.3 全域散列法
为了防止恶意构造数据导致严重冲突,可以引入随机性。每次初始化哈希表时,从一组散列函数中随机选择一个。这样攻击者无法预知具体的映射规则。
七、处理哈希冲突
实践中主要有两种解决冲突的策略:开放定址法和链地址法。
7.1 开放定址法
所有元素都存储在哈希表中。当发生冲突时,按某种规则寻找下一个空位。负载因子必须小于 1。
(1)线性探测
从冲突位置开始,依次向后探测,直到找到空位。公式为:hc(key, i) = (hash0 + i) % M。
缺点容易产生'聚集'现象,即连续冲突的位置会吸引更多新数据,导致后续查找变慢。


