当前位置: 首页 > news >正文

C++ 哈希表

前言

一、哈希概念

二、哈希函数

2.1 直接定址法

2.2 除留余数法/除法散列法

2.3 乘法散列法

2.4 全域散列法

三、哈希冲突

四、负载因子

五、解决哈希冲突

5.1 开放定址法

5.1.1 线性探测

5.5.2 二次探测

5.5.3 双重散列

5.2 链地址法

六、开放定址法代码实现

6.1 基本结构

6.2 插入

6.3 扩容

6.4 查找和删除

6.5 表的大小尽量为素数

6.6 解决key不能取模的问题

6.7 全部代码实现

七、链地址法代码实现

7.1 基本结构

7.2 插入

7.3 扩容

7.4 查找和删除

7.5 全部代码实现

总结


前言

本篇文章将要来实现哈希表,可能很多小伙伴都听过别人总是说起哈希哈希,但不知道哈希具体是什么,那本篇文章就给大家来揭秘哈希到底是什么。


一、哈希概念

哈希(hash)又称散列,是一种组织数据的方式。本质就是通过哈希函数把要存储的值key和存储位置建立一个映射关系,查找时通过这个哈希函数计算出key存储的位置,从而进行快速查找。哈希的查找是非常快的,基本上是O(1)。刚刚说到,要通过哈希函数建立映射关系,那哈希函数是什么呢?又有哪些呢?

二、哈希函数

2.1 直接定址法

当关键字的范围比较集中时,直接定址法就是非常简单高效的方法,比如一组关键字的值都在 [0, 50] 之间,那么我们开一个51个数的数组,每个关键字的值直接就是存储位置的下标。再比如一组关键字的值都在 [a,z] 的小写字母,那么我们开一个26个数的数组,用每个关键字的ASCII码值 -a 的ASCII码值就是存储位置的下标。计数排序就是用的直接定址法来映射的,也就是说直接定址法本质就是用关键字计算出一个绝对位置或者相对位置,也就是说值与位置是一对一的关系,但是直接定址法的适用范围太局限了,只适用于范围集中的正数,如果是浮点数,或者自定义类型,就不行了。就算是整数,如果范围太广也不行,假如最小的值是1,最大的值是10000,只有50个值要存储,但是现在要开10000个空间,非常浪费。所以直接定址法用的不多。

2.2 除留余数法/除法散列法

除法散列法也叫做除留余数法,顾名思义,假设哈希表的大小为M,那么通过key除以M的余数作为映射位置的下标,也就是哈希函数为:h(key) = key % M。
当使用除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。如果是2^{x} ,那么key % 2^{x} 本质相当于保留key的后x位,那么后x位相同的值,计算出的哈希值都是一样的,就冲突了。如:{63 , 31}看起来没有关联的值,如果M是16,也就是2^{4},那么63和31计算出的哈希值都是15,因为63的二进制后8位是 00111111,31的二进制后8位是 00011111。如果是10^{x} ,就更明显了,保留的都是10进值的后x位,如:{112, 12312},如果M是100,也就是10^{2} ,那么计算出的哈希值都是12。
当使用除法散列法时,建议M取不太接近2的整数次幂的一个质数(素数)。
但是在Java中的HashMap采用除法散列法时就是2的整数次幂做哈希表的大小M,这样的话,就不用取模,而可以直接位运算,相对而言位运算比取模运算更加高效一些。但是他不是单纯的去取模,比如M是2^{16},本质是取后16位,那么用key’ = key >> 16,然后把 key 和 key' 异或的结果作为哈希值。也就是说我们映射出的值还是在[0,M)范围内,但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀⼀些即可。
 除留余数法在实际中是用的最多的,我们下面的代码实现也用的是除留余数法

2.3 乘法散列法

乘法散列法对哈希表大小M没有要求,他的大思路第⼀步:用关键字 key 乘上常数 A (0<A<1),并抽取出 k*A 的小数部分。第⼆步:后再用M乘以k*A 的小数部分,再向下取整。
h(key) = floor(M * (A * key) % 1.0),其中floor表示对表达式进行下取整,A∈(0,1),这里最重要的是A的值应该如何设定,Knuth认为 A = (\sqrt{5}-1)/2 = 0.6180339887... (黄金分割点) 比较好。
假设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。

