【C++】哈希表实现 - 开放定址法
目录
- 一、哈希表的概念
- 1.1 直接定址法
- 1.2 哈希函数
- 1.2.1 除留余数法 / 除法散列法
- 1.2.2 乘法散列法(了解)
- 1.2.3 全域散列法(了解)
- 二、哈希表
- 2.1 解决哈希冲突
- 负载因子
- 2.1.1 开放定址法
- 哈希表线性探测方法的实现
- 1、节点结构
- 2. insert 函数的插入逻辑
- 3. insert 函数的扩容逻辑
- 4. insert 的测试
- 5. find 和 erase 函数
- 6. 关于 key 不能取模的问题

个人主页<—请点击
C++专栏<—请点击
一、哈希表的概念
哈希hash又称散列,是一种组织数据的方式。从译名来看,有散乱排列的意思。本质就是通过哈希函数把关键字Key跟存储位置建立一个映射关系,查找时通过这个哈希函数计算出Key存储的位置,进行快速查找。
1.1 直接定址法
当关键字的范围比较集中时,直接定址法就是非常简单高效的映射方法,比如一组关键字都在[0,99]之间,那么我们开个100大小的数组,每个关键字的值直接就是存储位置的下标。再比如一组关键字值都是[a,z]的小写字目,那么我们开个26大小的数组,每个关键字acsii码 - 'a'就是存储位置的下标。也就是说直接定址法本质就是用关键字计算出一个绝对位置或者相对位置。
局限性:只适用于范围分布较集中的整型,如果要存储的是字符串等数据,这个方法就不适用了。
1.2 哈希函数
一个好的哈希函数应该让N个关键字被等概率的均匀的散列分布到哈希表的M个空间中,但是实际中却很难做到,我们要尽量往这个方向去设计。
哈希映射的方法有很多,下面列举几个常见的。
1.2.1 除留余数法 / 除法散列法
假设哈希表的大小为M,那么通过key除以M的余数作为映射位置的下标,也就是哈希函数为:h(key) = key % M。
根据这个公式也不难发现,在某些数值的情况下,会导致冲突问题,如下图。

这里存在的⼀个问题就是,两个不同的key可能会映射到同一个位置去,这种问题我们叫做哈希冲突,或者哈希碰撞。理想情况是找出一个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的,所以我们要尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要去设计出解决冲突的方案。
当使用除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。如果是 ,那么key %2x本质相当于保留key的后X位,那么后X位相同的值,计算出的哈希值都是一样的,就冲突了。如:63 , 31看起来没有关联的值,如果M是16,也就是 24,那么计算出的哈希值都是15,因为63的⼆进制后8位是 00111111,31的⼆进制后8位是 00011111。如果是 10x,就更明显了,保留的是10进值的后X位,如:112, 12312,如果M是100,那么计算出的哈希值都12。
当使用除法散列法时,建议M取不太接近2的整数次幂的⼀个质数。
1.2.2 乘法散列法(了解)
乘法散列法对哈希表大小M没有要求,是用关键字K乘上常数 A (0<A<1),并抽取出k*A的小数部分。之后再用M乘以k*A的小数部分,再向下取整。
假设M为1024,key为1234,A = 0.6180339887,A*key= 762.6539420558,取小数部分为0.6539420558,M×((A×key)%1.0) =0.6539420558*1024 =669.6366651392,那么h(1234) = 669。
1.2.3 全域散列法(了解)
如果存在一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,比如,让所有关键字全部落入同⼀个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。
解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列法。
例如:h(key) = ((a × key + b)%P )%M,P需要选一个足够大的质数,a可以随机选[1,P-1]之间的任意整数,b可以随机选[0,P-1]之间的任意整数,这些函数构成了⼀个P*(P-1)组全域散列函数组。假设P=17,M=6,a = 3, b = 4,则h(8) = ((3 × 8 + 4)%17)%6 = 5。
需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数使用,后续增删查改都固定使用这个散列函数,就不会导致找不到插入的key了。
二、哈希表
2.1 解决哈希冲突
实践中哈希表一般还是选择除法散列法作为哈希函数,当然哈希表无论选择什么哈希函数也避免不了冲突,那么插入数据时,如何解决冲突呢?主要有两种两种方法,开放定址法和链地址法。
负载因子
假设哈希表中已经映射存储了N个值,哈希表的大小为M,那么 负载因子 = N / M,负载因子有些地方也翻译为载荷因子/装载因子等,他的英文为load factor。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。
2.1.1 开放定址法
在开放定址法中所有的元素都放到哈希表里,当一个关键字key用哈希函数计算出的位置冲突了,则按照某种规则找到一个没有存储数据的位置进行存储,开放定址法中负载因子⼀定是小于1的。这里的规则有三种:线性探测、二次探测、双重探测。
- 线性探测
从发生冲突的位置开始,依次线性向后探测,直到寻找到下⼀个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置。
h(key) = hash0 = key % M,如果hash0位置冲突了,则线性探测公式为:h(key,i) = hashi = (hash0 + i) % M, i = {1, 2, 3, ..., M − 1},因为负载因子小于1,则最多探测M-1次,就一定能找到一个存储key的位置。
将 {19,30,5,36,13,20,21,12} 等这一组值映射到M=11的表中:

哈希表线性探测方法的实现
要实现这里的代码,首先就要先解决几个细节问题,第一,我们可以直接组合vector进行实现,第二,使用vector实现的时候,不适合用vector<int>类型的,因为我们在存储数据的时候会有哈希冲突问题的出现,一旦出现,就需要线性探测,这就可能出现这个问题,我们由于冲突的问题,把key放在了后面,然后又把中间的某个位置删了,后来又要找key,但是中间有个空值,此时就会认为没存key,实际key在后面。
所以每个位置都要有三种状态,一种是空,一种是存在,一种是已删除。因此我们要使用一个结构体存储每个位置的信息。
1、节点结构
enum Status
{EMPTY, // 空位置EXIST, // 存在的数据DELETE // 已删除数据
};template<class K, class V>
struct HashData
{pair<K, V> _kv;Status _status = EMPTY;
};template<class K, class V>
class HashTable
{
public:HashTable():_tables(13) // 开初始空间,_n(0){ }
private:std::vector<HashData<K, V>> _tables;size_t _n = 0; // 有效数据个数
};
2. insert 函数的插入逻辑
bool insert(const pair<K, V>& kv)
{// 取余 size(), 因为 capacity > size,// 而超过 size 的地方不能直接放值int hash0 = kv.first % _tables.size();int hashi = hash0;int i = 1; // 线性探测while (_tables[hashi]._status == EXIST){hashi = (hashi + i) % _tables.size();++i;}_tables[hashi]._kv = kv;_tables[hashi]._status = EXIST;++_n;return true;
}
3. insert 函数的扩容逻辑
bool insert(const pair<K, V>& kv)
{// 负载因子 N/M >= 0.7 就扩容if ((double)_n / _tables.size() >= 0.7){HashTable<K, V> newtables;newtables._tables.resize(_tables.size() * 2 + 1);// 将旧表中的所有值重新映射到新表for (auto& data : _tables){if (data._status == EXIST){newtables.insert(data._kv);}}// 交换过来,完成扩容逻辑_tables.swap(newtables._tables);}// 取余 size(), 因为 capacity > size,// 而超过 size 的地方不能直接放值int hash0 = kv.first % _tables.size();int hashi = hash0;int i = 1; // 线性探测while (_tables[hashi]._status == EXIST){hashi = (hashi + i) % _tables.size();++i;}_tables[hashi]._kv = kv;_tables[hashi]._status = EXIST;++_n;return true;
}
4. insert 的测试
void test_hashtable1()
{int a[] = { 19,30,52,63,11,22,15,32,28,34,13,23,24,31 };HashTable<int, int> hash;for (auto& e : a){hash.insert({ e, e });}
}
测试结果:

测试结果没有问题。
注意,我们之前说最好数组的长度是一个质数,但是我们现在的代码,无法保证每次扩容后都是质数,那该怎么办呢?
stl源码中是直接给出了一张近乎二倍增长的质数表解决这个问题,这里也直接使用人家的做法解决这个问题。
template<class K, class V>
class HashTable
{
public:HashTable():_tables(__stl_next_prime(1)) // 开初始空间, 函数返回的是比 1 大且最接近 1 的值,_n(0){ }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;const unsigned long* pos = lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;}bool insert(const pair<K, V>& kv){// 负载因子 N/M >= 0.7 就扩容if ((double)_n / _tables.size() >= 0.7){HashTable<K, V> newtables;newtables._tables.resize(__stl_next_prime(_tables.size() + 1)); // +1,能保证下一个找到的比当前值大,且最接近当前值// ...}private:std::vector<HashData<K, V>> _tables;size_t _n = 0; // 有效数据个数
};
5. find 和 erase 函数
find:
HashData<K, V>* find(const K& key)
{int hash0 = key % _tables.size();int hashi = hash0;int i = 1;while (_tables[hashi]._status != EMPTY){if (_tables[hashi]._status == EXIST && _tables[hashi]._kv.first == key)return &_tables[hashi];hashi = (hashi + i) % _tables.size();i++;}return nullptr;
}
当当前位置不是空时,就持续探测,直到找到或者为空为止。
erase:
bool erase(const K& key)
{auto cur = find(key);if (cur){cur->_status = DELETE;--_n;return true;}else return false;
}
测试代码:
void test_hashtable3()
{int a[] = { 19,30,52,63,11,22,15,32,28,34,13,23,24,31 };HashTable<int, int> hash;for (auto& e : a){hash.insert({ e, e });}cout << hash.find(19) << endl;hash.erase(19);cout << hash.find(19) << endl;
}
测试结果:

