文章目录
- 一、unorderedset与unorderedmap
- 二、哈希概念
- 三、哈希函数
- 四、处理哈希冲突
- 五、哈希总结
一、unorderedset与unorderedmap
1.unordered_set和unordered_map是c++11新增的两个容器,底层使用的是哈希表
unordered_set和unordered_map使用逻辑与方式和set、map一致
unordered_set是key场景,unordered_map是key_value场景
unordered_set和unordered_map不支持冗余
unordered_multiset和unordered_multimap支持冗余
unordered_multiset和unordered_multimap是单向迭代器
因为unordered_multiset和unordered_multimap底层的哈希表
所以unordered_multiset和unordered_multimap遍历是无序的
2.unordered_set和unordered_map的头文件是
#include< unordered_set > #include< unordered_map >
3. unordered_set和unordered_map要求存储的数据可以转换为整型和比较大小
如果存储的数据本身不支持比较相等或者是比较相等的方式不符合预期 ,在模板参数
传可调用对象类型,通过可调用对象来控制比较相等逻辑
如果存储的数据本身不是整型,那么就需要编写一个可以将其转换为整型的可调用对象
然后在模板参数位置将可调用对象的类型传过去
4.unordered_set和unordered_map的声明

5.unordered_set和unordered_map使用
(1):成员函数
unordered_set和unordered_map和set、map的成员函数使用方式一致
(2):unordered_map也重载了[],使用方式和map一致