2.4 全域散列法

假如有一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,比如,让所有关键字全部落入同一个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列。
hab(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, 则 h34(8) = ((3 * 8 + 4) %17) % 6 = 5。
需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数使用,后续增删查改都固定使用这个散列函数,否则每次哈希都是随机选一个散列函数,那么插入是一个散列函数,查找又是另⼀个散列函数,就会导致找不到插入的key了。
除了上面的这些方法以外,哈希函数还有很多方法,大家可以去网站上搜一下,但是其他的就用的太少太少了,所以也就不一一举例了。

三、哈希冲突

像我们上面说的除法散列法、乘法散列法、全域散列法,它们的key有可能映射到同一个位置,拿除法散列法来举例,假设哈希表的大小M是5,那6%5 == 1, 11%5 == 1,那6和11就映射到了同一个位置。那对于这种两个不同的key可能会映射到同⼀个位置去,这种问题就叫做哈希冲突, 或者哈希碰撞。理想情况是找出一个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的, 以我们尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要去设计出解决冲突的方案。


四、负载因子

假设哈希表中已经映射存储了N个值,哈希表的大小为M,负载因子 = \frac{N}{M},负载因子有些地方也翻译为载荷因子/装载因子等,他的英文为load factor。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低,一般情况下负载因子控制在0.7左右,也就是说还剩下30%的空间没有用。

五、解决哈希冲突

5.1 开放定址法

在开放定址法中所有的元素都放到哈希表里,当一个关键字key用哈希函数计算出的位置冲突了,则按照某种规则找到一个没有存储数据的位置进行存储,开放定址法中负载因子一定是小于的。这里的规则有三种:线性探测、二次探测、双重探测。

5.1.1 线性探测

从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止,如果走
到哈希表的表尾,则重新回绕到哈希表头的位置。
假设哈希表的大小为M。
h(key) = hash0 = key % M, hash0位置冲突了,则线性探测公式为:hc(key, i) = hashi = (hash0 + i) % M, i = {1, 2, 3, ..., M-1},因为负载因子小于1,则最多探测M-1次,一定能找到一个存储key的位置。
线性探测的比较简单且容易实现,线性探测的问题假设,hash0位置连续冲突,hash0,hash1,
hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位
置,这种现象叫做群集/堆积。
下面用 {19, 30, 5, 36, 13, 20, 21, 12} 这一组值映射到M=10 的表中举例子。

5.5.2 二次探测

从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下⼀个没有存储数据的位置为止, 如果往右走 到哈希表的表尾,则重新回绕到哈希表头的位置; 如果往左走到哈希表头,则回绕到哈希表尾的位置;
假设哈希表的大小为M。
h(key) = hash0 = key % M, hash0位置冲突了,则二次探测公式为:
hc(key, i) = hashi = (hash0  \pm _{}  i^{_{2}}) % M, i = {1, 2, 3, ...,  \frac{M}{2}}
二次探测当 hashi = (hash0 - i^{_{2}}) % M   时,当hashi<0时,需要hashi += M。
下面用 {19, 30, 1, 36, 22, 11} 这一组值映射到M=10 的表中举例子。

5.5.3 双重散列

第一个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出一个跟key相关的偏移量值,不
断往后探测,直到寻找到下⼀个没有存储数据的位置为止。
假设哈希表的大小为M。
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整数幂时,从[0,M-1]任选一个奇数;
2、当M为质数时,h2(key) = key % (M-1) + 1
保证 与M互质是因为根据固定的偏移量所寻址的所有位置将形成⼀个群,若最大公约数
p = gcd(M, h1(key)) > 1,那么所能寻址的位置的个数为 M / P < M ,使得对于一个关键字来
说无法充分利用整个散列表。举例来说,若初始探查位置为1,偏移量为3,整个散列表大小为12,那么所能寻址的位置为{1, 4, 7, 10},寻址个数为 12 / gcd(12, 3) = 4
下面用 {19, 30, 41, 52} 这一组值映射到M=11 的表中举例子。

开放定址法虽然有这三种解决的思路,但本质上还都是自己的位置被占了,就占用别人的位置,还是在互相的抢占位置,那我们还有一个更好的办法,是一对多的关系,发生冲突了,也往一个位置中存,不去影响别人。


5.2 链地址法

开放定址法中所有的元素都放到哈希表里,链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储一个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,我们把这些冲突的数据链接成一个链表,挂在哈希表这个位置下面,链地址法也叫做拉链法或者哈希桶。
下面用 { 19,30,5,36,13,20,21,12,24,96 } 这一组值映射到M=11 的表中举例子。

如果出现了极端场景呢?某个桶特别长怎么办?其实我们可以考虑使用全域散列法,这样就不容易被针对了。但是假设不是被针对了,用了全域散列法,但是偶然情况下,某个桶很长,查找效率很低怎么办?在java的一个版本中的HashMap的做法是当桶的长度超过8时就把挂链表转换成挂红黑树。不过一般情况下, 单个桶特别长的场景还是基本不会出现的。


六、开放定址法代码实现

选择的哈希函数是除留余数法,解决哈希冲突的方法是开放定址法中的线性探测

因为在底层需要连续的空间,所以直接给一个vector就可以,拷贝构造,赋值,都可以直接用vector的,使用一些接口的时候也很方便。但是一定要注意,vector的每个位置里,除了要存一个值以外,还要去存储一个状态,为什么要额外存储一个状态呢?我们用5.1.1线性探测里面的图来举例子。

查找一定是找到空为止,因为插入时如果是因为冲突,那找到空位置就放下了,所以空位置之后不可能还有,对于查找16来说是这样,那我们看12呢,原本是找的到的,但是因为13被删除了,到3位置的时候为空,就停止了,但其实12是在后面的,所以要增加一个状态表示,如果这个位置的状态是空就停止,是删除也要继续向后找。

6.1 基本结构

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 HashTable{public:HashTable(size_t size = 11){_table.resize(size);}private:vector<HashData<K, V>> _table;size_t _n = 0;};
}