6. 关于 key 不能取模的问题
当key是整型时,上面的代码当然是可行的,但当key是string类型时,此时key就不能够取模了,那么我们需要给HashTable增加一个仿函数,这个仿函数支持把key转换成一个可以取模的整型值,如果key可以转换为整型,并且不容易冲突,那么这个仿函数就用默认参数即可,如果这个key不能转换为整型,我们就需要自己实现一个仿函数传给这个参数,实现这个仿函数的要求就是尽量key的每值都参与到计算中,让不同的key转换出的整型值不同。string做哈希表的key非常常见,所以我们可以考虑把string特化一下。
template<class K>
struct HashOfKey
{size_t operator()(const K& k){return (size_t)k;}
};
有了这个之后,我们可以对HashTable类增加一个类模板参数,使传进来的key都先进行转换。
template<class K, class V, class Hash = HashOfKey<K>>
class HashTable
{
public:HashTable():_tables(__stl_next_prime(1)) // 开初始空间, 函数返回的是比 1 大且最接近 1 的值,_n(0){ }bool insert(const pair<K, V>& kv){// ...Hash hash; // 转换成整型的仿函数// 取余 size(), 因为 capacity > size,// 而超过 size 的地方不能直接放值int hash0 = hash(kv.first) % _tables.size();// ...}HashData<K, V>* find(const K& key){Hash hash; // 转换成整型的仿函数int hash0 = hash(key) % _tables.size();// ...}private:std::vector<HashData<K, V>> _tables;size_t _n = 0; // 有效数据个数
};
接下来对string类型的仿函数进行特化。
template<>
struct HashOfKey<string>
{size_t operator()(const string& k){size_t hs = 0;for (auto& e : k){hs += e;hs *= 131; // 能够有效防止 "abcd" "bcda"的整型值一样的情况}return hs;}
};
针对string类型的测试:
void test_hashtable4()
{HashTable<string, int> hash;hash.insert({ "counsel", 1 });hash.insert({ "norm", 2 });hash.insert({ "echo", 3 });hash.insert({ "serve", 4 });hash.insert({ "fuss", 5 });cout << hash.find("echo") << endl;hash.erase("echo");cout << hash.find("echo") << endl;
}
测试结果:

测试结果无误。
- 二次探测
从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置;
h(key) = hash0 = key % M,hash0位置冲突了,则二次探测公式为:hc(key,i)= hashi = ``(hash0 ± i2 ) % M, i = {1, 2, 3, ..., M/2 }。二次探测当 hashi = (hash0 − i2)%M 时,当hashi<0时,需要hashi += M。
- 双重散列(了解)
双重散列有两个哈希函数一个h1,一个h2,第一个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出一个跟key相关的偏移量值,不断往后探测,直到寻找到下一个没有存储数据的位置为止。
h1 (key) = hash0 = key % M,hash0位置冲突了,则双重探测公式为:hc(key,i) = hashi = (hash0 + i ∗ h2 (key)) % M,i = {1, 2, 3, ..., M}。
要求h2 (key) < M且 h2 (key)和M互为质数,有两种简单的取值方法:1、当M为2整数幂时,h2 (key)从[0,M-1]任选⼀个奇数;2、当M为质数时,h2 (key) = key % (M − 1) + 1。
保证h2 (key)与M互质,原因是固定的偏移量所寻址的所有位置将形成一个固定的群,若最大公约数p = gcd(M, h1 (key)) > 1,那么所能寻址的位置的个数为M/P < M,使得对于一个关键字来说无法充分利用整个散列表。
举例来说,若初始探查位置为1,偏移量h2 (key)为3,整个散列表大小M为12,那么所能寻址的位置为{1, 4, 7, 10}。寻址个数为12/gcd(12, 3) = 4。
下面演示{19,30,52,74} 等这一组值映射到M=11的表中,设h2 (key) = key%10 + 1。

总结:尽管开放定址法有很多解决哈希冲突的方案,但是这个方法还是有很大的缺陷。
1、聚集现象是开放定址法最致命的缺陷之一,它会导致哈希表的性能严重退化。
2. 性能对负载因子的变化极为敏感。随着负载因子的升高,找到空槽位的平均探测次数会急剧增加。经验表明,当负载因子α超过0.7 或 0.8时,开放定址法的性能就会开始显著下降。
总结:
以上就是本期博客分享的全部内容啦!如果觉得文章还不错的话可以三连支持一下,你的支持就是我前进最大的动力!
技术的探索永无止境! 道阻且长,行则将至!后续我会给大家带来更多优质博客内容,欢迎关注我的CSDN账号,我们一同成长!
(~ ̄▽ ̄)~
