强查找数据结构(3)-- 哈希表

本文介绍了哈希表在海量数据去重中的应用,详细阐述了哈希函数的选择、哈希冲突的解决方法如拉链法和开放寻址法,以及C++ STL中散列表的实现。同时,讨论了布隆过滤器的工作原理,它是如何通过有限的内存空间和多个哈希函数来高效判断key是否存在,以及其假阳率的控制。最后提到了分布式一致性哈希在分布式缓存中的作用。
摘要由CSDN通过智能技术生成

一、 海量数据去重

海量数据去重应该用哪种数据结构呢?
hash 还是 平衡二叉树?
两者的对比:平衡二叉树也是强查找数据结构,它的查找过程是基于数据比较找出key所在节点,节点有序,比较过程近似于二分查找效率高。但是当key是一个字符串或者复杂类型(比如结构体)时,对key进行比较是耗时的操作,尤其是对于海量数据这种耗时操作会显著降低比较效率。为了应对这个问题,解决方案是不再比较key,而是将key映射成地址,以便直接通过key找到数据所在位置。hash正是基于key映射成地址的思路,进行海量数据查找的数据结构。

二、hash的组成

要实现hash的功能,需要包含如下组成部分:

  1. hash函数:实现key与数据存储位置的映射关系, hash(key) = addr。
  2. 数组:存放数据或者存放数据所在链表的指针
    在这里插入图片描述
    图1. 哈希表及其组成

三、哈希函数

hash函数的选择需要满足如下条件:
(1)han函数的计算速度足够快,至少比直接比较key值快;
(2)hash函数对key的映射有强随机分布特性(等概率、均匀分布在整个地址空间),也就是将key映射成的addr是在数组位置的分布是均匀的,避免出现有些数组位置被映射到的概率很高,而有些数组位置被映射到的概率很低。
业界常用的hash函数:
murmurhash1:速度最快,但质量较低;
murmurhash2:速度和质量较高,两者比较平衡,普遍采用这种hash函数;
murmurhash3:速度较慢,但质量最高;
siphash:redis6.0中使用的hash算法,适用于解决字符串接近的强随机分布性(比如:role:1000; role:1001 两组key是一样的,会将其value尽可能均匀分布);rust等语言也会选用该hash算法实现hashmap;
cityhash具备强随机分布性。

四、哈希的操作

查找和插入操作:先根据哈希函数hash(key)将key值计算出一个哈希值,然后用这个哈希值对数组长度取余操作,取余结果就是key映射到数组的地址:hash(key) % arraySize = addr,再从数组addr位置找出或者插入对应的值value。
数组的长度可以是固定的,也可以是动态变化的,通常取4、8、16等。当数据增加时数组长度翻倍,当数据减少时数组长度折半。

五、哈希冲突

  1. hash函数存在的问题:如果有多个不同的key映射到同一个数组地址时会存在冲突,这种冲突称为hash冲突。比如,对于长度为16的哈希数组,17%16 得到的数组地址 和 33%16 得到的数组地址一样,两者存放value的位置发生冲突。当存放的数据越多时,发生哈希冲突概率越高。

  2. 描述哈希冲突激烈程度的参数:负载因子。负载因子其实就是散列表的数据存储密度,数组存储元素的个数used / 数据长度arraySize;负载因子越大,冲突越大。

  3. 解决冲突的方法:
    3.1 负载因子在合理范围内 (0.1 <= used / arraySize <=1 )时,可以采用如下两种方法解决哈希冲突。
    (1) 拉链法/链表法,对冲突位置存放的数据用链表保存。但是这种方法可能会遇到一种极端情况:当冲突元素较多,该冲突链表过长时,此时对链表元素的查询方式依旧要比较key,链表的查询时间复杂度O(n),为了优化可以将该链表转换成红黑树或者最小堆,降低查询时间复杂度。一般当链表长度超过256个节点时,就认为该链表过长需要转换结构。
    业界应用:redis的哈希表实现;STL中unordered_(multi)map/set的实现
    在这里插入图片描述
    图2. 拉链法解决hash冲突

    (2) 开放寻址法/线性探查法
    元素直接存放到数组中,而不是存放到数组指针对应的链表里。如果存放元素时数组的某个位置 i 出现冲突,就进行寻址找到下一个未使用的位置存放数据。
    寻址方式有:
    从 i 往后按照一定的步长进行寻址,i + 1, i + 2, i + 3… , i + n;
    这种寻址方式由于在数据添加时总是从最近的相邻位置插入,因此在数据逐渐增加时,会导致同类哈希聚集。所谓的同类哈希聚集是指,相似数据存储位置聚集在一起,总是在相邻位置。
    在这里插入图片描述
    图3.开放寻址法存放数据

    为了缓解上述哈希聚集的问题,可以对寻址步长进行修改:
    i - 1^2,i + 2^2,i - 3^2,i + 4^2,但当数据增多后依旧会造成哈希聚集,它只是将哈希聚集延后了。

    进一步地,可以采用双重哈希解决哈希聚集的问题,本质是一种线性探查的思路,布隆过滤器也是采用这种思路。

    3.2 负载因子如果不在合理范围内时,当 used / arraySize > 1时采用扩容方式增大数组长度,避免冲突;
    当 used / arraySize < 0.1 时 采用缩容方式减半数组长度,节约空间。
    扩容或者缩容后采用rehash,rehash是因为数组长度发生变化了,hash(key)%arraySize映射得到的addr位置需要重新计算,并将数据存入到新位置中。

六、C++ STL中散列表的实现