6.2 插入

bool insert(const pair<K, V>& kv)
{size_t hashi = kv.first % _table.size();// 线性探测while (_table[hashi]._state == EXIST){hashi++;hashi %= _table.size();}_table[hashi]._kv = kv;_table[hashi]._state = EXIST;++_n;return true;
}

在这里取模的时候,一定要注意模的是size而不是capacity,因为假设size是10,capacity是20,我们去模capacity,模完可能是13,15的位置,要往vector里填值,就要用[]访问,vector的[]会先检查,放的位置是不是小于size,13,15是大于10的,直接就报错了,所以要模size,而不是capacity,这是一个细节的地方。


6.3 扩容

下面就来实现扩容逻辑,扩容并不是说把空间给大一点,然后值不动,扩容以后,元素的映射位置就会变化,比如,原来10个空间的时候,15会映射到5的位置,现在按照2倍扩容,20个空间,15就会映射到15的位置,扩完容会影响元素的映射位置,所以正确的做法应该是拿一张新表,遍历旧表,重新映射到新表,映射完后,再把两张表交换,那我们使用的就是新表了。

bool insert(const pair<K, V>& kv)
{// 扩容if ((double)_n / (double)_table.size() >= 0.7){vector<HashData<K, V>> newtable(_table.size()*2);for (int i = 0; i < _table.size(); i++){if (_table[i]._state == EXIST){// 下面的逻辑还要再写一遍,有点冗余}}}size_t hashi = kv.first % _table.size();// 线性探测while (_table[hashi]._state == EXIST){hashi++;hashi %= _table.size();}_table[hashi]._kv = kv;_table[hashi]._state = EXIST;++_n;return true;
}