二、哈希概念
1.哈希又称作散列,是一种组织数据的方式,哈希是译名,散列是形容该数据结构的名字
哈希之所以称作散列是因为从表面来看,哈希数据结构看起来是胡乱排列的
2.哈希本质上就是通过哈希函数将存储的数据和存储位置一一对应
存储时,使用哈希函数计算出数据存储的位置
查找时,使用哈希函数计算出数据存储的位置,然后找到该数据
3.直接定址法
3.1直接定址法使用于数据比较集中时,当数据比较集中,例如数据范围在0 -- 99
那么就直接开辟100个空间,每一个值就是存储位置对应的下标,那么每一个值就拥有
对应的存储位置
3.2但是直接定址法不适用于数据范围分散时,例如数据范围是0 -- 9999
那么就需要开辟10000个空间,但是如果此时只存储10个数据,那么9990个空间就浪费
太浪费空间了
三、哈希函数
1.根据直接定址法我们得知,当数据不集中时,直接定址法就不可以使用了,那么就需要
重新设计哈希函数,使得数据经过哈希函数的计算之后可以明确的得到唯一的存储位置
2.除法散列法/除留余数法
2.1除法散列法/除留余数法思想:
假设此时有N个数据要存储,那么就需要开辟M个数据大小的空间(M >= N)
那么此时保证了每一个数据都肯定有一个存储的位置
此时再将数据 % M == X得到的结果X一定是0 <= X < M,此时我们可以发现
数据 % M结果一定是小于M的,此时数据 % M的结果肯定是当前空间的一个下标
那么此时就可以使得数据唯一确定匹配一个存储位置
除法散列法/除留余数法哈希函数 == h(key) = key % M
2.2哈希冲突
根据2.1的分析后,可以敏锐的意识到,不同的数据 % M的结果可能是一致的
例如:33 % 11 == 0,22 % 11 == 0,那么此时不同的数据通过哈希函数计算出来的存储
位置的相同的,一个存储位置只可以存储一个数据,那么此时有两个数据需要存储的
位置都在同一个地方,那么该位置就是冲突的,这种情况我们称作哈希冲突
2.3因为会发生哈希冲突,所以好的哈希函数应该避免哈希冲突,一个好的哈希函数应该
让N个关键字被等概率的均匀的散列分布到哈希表的M个空间中,但是实际中却很难做
到,但是我们要尽量往这个方向去考量设计。
2.4将数据转为整型
根据2.1的分析后,我们可以观测到,这种哈希函数是需要去%M的,但是只有整型
才可以执行%运算,那么对于不是整型的数据,那么就需要通过一个哈希函数将数据
转换为整型,其实也就是上面unordered_set/map声明中的参数Hash<key>
本质上是将不是整型的数据,先让其对应映射成一个整型,再通过映射的整型去哈希表
中进行映射
2.4当使用除法散列法时,要尽量避免M为某些值,如2的幂,8的幂,10的幂,16的幂
例如此时M是2^4
因为计算机当中存储数据本质上存储的是二进制形式的010010011101
如果此时将数据 % 2的幂,那么只有位权 < 2的幂的位参与运算,位权 > 2的幂的位
都会被除尽,仔细一想,一个整型的存储是32个二进制位,此时是2^4,一共32个位
最终只有4个位参与了运算,那么使得计算结果一致的概率不就大大提升了,也就是
发生哈希冲突的概率就提高了
例如此时M是10^2
因为我们在让穿使用中使用的是10进制,并且在编写程序填入数据时,使用的是10进制
如果此时将数据 % 10的幂,那么只有位权 < 10的幂的位参与运算,位权 > 10的幂的位
都会被除尽,那么此时M也就是10^2,那么只会有两位参与运算,其余的位全部被除尽
那么只有两位参与运算,计算结果重复的概率就提高了,也就是哈希冲突的概率提高了
8的幂和16的幂情况一致,因为8进制和16进制,最本质上也是2的幂
那为什么其他数的幂就没事呢?
例如此时是11的幂
因为我们在表示数据时大多是2进制和10进制,那么11的幂就不会影响到参与计算的位
除非有人闲着出一个11进制,那么11的幂才会影响到位的运算
本质上就是10的次幂就会影响10进制的位,2的次幂就会影响2进制的位
2.5当使用除法散列法时,建议M取不太接近2的整数次幂的⼀个质数(素数)
2.6需要说明的是,实践中也是八仙过海,各显神通,Java的HashMap采⽤除法散列法时
就是2的整数次幂做哈希表的大小M,但是他不是单纯的去取模,比如M是2^16次方,本质
是取后16位,那么⽤key = key>>16,然后把key和key'异或的结果作为哈希值
本质上是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀⼀些即可
3.乘法散列法
3.1乘法散列法只需要保证M >= N即可,其余的对M没有要求
3.2乘法散列法思想:
用key乘上一个A(0 < A < 1),那么此时肯定会产生一个小数,此时再将小数部分取出来 * M
此时结果肯定 < M,然后再进行向下取整,那么此时就可以得到一个0 -- M - 1之间的数
此时该结果就可以作为key存储的下标,就可以做到key与存储位置的映射
3.3乘法散列法哈希函数:
h(key) = floor(M × ((A × key)%1.0)),其中floor表示对表达式进行下取整A∈(0,1),这里
最重要的是A的值应该如何设定,Knuth认为A = ( 5 − 1)/2 = 0.6180339887....
(黄金分割点) 比较好
4.全域散列法
4.1为什么要拥有全域散列法
如果存在⼀个恶意的对手,他针对我们提供的散列函数,特意构造出⼀个发生严重冲突的
数据集,比如,让所有关键字全部落入同一个位置中。这种情况是可以存在的,只要散列
函数是公开且确定的,就可以实现此攻击
4.2全域散列法思想
hab (key) = ((a × key + b)%P )%M
P需要选一个足够大的质数,a可以随机选[1,P-1]之间的任意整数b可以随机选[0,P-1]之间
的任意整数,这些函数构成了⼀个P*(P-1)组全域散列函数组
需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的⼀个散列函数使用,后
续增删查 改都固定使用这个散列函数,否则每次哈希都是随机选一个散列函数,那么插入
是一个散列函数, 查找又是另一个散列函数,就会导致找不到插入的key了
四、处理哈希冲突
1.实践中哈希表⼀般还是选择除法散列法作为哈希函数,当然哈希表无论选择什么哈希函数
也避免不了冲突,那么插入数据时,如何解决冲突呢?主要有两种两种方法,开放定址法和
链地址法
2.开放定址法:
2.1负载因子
假设哈希表中已经映射存储了N个值,哈希表的大小为M,那么负载因子 = N / M,
负载因⼦有些地⽅ 也翻译为载荷因子/装载因子等,负载因子越大,哈希冲突的概率越
高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低
2.2开放定址法思想
在开放定址法中所有的元素都放到哈希表里,当一个关键字key用哈希函数计算出的位置
冲突了,则按照某种规则找到⼀个没有存储数据的位置进行存储,开放定址法中负载因子
一定是小于的。这里的规则有三种:线性探测、二次探测、双重探测
2.3线性探测
2.3.1从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位
置为止,如果走到哈希表尾,则回绕到哈希表头的位置
2.3.2h(key) = hash0 = key % M,hash0位置冲突了,则线性探测公式为:
hashi = (hash0 + i) % M, i = {1, 2, 3, ..., M − 1},因为负载因子小于1, 则最多探测
M-1次,一定能找到一个存储key的位置
2.4结构
2.4.1哈希表底层是一个数组,那么就可以复用vector进行实现
2.4.2因为哈希表需要频繁的使用[],所以vector的size就是哈希表的空间大小
而不是vector的capacity是哈希表的空间大小
vector[]的使用范围必须是[0,size)vector只允许使用[]访问有效的数据
所以vector的size才是哈希表的实际大小
2.4.3因为哈希表的大小实际是vector底层的实际数据的个数,那么就表明,哈希表实际
存储的位置都是会有一个原本的数据的,那么哈希表在进行插入时就分不清映射的位置
是有值?还是空?还是被删除了?那么就需要额外的针对每一个空间标记一个状态
2.4.4代码

