哈希表实现
1. 哈希概念
哈希(Hash)又称散列 ,是一种组织数据的方式。从译名来看,有散乱排列的意思。本质就是通过哈希函数把关键字 Key 跟存储位置建立一个映射关系,查找时通过这个哈希函数计算出 Key 存储的位置,进行快速查找。
1.1 直接定址法
当关键字的范围比较集中时,直接定址法就是非常简单高效的方法。
- 整数示例 :比如一组关键字都在
[0, 99]之间,那么我们开一个 100 个数的数组,每个关键字的值直接就是存储位置的下标。 - 字符示例 :再比如一组关键字值都在
[a, z]的小写字母,那么我们开一个 26 个数的数组,每个关键字 ASCII 码减去'a'的 ASCII 码就是存储位置的下标。
核心总结:也就是说直接定址法本质就是用关键字计算出一个绝对位置或者相对位置。
1.2 哈希冲突
直接定址法的缺点也非常明显,当关键字的范围比较分散时,就很浪费内存甚至内存不够用。
假设我们只有数据范围是 [0, 9999] 的 N 个值,我们要映射到一个 M 个空间的数组中(一般情况下 M >= N),那么就要借助哈希函数 (Hash Function) hf,关键字 key 被放到数组的 h(key) 位置,这里要注意的是 h(key) 计算出的值必须在 [0, M) 之间。
这里存在的一个问题就是,两个不同的 key 可能会映射到同一个位置去,这种问题我们叫做哈希冲突 ,或者哈希碰撞。
注意:理想情况是找出一个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的。所以我们尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要去设计出解决冲突的方案。
1.3 负载因子
假设哈希表中已经映射存储了 N 个值,哈希表的大小为 M,那么负载因子(有些地方也翻译为载荷因子 / 装载因子 等,英文为 load factor)的计算公式为:
λ=NM \lambda = \frac{N}{M} λ=MN
负载因子是衡量哈希表填充程度的重要指标,它直接影响哈希表的性能:
- 负载因子越大:哈希冲突的概率越高,但空间利用率也越高。
- 负载因子越小:哈希冲突的概率越低,但空间利用率也越低。
因此,在设计哈希表时,需要在时间和空间效率之间进行权衡,选择一个合适的负载因子阈值。
c++中一般不超过0.7,一旦超过就会触发扩容。
哈希函数
哈希函数是用来将任意长度的输入数据转换为固定长度的输出值的,用来将关键词key转化为合法的映射值。"固定长度的输出值"在不同的场景下有不同的叫法,比如哈希值、散列值、哈希码或摘要。
一个好的哈希函数应该让 N 个关键字被等概率的、均匀地散列分布到哈希表的 M 个空间中。
虽然在实际工程中很难做到绝对的均匀分布,但我们在设计哈希函数时,要尽量往这个方向去考量。
哈希函数的设计原则
为了实现上述的"均匀分布",设计一个优秀的哈希函数通常遵循以下核心原则:
-
确定性
- 相同的输入必须永远产生相同的输出。这是哈希表能够正确查找数据的基础。
-
高效性
- 哈希函数的计算应该非常快。如果计算哈希值的时间比查找数据本身还长,哈希表就失去了意义。
-
均匀性
- 这是最核心的目标。哈希值应该尽可能均匀地分布在
[0, M)的范围内,避免某些位置拥挤(冲突多),而某些位置空闲。
- 这是最核心的目标。哈希值应该尽可能均匀地分布在
-
雪崩效应
- 输入的微小变化(例如字符串中改变一个字符),应该导致输出哈希值的巨大、不可预测的变化。这能有效减少规律性冲突。
-
充分利用输入信息
- 哈希函数应该考虑到输入数据的所有部分。例如,对于字符串,不能只看前几个字符,否则像 "apple" 和 "application" 这样的词就会频繁冲突。
我们上面所说的直接寻址法就是设计的一种哈希函数。
哈希函数设计
除法散列法/除留余数法
除法散列法也叫做除留余数法,顾名思义,假设哈希表的大小为 M,那么通过 key 除以 M 的余数作为映射位置的下标,也就是哈希函数为:
h(key)=key(modM) h(key) = key \pmod M h(key)=key(modM)
-
关于 M 的取值建议 :
当使用除法散列法时,要尽量避免 M 为某些值,如 2 的幂,10 的幂等。
- 如果是 2X2^X2X :那么
key % 2^X本质相当于保留key的二进制后 X 位。那么后 X 位相同的值,计算出的哈希值都是一样的,就冲突了。- 例如 :
{63, 31}看起来没有关联的值,如果 M 是 16(即 242^424),那么计算出的哈希值都是 15。因为 63 的二进制后 8 位是00111111,31 的二进制后 8 位是00011111,它们的后 4 位都是1111。
- 例如 :
- 如果是 10X10^X10X :就更明显了,保留的都是十进制的后 X 位。
- 例如 :
{112, 12312},如果 M 是 100(即 10210^2102),那么计算出的哈希值都是 12。
- 例如 :
- 如果是 2X2^X2X :那么
-
理论上的最佳实践 :
当使用除法散列法时,建议 M 取不太接近 2 的整数次幂的一个质数(素数)。
-
实践中的灵活应用 (Java HashMap) :
需要说明的是,实践中也是八仙过海,各显神通。Java 的 HashMap 采用除法散列法时就是 2 的整数次幂 做哈希表的大小 M。
- 位运算优化:这样玩的话,就不用取模,而可以直接位运算,相对而言位运算比模运算更高效一些。
- 二次哈希(扰动函数) :但他不是单纯的去取模。比如 M 是 2162^{16}216 次方,本质是取后 16 位,那么用
key' = key >> 16,然后把key和key'异或的结果作为哈希值。 - 目的 :也就是说我们映射出的值还是在
[0, M)范围内,但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀一些即可。
总结:所以我们上面建议 M 取不太接近 2 的整数次幂的一个质数的理论是大多数数据结构书籍中写的理论,但是实践中,灵活运用,抓住本质。
乘法散列法(了解)
乘法散列法对哈希表大小 M 没有要求,它的大思路分为两步:
- 用关键字
K乘上常数A(0<A<10 < A < 10<A<1),并抽取出K * A的小数部分。 - 再用 M 乘以
K * A的小数部分,再向下取整。
其哈希函数公式为:
h(key)=⌊M×((A×key)(mod1))⌋ h(key) = \lfloor M \times ((A \times key) \pmod 1) \rfloor h(key)=⌊M×((A×key)(mod1))⌋
其中 floor 表示对表达式进行下取整,A∈(0,1)A \in (0, 1)A∈(0,1)。这里最重要的是 A 的值应该如何设定,Knuth 认为 黄金分割点 比较好:
A=5−12≈0.6180339887... A = \frac{\sqrt{5} - 1}{2} \approx 0.6180339887... A=25 −1≈0.6180339887...
计算示例:
乘法散列法对哈希表大小 M 是没有要求的。假设 M 为 1024,key 为 1234,A = 0.6180339887:
- A×key=762.6539420558A \times key = 762.6539420558A×key=762.6539420558
- 取小数部分为 0.65394205580.65394205580.6539420558
- M×((A×key)(mod1.0))=0.6539420558×1024=669.6366651392M \times ((A \times key) \pmod{1.0}) = 0.6539420558 \times 1024 = 669.6366651392M×((A×key)(mod1.0))=0.6539420558×1024=669.6366651392
- 那么 h(1234)=669h(1234) = 669h(1234)=669
全域散列法(了解)
全域散列法主要为了解决恶意攻击问题。如果存在一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,比如,让所有关键字全部落入同一个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。
解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列。
全域散列函数公式:
hab(key)=((a×key+b)(modP))(modM) h_{ab}(key) = ((a \times key + b) \pmod P) \pmod M hab(key)=((a×key+b)(modP))(modM)
- P:需要选一个足够大的质数。
- a :可以随机选 [1,P−1][1, P-1][1,P−1] 之间的任意整数。
- b :可以随机选 [0,P−1][0, P-1][0,P−1] 之间的任意整数。
- 这些函数构成了一个 P×(P−1)P \times (P-1)P×(P−1) 组全域散列函数组。
计算示例:
假设 P=17P=17P=17,M=6M=6M=6,a=3a=3a=3,b=4b=4b=4,则:
h34(8)=((3×8+4)(mod17))(mod6)=5 h_{34}(8) = ((3 \times 8 + 4) \pmod{17}) \pmod 6 = 5 h34(8)=((3×8+4)(mod17))(mod6)=5
重要提示:
需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数使用,后续增删查改都固定使用这个散列函数。否则每次哈希都是随机选一个散列函数,那么插入是一个散列函数,查找又是另一个散列函数,就会导致找不到插入的 key 了。
处理哈希冲突
实践中哈希表一般还是选择除法散列法作为哈希函数,当然哈希表无论选择什么哈希函数也避免不了冲突,那么插入数据时,如何解决冲突呢?主要有两种方法:开放定址法 和链地址法。
开放定址法
在开放定址法中,所有的元素都放到哈希表里。当一个关键字 key 用哈希函数计算出的位置冲突了,则按照某种规则找到一个没有存储数据的位置进行存储。开放定址法中负载因子一定是小于 1 的。这里的规则有三种:线性探测、二次探测、双重探测。
线性探测
- 原理:从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止。如果走到哈希表尾,则回绕到哈希表头的位置。
- 公式 :
假设 h(key)=hash0=key%Mh(key) = hash_0 = key \% Mh(key)=hash0=key%M,如果 hash0hash_0hash0 位置冲突了,则线性探测公式为:
hc(key,i)=hashi=(hash0+i)%M,i={1,2,3,...,M−1} h_c(key, i) = hash_i = (hash_0 + i) \% M, \quad i = \{1, 2, 3, ..., M - 1\} hc(key,i)=hashi=(hash0+i)%M,i={1,2,3,...,M−1}
因为负载因子小于 1,则最多探测 M-1 次,一定能找到一个存储 key 的位置。 - 优缺点 :
- 线性探测比较简单且容易实现。
- 问题 :假设 hash0hash_0hash0 位置连续冲突,hash0,hash1,hash2hash_0, hash_1, hash_2hash0,hash1,hash2 位置已经存储数据了,后续映射到这些位置的值都会争夺 hash3hash_3hash3 位置。这种现象叫做群集 或堆积。下面的二次探测可以一定程度改善这个问题。
- 演示 :
下面演示{19, 30, 5, 36, 13, 20, 21, 12}这一组值映射到 M=11 的表中。- h(19)=8h(19) = 8h(19)=8
- h(30)=8h(30) = 8h(30)=8
- h(5)=5h(5) = 5h(5)=5
- h(36)=3h(36) = 3h(36)=3
- h(13)=2h(13) = 2h(13)=2
- h(20)=9h(20) = 9h(20)=9
- h(21)=10h(21) = 10h(21)=10
- h(12)=1h(12) = 1h(12)=1
二次探测
- 原理:从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止。如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置。
- 公式 :
假设 h(key)=hash0=key%Mh(key) = hash_0 = key \% Mh(key)=hash0=key%M,如果 hash0hash_0hash0 位置冲突了,则二次探测公式为:
hc(key,i)=hashi=(hash0±i2)%M,i={1,2,3,...,M2} h_c(key, i) = hash_i = (hash_0 \pm i^2) \% M, \quad i = \{1, 2, 3, ..., \frac{M}{2}\} hc(key,i)=hashi=(hash0±i2)%M,i={1,2,3,...,2M} - 注意 :
二次探测当 hashi=(hash0−i2)%Mhash_i = (hash_0 - i^2) \% Mhashi=(hash0−i2)%M 时,当 hashi<0hash_i < 0hashi<0 时,需要 hashi+=Mhash_i += Mhashi+=M。
cpp
bool Insert(const pair<K, V>& kv) {
// 1. 检查是否已存在(根据需求,也可以选择更新值)
if (Find(kv.first)) {
return false;
}
// 2. 检查负载因子,如果 > 0.7 则扩容
// 使用 _n * 10 / size >= 7 代替浮点运算,效率更高
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);
}
}
// 交换新旧表(利用移动语义,效率高),使用了vector内部的swap,相当于交换了对象,在离开作用域之后newHT销毁自动调用和析构函数,实际析构的是旧表,而外界的_table被更新成了新表
_tables.swap(newHT._tables);
}
// 3. 计算哈希位置并插入
Hash hash;
size_t hash0 = hash(kv.first) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
// 线性探测:寻找 EMPTY 或 DELETE 的位置
// 注意:这里只判断 EXIST,因为 DELETE 位置是可以复用的
while (_tables[hashi]._state == EXIST) {
// 再次检查 key 是否重复(防止 hash 冲突但 key 相同的情况,虽然上面 Find 过了,但为了逻辑严密)
if (_tables[hashi]._kv.first == kv.first) {//因为hash表中不能存储关键词相同的键值对
return false;
}
// 线性探测公式
hashi = (hash0 + i) % _tables.size();//更新映射下标,如果新的下标为空则保存,否则继续循环寻找新的地址。
++i;//增加步长
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
cpp
hashi = (hash0 + i*i) % _tables.size();//二次探测实质上就是通过改变步长为i^2来寻找位置。
扩容策略与质数表
这里我们将哈希表的负载因子控制在 0.7 。当负载因子达到 0.7 以后,我们就需要扩容了。我们还是按照 2倍 进行扩容,但是同时我们要保持哈希表大小是一个 质数。
如果第一个数是质数,2倍后通常就不是质数了。那么如何解决呢?
SGI STL 版本哈希表使用的方法。维护一个近似2倍的质数表,每次扩容时去质数表中获取下一个合适的质数大小。
SGI STL 质数表实现
cpp
inline unsigned long __stl_next_prime(unsigned long n)
{
// Note: assumes long is at least 32 bits.
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
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
};
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list + __stl_num_primes;
// 使用二分查找找到第一个大于等于 n 的质数
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
Key不能取模的问题
当key是string/Date等类型时,key不能取模,那么我们需要给HashTable增加一个仿函数,这个仿函数支持把key转换成一个可以取模的整形。
- 如果key可以转换为整形并且不容易冲突,那么这个仿函数就用默认参数即可。
- 如果这个Key不能转换为整形,我们就需要自己实现一个仿函数传给这个参数。
- 实现这个仿函数的要求:尽量key的每个值都参与到计算中,让不同的key转换出的整形值不同。
string做哈希表的key非常常见,所以我们可以考虑把string特化一下。
cpp
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
// 特化
template<>
struct HashFunc<string>
{
// 字符串转换成整形,可以把字符ascii码相加即可
// 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的
// 这里我们使用BKDR哈希的思路,用上次的计算结果去乘以一个质数,这个质数一般去31, 131等效果会比较好
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto e : key)
{
hash *= 131;
hash += e;
}
return hash;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; // 表中存储数据个数
};