之前我们说过的负载因为在0.7左右就扩容,这里的判断条件要注意,_n和_table.size()都是int类型,结果想得出浮点数就是强转成double,不管怎么都要转1个,2个都转也可以。开一个新表newtable,大小是原来的2倍,遍历旧表,只要状态是存在,就插入到新表,这里注意的是,状态是删除,在扩容的时候不用管,状态是删除就代表没有这个数据,所以只看存在即可,但是大家会发现一个问题,那现在我还是要算出应该插入在新表的哪个位置,然后线性探测,再放值,那不就是下面的逻辑再写一次吗?是不是太冗余了,所以我们可以想办法复用,也就是直接创建一个HashTable这个类的对象,在类内直接用对象去调insert来实现复用。

bool insert(const pair<K, V>& kv)
{// 扩容if ((double)_n / (double)_table.size() >= 0.7){HashTable<K, V> newht(_table.size()*2);for (int i = 0; i < _table.size(); i++){if (_table[i]._state == EXIST){newht.insert(_table[i]._kv);}}_table.swap(newht._table);}size_t hashi = kv.first % _table.size();// 线性探测while (_table[hashi]._state == EXIST){hashi++;hashi %= _table.size();}_table[hashi]._kv = kv;_table[hashi]._state = EXIST;++_n;return true;
}

newht是新的对象,插入的时候就是往newht里面的表去插入,这张表的空间是够的,所以去复用的时候,就不会进入扩容逻辑,最后把原来的表遍历完了,再去交换。


6.4 查找和删除