2.5插入
2.5.1判断哈希表中该数据是否已经存在
实现查找find之后,通过find进行判断
2.5.2看哈希表的负载因子是否 >= 0.7,如果大于0.7那么就需要对哈希表进行扩容
扩容一般是扩二倍,但是我们很清楚的意识到,无论我们传递上面样的质数过去,*2
都会变为偶数,那么为了解决这个问题,大佬们就发明了近似二倍的质数表,每次去
质数表中获取到接下来哈希表容量的大小
2.5.3质数表

2.5.4扩容逻辑
哈希表进行扩容时,那么新的哈希表和原本的哈希表的大小是不一样的,那么就是说M
变化了,那么哈希表的大小发生了变化,那么扩容时就不可以将原先哈希表的数据单纯
的拷贝过来,因为哈希表的大小不一样了,所以需要重新映射
2.5.5key不能取模问题
因为此时哈希函数采用的是除留余数法,那么key就必须支持取模,而只有整型才可以
进行取模,那么面对其他类型的数据,那么就需要提供一个哈希函数(仿函数)先将该数据
映射为整型,再使用该整型进行映射,但是如果存储的数据本身就是一个整型呢?
那么就需要给哈希函数(仿函数)来一个缺省值,默认是整型的哈希函数
但是STL库中的的哈希表对于stirng是不需要传递仿函数的,为什么?
因为哈希表中存储一个string太常见了,那么就将整型的哈希函数(仿函数)特化出一个
string的哈希函数(仿函数)
因为不同的string中可能字符都是相等的,那么就不能只是简单的将string中的字符进行
相加,而是+=一个字符之后再进行一次额外的运算,这样即使是string中的字符都相同
但是顺序不一样时,最终的结果也会不同
2.5.6key不支持比较相等的问题
因为插入key时需要判断此时哈希表中是否拥有了key,那么就需要key进行比较相等
如果key本身不支持比较相等或者比较相等的逻辑不符合,那么就需要传递一个可调用
对象一般使用仿函数来进行处理
那么对于存储整型就不需要显示去传一个可调用对象比较相等,那么就给比较相等的函数
一个缺省值
2.5.7插入逻辑
当哈希表扩容完毕时,那么就需要计算出插入的数据所映射的位置的下标hash0
通过哈希函数计算出来hash0之后判断该位置是否为空或者删除,如果为空或者
删除那么就在该位置将该数据进行插入,如果是存在,那么就继续向后进行查找
一个空位置进行插入
2.5.8代码

2.6查找,删除
2.6.1查找删除逻辑
先将插入的数据通过哈希函数转换为可以取模的整型,然后再将该整型通过哈希函数计算出
应该存储的位置,如果该位置为空,那么哈希表就没有该数据,如果该位置状态为exist
就看位置存储的数据是否为该数据,如果不是就继续线性探测直到找到状态为空的位置为止
如果位置状态为erase,那么就继续进行线性探测,直到找到状态为空的位置为止
因为如果该数据在哈希表中存在,那么它一定在从[哈希函数计算的位置,状态为空的位置)
之间
2.6.2代码