C++ STL中使用了散列表的容器,关联容器中的unordered四兄弟,unordered_map / unordered_set / unordered_multimap / unordered_multiset。实现原理图如下:
在这里插入图片描述
图4. STL散列表的实现原理图

从图中可以看到哈希表采用了拉链法解决哈希冲突,但是有所不同的是各个链表直接是之间串联起来的,这是为了STL迭代器能够遍历所有的哈希结点。此外,数组中的每个指针元素并非指向对应链表的首结点,而是指向上一个数组元素的指针对应链表的尾结点。

七、哈希表的应用 – 布隆过滤器

有时需要应对这样的一种场景,只需要知道key是否存在,而无需获取value,比如文件中存在key和value,要知道key是否存在,这时可以使用布隆过滤器提前存入文件的key,当需要判断文件中是否有key时,先查询布隆过滤器中是否有待查询的key值,从而减少文件IO操作提高效率。同理,也可以利用于判断数据库中的key是否存在。

  1. 布隆过滤器的原理
    布隆过滤器本质上是一个哈希表,采用开放寻址法处理哈希冲突。这张哈希表上的每一位只能设置0或1,1表示key对应的值存在,0表示key对应值不存在。这种哈希表的设值方式使得布隆过滤器只需很少的内存就可以存储海量数据。
    布隆过滤器运用多个哈希函数对同一个key值映射到哈希表的多个位置,并在每个位上设置1。
    如图所示,两个key值str1和str2通过分别通过3个hash函数映射到哈希表的三个位置,每个位置置为1。
    当搜索key是否存在时,再次通过相同的哈希函数找到相应的位是否设为了1;
    如果有任意一个位为0表示该key不存在;
    如果所有对应的位全部是1,表示该key可能存在(不一定100%存在,不一定存在是因为可能由于hash冲突,使原本不存在的key对应的位上置为了1,当第一次查找该key时发现其所有映射位都已经为1将其误判为存在),对key存在的误判率称为假阳率p,假阳率是可控的;因此布隆过滤器常用于确定key是否一定不存在。
    注意:布隆过滤器不支持对位点的删除操作,因为有可能多个Key映射到同一个位,如果删除某个位(将该位设为0)将会影响其他key的映射位;或者即使删除了某个位,下次再映射到该位时又会将其置为1,无法找到删除位。
    在这里插入图片描述
    图1. 布隆过滤器原理图

  2. 布隆过滤器的构成
    (1)位图:由于哈希值只有0或1,用一个bite位就可以存储,因此用位图作为哈希表存放数据。位图所占的比特位个数 m。位图可以用数组表示,比如,用byte map[8],构成了64位的位图。
    (2)hash函数:k个hash函数。

通过哈希函数和位图将key值转换成位图中的索引位置,hash(key) % m --> 在位图中的位置,如果是二维位图,需要确定二维坐标 i 和 j。
在这里插入图片描述
图2. 布隆过滤器中的二维位图

  1. 布隆过滤器的使用方法
    (1)在实际使用中,需要确定hash函数的个数k 和 位图空间大小m,确定方法是根据应用场景获取存储key的个数n,获取可以接收的假阳率p(0 ~ 1)。以下四个公式表述了四个参数的关系,根据其中三个参数可以算出另一个参数:
    n = c e i l ( m / ( − k / l o g ( 1 − e x p ( l o g ( p ) / k ) ) ) ) ; n = ceil(m / (-k / log(1 - exp(log(p) / k)))); n=ceil(m/(k/log(1exp(log(p)/k))));
    p = p o w ( 1 − e x p ( − k / ( m / n ) ) , k ) ; p = pow(1 - exp(-k / (m / n)), k); p=pow(1exp(k/(m/n)),k);
    m = c e i l ( ( n ∗ l o g ( p ) ) / l o g ( 1 / p o w ( 2 , l o g ( 2 ) ) ) ) ; m = ceil((n * log(p)) / log(1 / pow(2, log(2)))); m=ceil((nlog(p))/log(1/pow(2,log(2))));
    k = r o u n d ( ( m / n ) ∗ l o g ( 2 ) ) ; k = round((m / n) * log(2)); k=round((m/n)log(2));
    计算工具网站:布隆过滤器参数计算
    布隆过滤器计算
    图3. 布隆过滤器计算工具

(2)假阳率p与其他三个参数的关系
通过计算工具网站给出的三张图,可以看出假阳率和其他三个参数的关系
在这里插入图片描述
图4. 假阳率 p 与存储数据key的个数 n 之间的关系图
当布隆过滤器存入的数据key越多时,假阳率越高。这很好理解,当存入数据越多,发生哈希冲突的概率越高,导致假阳率越高。

在这里插入图片描述
图5. 假阳率p与位图的bite个数 m 之间的关系图
当布隆过滤器位图的bite个数 m 越多时,假阳率p越低。也容易理解,当存放数据的位图越大,发生哈希冲突的概率越低,导致假阳率越高。

在这里插入图片描述
图6. 假阳率p与哈希函数的个数 k 之间的关系图
当布隆过滤器中的哈希函数个数k越多时,假阳率p呈现先低后高的趋势。k在31时假阳率是最低的。这也是很多公式中将k值选为31左右(通常用32)的原因。

八、分布式一致性hash

主要是解决了 在分布式缓存中扩容的问题。
分布式缓存:当key-value量较多,用一个缓存存储key-value数据时效率低,需改用多个缓存存放key-value。
分布式缓存扩容时可能会到导致存放在原本缓存中的数据无法被查询到(缓存失效)。
怎么解决?
固定算法解决缓存失效。后续补充。

文章参考与<零声教育>的C/C++linux服务期高级架构线上课学习

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值