template<class K, class V>
class HashTable
{
public:HashData<K, V>* find(const K& key){size_t hashi = key % _table.size();while (_table[hashi]._state != EMPTY){// 值相等并且状态不是删除才可以返回if (_table[hashi]._kv.first == key && _table[hashi]._state != DELETE){return &_table[hashi];}hashi++;hashi %= _table.size();}return nullptr;}bool erase(const K& key){HashData<K, V>* ret = find(key);if (ret){ret->_state = DELETE;--_n;return true;}else{return false;}}private:vector<HashData<K, V>> _table;size_t _n = 0;
};

删除就是直接复用查找就可以,找到了就直接改状态。


6.5 表的大小尽量为素数

之前在讲除留余数法的时候,我们说过哈希表的大小M尽量为素数,这样就可以减少一些冲突,这里面运用了一些数学的原理,那我们看看源码是怎么实现的,源码选取自SGI3.0版本,因为unordered_set和unordered_map是C++11出的,而SGI3.0版本的时候要早于C++11,所以源码中是没有这两个容器的,虽然没有容器,但是是实现了哈希表的,在stl_hashtable.h这个文件中

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
};inline unsigned long __stl_next_prime(unsigned long n)
{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;
}

可以看到,库的实现是直接定义出来一个大小为28的数组,基本上除了最后一次以外,其他值都是接近于2倍的增长,选取的是2倍附近最合适的素数,first是数组的起始位置,last是在整个数组的后面,lower_bound算出在first-last这段区间内>=n的值,假如n是60,那>=的就是97,下一次的哈希表的大小就是97,如果没有值了,那pos的位置就和last是一样的,所以额外加了一个判断。这种情况下就返回最后一个值,42亿9千万的这个值,如果还有值就返回这个值。那我们也可以借鉴这种思路来实现。

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
};inline unsigned long __stl_next_prime(unsigned long n)
{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;
}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 HashTable{public:HashTable(size_t size = __stl_next_prime(0)){_table.resize(size);}bool insert(const pair<K, V>& kv){if (find(kv.first))return false;// 扩容if ((double)_n / (double)_table.size() >= 0.7){HashTable<K, V, Hash> newht(__stl_next_prime(_table.size() + 1));for (int i = 0; i < _table.size(); i++){if (_table[i]._state == EXIST){newht.insert(_table[i]._kv);}}_table.swap(newht._table);}Hash hs;size_t hashi = kv.first % _table.size();// 线性探测while (_table[hashi]._state == EXIST){hashi++;hashi %= _table.size();}_table[hashi]._kv = kv;_table[hashi]._state = EXIST;++_n;return true;}};
}

在构造函数中,只需要拿一个最接近的比0大的值就可以了,那表刚开始的大小就是53,后面在扩容的时候,一定要注意,传参的时候就比当前size多1就可以,不要传2倍,如果传2倍,那53*2=106,下一个值就选到193去了,97空过去了,所以我们传+1就可以,最接近的比54大的值,就选到了97,这是一个要注意的地方。


6.6 解决key不能取模的问题

只有key是整形的时候才是可以取模的,如果是浮点数,指针,负数都不可以,在C++中负数取模还是负数,不能变成正数,那这种情况,就要做两层映射,先把它们变成正数,再去找要存储的位置。我们可以用仿函数控制。

template<class K>
struct HashFunc
{size_t operator()(const K& key){return (size_t)key;}
};

除此之外,我们要把这个仿函数给成模版参数,因为自定义类型需要使用的人自己来控制。由于在这么多的自定义类型中,字符串做key是非常非常常见的,所以我们可以针对string类型做一个特化。

string的处理方式,可以考虑把字符串对应的ASCII码加起来,但是这样又会有一个问题,就是 "abcd" 和 "acdb" 加起来的值是一样的,字符串的内容一样,只是顺序不一样,也会造成冲突,解决这个问题有很多的算法,叫做字符串哈希算法,我们在这里用的是BKDRHash算法,也就是在将每一个字符加起来之前,先用累计的和乘上一个固定值131,这样 "abcd" 和 "acdb" 这两个字符串算出来的值不一样,就能进一步的减少冲突的概率,溢出的问题我们不用考虑,溢出也没有问题,只要我们是这么存的,找的时候也是这么找的,就肯定可以找的到。

template<class K>
struct HashFunc
{size_t operator()(const K& key){return (size_t)key;}
};// 特化
template<>
struct HashFunc<string>
{size_t operator()(const string& str){size_t sum = 0;for (auto ch : str){sum *= 131;sum += ch;}return sum;}
};

6.7 全部代码实现

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
};inline unsigned long __stl_next_prime(unsigned long n)
{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;
}template<class K>
struct HashFunc
{size_t operator()(const K& key){return (size_t)key;}
};// 特化
template<>
struct HashFunc<string>
{size_t operator()(const string& str){size_t sum = 0;for (auto ch : str){sum *= 131;sum += ch;}return sum;}
};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:HashTable(size_t size = __stl_next_prime(0)){_table.resize(size);}bool insert(const pair<K, V>& kv){// 有重复的值就不插入if (find(kv.first))return false;// 扩容if ((double)_n / (double)_table.size() >= 0.7){HashTable<K, V, Hash> newht(__stl_next_prime(_table.size() + 1));for (int i = 0; i < _table.size(); i++){if (_table[i]._state == EXIST){newht.insert(_table[i]._kv);}}_table.swap(newht._table);}Hash hs;size_t hashi = hs(kv.first) % _table.size();// 线性探测while (_table[hashi]._state == EXIST){hashi++;hashi %= _table.size();}_table[hashi]._kv = kv;_table[hashi]._state = EXIST;++_n;return true;}HashData<K, V>* find(const K& key){Hash hs;size_t hashi = hs(key) % _table.size();while (_table[hashi]._state != EMPTY){// 值相等并且状态不是删除才可以返回if (_table[hashi]._kv.first == key && _table[hashi]._state != DELETE){return &_table[hashi];}hashi++;hashi %= _table.size();}return nullptr;}bool erase(const K& key){HashData<K, V>* ret = find(key);if (ret){ret->_state = DELETE;--_n;return true;}else{return false;}}private:vector<HashData<K, V>> _table;size_t _n = 0;};
}

七、链地址法代码实现

vector的每个位置里除了存值外,还要存一个节点指针。

7.1 基本结构

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 HashTable{typedef HashNode<K, V> Node;public:HashTable(size_t size = 11){_table.resize(size, nullptr);}private:vector<Node*> _table;size_t _n = 0;};
}