2.7完整代码

2.8二次探测
2.8.1二次探测意义
线性探测比较简单且容易实现,线性探测的问题假设,hash0位置连续冲突,hash0,
hash1, hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值
都会争夺hash3位置,这种现象叫做群集/堆积。二次探测可以一定程度改善这个问题。
2.8.2二次探测思想
从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的
位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,
则回绕到哈希表尾的位置
公式:
h (key**) = hash0 = key% M, hash0位置冲突了,则⼆次探测公式为:**
hashi= (hash0 ± i^2) % M, i= {1, 2, 3, ...,M / 2 }
二次探测当 hashi= (hash0 − i^2)%M时,当hashi<0时,需要hashi += M
3.链地址法(哈希桶)
3.1链地址法思路(解决哈希冲突思路)
开放定址法中所有的元素都放到哈希表里,链地址法中所有的数据不再直接存储在哈希表中,
哈希表中存储⼀个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位
置时,我们把这些冲突的数据链接成⼀个链表,挂在哈希表这个位置下面,
链地址法也叫做拉链法或者哈希桶
3.2扩容
3.2.1开放定址法负载因子必须小于1,链地址法的负载因子就没有限制了,可以大于1。
负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率
越低,空间利用率越低;stl中unordered_xxx的最大负载因子基本控制在1,大于1就扩容
3.2.2极端场景
如果极端场景下,某个桶特别长怎么办?某个桶很长,查找效率很低怎么办?
这里在Java8的HashMap中当桶的长度超过⼀定阀值(8)时就把链表转换成红黑树
3.2.3基础结构框架
3.2.4扩容逻辑
(1)创建一个新的哈希桶,将原先哈希桶中的数据也就是一个一个结点进行重新映射
在这种情况下,就需要将原先哈希桶中的一个一个结点重新拷贝,并且再将原先哈希桶
中的一个一个结点进行释放,但是哈希桶中本身存储的就是一个一个结点的指针,这些
结点是可以移动的啊,那么还将这些结点重新拷贝再释放很拉低效率,说白了就是闲着
(2)创建一个新的哈希桶,将原先哈希桶中的一个一个结点进行重新映射并且将这些结点
移动到新的哈希桶当中,最后再对两个哈希桶中底层的vector进行交换
(3)由于哈希桶中存储的是结点的指针,也就是哈希桶中存储数据的空间是额外动态开辟
出来的,那么针对于哈希桶就需要实现析构函数,拷贝构造,拷贝赋值
3.2.5构造、析构、拷贝构造、赋值重载代码实现
3.2.6插入逻辑
哈希桶中存储的是链表的地址,那么当需要插入一个key时,通过哈希函数计算出存储的
位置,如果该位置存储的地址是nullptr,那么直接将该存储位置中存储的指针改为新增
结点的指针,如果改位置存储的地址是有效地址,那么在改存储位置所对应的链表上
进行头插即可,因为此时无论是头插还是尾插都可以,但是在单链表中头插的效率明显
高于尾插
3.2.7实现代码
3.2.8find和erase实现

3.3 完整代码

五、哈希总结
1.哈希的本质就是通过哈希函数使得存储的数据有一个对应的存储位置
插入时通过哈希函数计算出该数据存储的位置,查找时根据哈希函数计算出数据存储的位置
2.哈希存储的数据要支持取模,只有整型才可以进行取模运算,也就是说,如果存储的数据
不是整型,那么就需要自己设计一个哈希函数使得该数据可以先映射为一个整型,然后再将
该整型映射到哈希中存储的位置,进行两次映射,不过需要注意的是很多数据可能是内容
相同,只是顺序不一致,那么面对这种情况,就可以在处理该数据一部分之后再进行一种
运算一般是*131,这样就可以使得即使内容不同只要顺序不一致映射出来的整型就不一致
3.哈希在存储时,不同的数据可能会映射到同一个存储位置,这种情况称作哈希冲突
那么面对哈希冲突要求数据是要支持比较相等的,如果该数据不支持比较相等或者是
比较相等的方式不符合我们的逻辑,那么就可以自己定制一个可调用对象,将可调用对象
传递过去,这样就可以通过自己所设计的可调用对象进行比较