7.2 插入

bool insert(const pair<K, V>& kv)
{size_t hashi = kv.first % _table.size();Node* newnode = new Node(kv);newnode->_next = _table[hashi];_table[hashi] = newnode;++_n;return true;
}

链表的插入我们肯定选择头插,这样的效率会高很多,如果是尾插的话,还要去找尾。


7.3 扩容

链地址法的负载因子就没有限制了,可以大于1。我们可以保证每个位置至少挂一个桶时再去扩容,unordered_set 和 unordered_map 的最大负载因子基本控制在1。
扩容的逻辑我们还是和开放定址法一样,定义出一个新的对象去复用。
bool insert(const pair<K, V>& kv)
{// 扩容if (_n / _table.size() == 1){HashTable<K, V> newht(_table.size()*2);for (int i = 0; i < _table.size(); i++){Node* cur = _table[i];while (cur){newht.insert(cur->_kv);cur = cur->_next;}}_table.swap(newht._table);}size_t hashi = kv.first % _table.size();Node* newnode = new Node(kv);newnode->_next = _table[hashi];_table[hashi] = newnode;++_n;return true;
}

但是这样写大家有没有发现一个问题,就是这个节点本来是存在的,但是现在去复用,又要去new一个节点出来,然后还要把原来的节点给释放了,这样太麻烦了,所以我们可以直接把原来的节点给搬下来,不去new新的节点了,重新去计算应该映射在新表的哪个位置,一个节点一个节点的往下拿,最后交换两张表即可。

bool insert(const pair<K, V>& kv)
{// 扩容if (_n / _table.size() == 1){vector<Node*> newtable(_table.size()*2, nullptr);for (int i = 0; i < _table.size(); i++){Node* cur = _table[i];while (cur){Node* next = cur->_next;size_t hashi = cur->_kv.first % newtable.size();cur->_next = newtable[hashi];newtable[hashi] = cur;cur = next;}_table[i] = nullptr;}_table.swap(newtable);}size_t hashi = kv.first % _table.size();Node* newnode = new Node(kv);newnode->_next = _table[hashi];_table[hashi] = newnode;++_n;return true;
}

7.4 查找和删除

Node* find(const K& key)
{size_t hashi = key % _table.size();Node* cur = _table[hashi];while (cur){if (cur->_kv.first == key){return cur;}cur = cur->_next;}return nullptr;
}bool erase(const K& key)
{size_t hashi = key % _table.size();Node* cur = _table[hashi];Node* prev = nullptr;while (cur){if (cur->_kv.first == key){if (prev == nullptr){_table[hashi] = cur->_next;}else{prev->_next = cur->_next;}--_n;delete cur;return true;}prev = cur;cur = cur->_next;}return false;
}

链地址法的删除不可以复用查找,因为需要拿到删除节点的前一个节点prev。

而找到了又分为两种情况

第一种情况是prev不为空,直接让prev->_next = cur->_next即可

第二种情况是prev为空,让cur->_next成为第一个挂的桶,_table[hashi] = cur->_next


表的大小尽量为素数和key不能取模的问题和开放定址法是一样的,并且因为链地址法是一个一个的节点,所以析构、拷贝构造、赋值是需要我们自己写的,在这里我们就直接给代码了。

7.5 全部代码实现

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
};inline unsigned long __stl_next_prime(unsigned long n)
{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;
}template<class K>
struct HashFunc
{size_t operator()(const K& key){return (size_t)key;}
};// 特化
template<>
struct HashFunc<string>
{size_t operator()(const string& str){size_t sum = 0;for (auto ch : str){sum *= 131;sum += ch;}return sum;}
};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(size_t size = __stl_next_prime(0)){_table.resize(size, nullptr);}// 析构~HashTable(){for (int i = 0; i < _table.size(); i++){Node* cur = _table[i];while (cur){Node* next = cur->_next;delete cur;cur = next;}_table[i] = nullptr;}}// 拷贝构造,调用插入,一个一个插入就可以HashTable(const HashTable<K, V, Hash>& ht){_table.resize(ht._table.size(), nullptr);for (int i = 0; i < ht._table.size(); i++){Node* cur = ht._table[i];while (cur){insert(cur->_kv);cur = cur->_next;}}}// 现代写法,调用拷贝构造深拷贝HashTable<K, V, Hash>& operator=(HashTable<K, V, Hash> ht){swap(_table, ht._table);swap(_n, ht._n);return *this;}bool insert(const pair<K, V>& kv){// 有重复的值就不插入if (find(kv.first))return false;Hash hs;// 扩容if (_n / _table.size() == 1){/*HashTable<K, V> newht(__stl_next_prime(_table.size() + 1));for (int i = 0; i < _table.size(); i++){Node* cur = _table[i];while (cur){newht.insert(cur->_kv);cur = cur->_next;}}_table.swap(newht._table);*/vector<Node*> newtable(__stl_next_prime(_table.size() + 1), nullptr);for (int i = 0; i < _table.size(); i++){Node* cur = _table[i];while (cur){Node* next = cur->_next;size_t hashi = hs(cur->_kv.first) % newtable.size();cur->_next = newtable[hashi];newtable[hashi] = cur;cur = next;}_table[i] = nullptr;}_table.swap(newtable);}size_t hashi = hs(kv.first) % _table.size();Node* newnode = new Node(kv);newnode->_next = _table[hashi];_table[hashi] = newnode;++_n;return true;}Node* find(const K& key){Hash hs;size_t hashi = hs(key) % _table.size();Node* cur = _table[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) % _table.size();Node* cur = _table[hashi];Node* prev = nullptr;while (cur){if (cur->_kv.first == key){if (prev == nullptr){_table[hashi] = cur->_next;}else{prev->_next = cur->_next;}--_n;delete cur;return true;}prev = cur;cur = cur->_next;}return false;}private:vector<Node*> _table;size_t _n = 0;};
}

总结

哈希表在实际中用的非常多,大家要对它的底层结构有一定的了解,基本上哈希函数选择的就是除留余数法,解决哈希冲突的方式是哈希桶,看到这里相信大家也明白了链地址法相较于开放定址法的优点。如果大家觉得小编写的还不错的,可以给一个三连表示支持,谢谢大家!!!

相关文章:

  • Qt QML实现Windows桌面歌词动态播放效果
  • QtApplets-实现应用程序单例模式,防止重复运行
  • 2025年Q2(流动式)起重机司机考试题
  • 【Windows本地部署n8n工作流自动平台结合内网穿透远程在线访问】
  • Ubuntu利用docker搭建Java相关环境记录(二)
  • Vision Transformer项目分析与介绍
  • 压缩包网页预览(zip-html-preview)
  • Apache Atlas构建安装(Linux)
  • Python 深度学习 第8章 计算机视觉中的深度学习 - 卷积神经网络使用实例
  • YOLO训练多评价指标曲线画图
  • 【2025“华中杯”大学生数学建模挑战赛】选题分析 A题 详细解题思路
  • k8s报错kubelet.go:2461] “Error getting node“ err=“node \“k8s-master\“ not found“
  • 【秣厉科技】LabVIEW工具包——OpenCV 教程(20):拾遗 - imgproc 基础操作(下)
  • Python实例题:Python自动化开发-考勤处理
  • iptables防火墙
  • 深入浅出 Redis:核心数据结构解析与应用场景Redis 数据结构
  • 简述Apache RocketMQ
  • R语言简介与下载安装
  • 面试题之高频面试题
  • 扩展欧几里得算法【Exgcd】的内容与题目应用
  • 浙江省委金融办原副主任潘广恩被“双开”
  • 对谈|“大礼议”:嘉靖皇帝的礼法困境与权力博弈
  • 乌克兰官员与法德英美四国官员举行会谈
  • 南昌上饶领导干部任前公示:2人拟提名为县(市、区)长候选人
  • “16+8”“生酮饮食”,网红减肥法究竟靠谱吗?
  • 音乐节困于流量