【海量数据去重的Hash与BloomFilter】

1. 背景

  • 使用word文档时,word如何判断某个单词是否拼写正确?
  • 网络爬虫程序,怎么让它不去爬相同的url页面?
  • 垃圾邮件过滤算法如何设计?
  • 公安办案时,如何判断某嫌疑人是否在网逃名单中?
  • 缓存穿透问题如何解决?

2. 平衡二叉树

增删改查时间复杂度为 log ⁡ 2 n \log_{2}{n} log2n
平衡的目的是增删改后,保证下次搜索能稳定排除一半的数据(通过使树的高度保持较低);
O ( l o g 2 n ) O(log_2n) O(log2n)的直观理解:100万个结点,最多比较20次;10亿个结点,最多比较30次(n表示结点的个数);
总结:通过比较保证有序,通过每次排除一半的元素达到快速索引的目的。

二分查找_每次搜索排除一半
有序数组
平衡二叉搜索树
AVL
红黑树
平衡多路搜索树
B-树
B+树
多层级有序链表
跳表

搜索过程:首先与根节点进行比较,大于根节点就在右边,小于根节点就在左边(每次比较就能排除一半的结点)。

避免比较:通过函数找到存储的位置。提升搜索效率。

hash的应用
散列表
与平衡二叉树比较
通过比较_结构有序_提升搜索效率
key与结点存储位置的映射关系
组成
hash函数
数组_存储位置
作用:实现映射关系
选择hash
计算速度快
强随机分布性
murmurhash2,cityhash,siphash
操作流程
插入
搜索
冲突
冲突产生原因
负载因子
描述冲突激烈程度,也可描述存储密度
解决冲突
负载因子在合理范围内
链表法或称为拉链法
开放寻址法或成为线性探查
负载因子不在合理范围内
扩容
used大于size
缩容
used小于0.1*size
rehash
hash函数传入参数key,对size取余等于index
翻倍扩容
重新对size取余找到新的位置
stl中散列表的实现
unordered_*
为了实现迭代器,将后面具体结点串成一个单链表
布隆过滤器
背景
内存有限,只想确定某个key存不存在,不想知道具体的内容
某个文件
数据库rocksdb
某个数据库
构成
位图
n个hash函数
怎么操作的
将key送入hash函数再对行列取余数算出多个存储位置
要点
能确定某个key一定不存在,可控假阳率地确定存在
不能删除
根据n和p算出m和k
分布式一致性hash
解决了什么问题
解决分布式缓存扩容的问题
怎么解决的
固定算法
解决缓存失效
目的
避免缓存失效
数据迁移
保证数据均衡
虚拟结点

数组:把key取出,通过hash函数对数组长度取余,就可以得到具体存储在数组中的位置。然后就把这个结点存储在这个位置。

3. hash函数

映射函数Hash(key) = addr; hash函数可能会把两个或两个以上的不同key映射到同一地址,这种情况称之为冲突(或者hash碰撞)。

4. 选择hash

  • 计算速度快(如果计算速度慢,还不如比较key)
  • 强随机分布(等概率、均匀地分布在整个地址空间)(任意一个key通过hash函数落在数组的任意一个槽位的概率差不多,这样才能够确保当数组存储大量元素时,能够让它等概率地、均匀地分布在整个空间中)
  • murmurhash1[速度最快但质量很一般], murmurhash2[质量较好、计算速度较快], murmurhash3[速度最慢、质量最好](业界通常选择的hash函数,通常会选择murmurhash2), siphash(redis6.0当中使用,rust等大多数语言选用的hash算法来实现hashmap), cityhash(使用也很广泛)都具备强随机分布性;测试不同hash算法的效率和质量的地址:链接:link
  • siphash主要解决字符串接近的强随机分布性(role: 10001 role:10002)
  • 不需要看懂hash算法,只需知道如何选择即可。

5. 散列表的操作流程

散列表的结点中kv(key, value)是存储在一起的。

struct node{
  void *key;
  void *val;
  struct node *next;
}

在这里插入图片描述

  • hash函数(key) % (数组长度) = 存放的位置。
  • 因为数组长度是有限的,所以随着存储元素的增多,必然会出现哈希冲突。
  • 哈希冲突:多个不同的key经过hash函数,对数组长度取余得到数组的同一个位置。
  • 如果产生冲突:通过链表链接起来(指针数组)。该指针数组定义多大合适?通常是一个动态增加的过程(每超出容量,翻倍扩张容量:4–>8–>16–>32),随着删除的元素越来越多,会进行缩小。
  • 平衡二叉树通过有序提升效率,散列表牺牲了有序性,是一种映射的关系,没有保证有序性,只是为了查询某个结点存在的位置。

6. 哈希冲突产生的原因

引入:抽屉原理是一种组合数学的基本原理,它指出如果n+1个物品放入n个抽屉中,那么至少有一个抽屉中包含至少两个物品。
所以当数组长度固定时,必然会产生冲突。

7. 通过什么来描述冲突的激烈程度?

  • 负载因子:数组存储元素的个数/数组长度;用来形容散列表的存储密度;负载因子越小,冲突的概率越小,负载因子越大,冲突的概率越大。

8. 冲突处理

8.1 链表法

  • 引用链表来处理哈希冲突,也就是将冲突元素用链表链接起来,这也是常用的处理冲突的方式,但是可能出现的极端情况是:冲突元素过多,冲突链表过长,这个时候可以将这个链表转换为红黑树、最小堆,由原来链表的时间复杂度 O ( n ) O(n) O(n)转换为 O ( l o g 2 n ) O(log_2n) O(log2n)
  • 判断该链表过长的依据是多少?可以采用超过256(经验值)个结点的时候将链表结构转换为红黑树结构或者堆结构(java hashmap)。
  • 使用举例:redis、STL中的unordered_*、java

8.2 开放寻址法

  • 简述:当产生冲突时尝试寻找下一个位置,直到找到可存储的位置为止。
  • 为什么近似值的hash值也近似?
    • 近似值的hash值也近似的原因主要是哈希函数的设计原理。哈希函数的设计目标是将输入数据映射到特定的输出,通常是一个固定大小的数字或字节序列。哈希函数的设计原则是使得输入数据的微小变化能够尽可能地反映在输出上,也就是说,如果输入数据只有微小的差异,那么哈希函数的输出也应该只有微小的差异。

将所有的元素都存放在哈希表的数组中,不适用额外的数据结构;一般使用线性探查的思路解决。

  1. 当插入新元素时,使用哈希函数在哈希表中定位元素位置;
  2. 检查数组中该槽位索引是否存在元素。如果该槽位为空,则插入,否则3;
  3. 在2检测的槽位索引上加一定步长接着检查2;加一定步长分为以下几种:
    • i + 1 , i + 2 , i + 3 , i + 4 , . . . , i + n i+1, i+2, i+3, i+4,..., i+n i+1,i+2,i+3,i+4,...,i+n(这种方式的缺点是:如果有很多key通过hash函数计算得到的槽位产生冲突进行移动后都是落在这附近,就会造成时间复杂度由 O ( 1 ) O(1) O(1)退化到 O ( n ) O(n) O(n),因为一直要往下找)。
    • i − 1 2 , i + 2 2 , i − 3 2 , i + 4 2 . . . i - 1^2, i+2^2,i-3^2,i+4^2... i12,i+22,i32,i+42...这两种方式都会导致同类hash聚集,可就是近似值的hash值也近似,那么它的数组槽位也靠近。第一种同类聚集冲突在前,第二种只是将聚集冲突延后。另外还可以使用双重哈希来解决上面出现hash聚集的现象。

在这里插入图片描述

  • 缩容为了节约空间,扩容为了避免冲突。在扩容或者缩容后要进行rehash。

9. unordered_*四兄弟

9.1 底层都是红黑树的STL数据结构

  • map
  • set
  • multimap
  • multiset

9.2 底层都是散列表的STL数据结构

  • unordered_map
  • unordered_set
  • unordered_multimap
  • unordered_multiset

10. hash map底层的实现

做了一个优化:把多个链表串成一个链表.
STL底层都是基于模板的思想解决问题的,不是面对对象的思想,
在这里插入图片描述
在这里插入图片描述

如何插入一个结点?
最开始的hash map是这样的:只有一个数组和一个头结点,头结点不存储任何数据。
插入一个元素,如何插入?

  • 首先将(key, value)对的key通过hash函数找到一个具体的位置,假设找到4号这个位置。4号结点此时是指向空的。把4号结点指向头结点,把头结点的next指针指向新插入的结点:
    在这里插入图片描述

  • 现在又有一个(key, value)对,映射在4号位置,依然采用头插法,先让4指向这个结点,然后头结点指向这个结点,这个新插入的结点指向上一个插入的结点。
    在这里插入图片描述

  • 如果又来一个新的结点(key, value),key通过hash函数后算出的位置是0,0目前是指向空的, 让这个新插入的结点指向头结点指向的结点,头结点指向这个新插入的结点。
    在这里插入图片描述

10.1 优化点

为了实现迭代器(遍历所有元素),将后面具体结点串成一个单链表。

10.2 源码中的定义

  • Node** _M_buckets(桶子) :数组
  • size_type _M_bucket_count :数组的长度
  • size_type _M_element_coun(used) :实际存储的元素个数
  • RehashPolicy _M_rehash_policy :rehash的一个策略,暂时不用管

11. 总结:散列表需要掌握的知识

  • 与其他结构进行比较(平衡二叉树)
  • 散列表插入、搜索的操作流程。插入先要搜索。
  • 产生冲突。冲突的衡量方法以及解决冲突的手段。

12. 布隆过滤器

12.1 背景

不想知道具体的内容,只想知道这个key存不存在,就可以使用布隆过滤器。布隆过滤器与散列表本质上的原理一模一样,但是为了节约内存,不是用具体的数组,是用一个位图(bitmap)来实现。bitmap也相当于一个数组,但是这个数组的槽位中只有两个状态:0或1。使用位图替换散列表中的数组。

  • 布隆过滤器在内存中,可以快速判断某个key是否在文件中。可以减少对费时间、阻塞的文件IO的操作。
  • 服务器和数据库(MySQL)要进行网络交互,如果要知道这个key是否在MySQL中,不想经过网络交互并且在MySQL中查询。这时候可以在服务器这个地方部署一个布隆过滤器,直接把key在布隆过滤器中查询一下它是不是在MySQL中。

12.2 构成

位图(BIT数组)+n个hash函数
实现方式:
vector<char> bitmapuint64_t bitmap[]
char是一个字节,有8位。
在C++中可以使用byte buf[8]来构建64bit的位图
n = i * 8 + j
在这里插入图片描述

假设hash(key) = 173,如何计算得出存储在位图中的位置?相当于算出第几行第几列。

  1. 173 % 64 = 45
  2. 45 % 8 = 5
  3. 45 / 8 = 5

12.3 原理

在这里插入图片描述

  • 存储:str1通过三个hash函数,再对位图的长度取余,锁定位图上的三个位置,将位图上的这三个位置置为1。
  • 搜索:str2经过这三个hash函数同样会落入这三个位置。如果位置图的这三个位置中有一个为0,肯定不存在。假阳率可控。

当一个元素加入位图时,通过k个hash函数将这个元素映射到位图的k个点,并把它们置为1;当检索时,再通过k个hash函数运算检测位图的k个点是否都为1;如果有不为1的点,那么认为该key不存在;如果全部为1,则可能存在

布隆过滤器不支持删除操作,为什么?

  • 在位图中每个槽位只有两种状态(0或者1),一个槽位被设置为1状态,但不确定它被设置了多少次,也就是不知道被多少个key哈希映射而来以及是被具体哪个hash函数映射而来。

12.4 应用场景

  • 布隆过滤器通常用于判断某个key一定不存在地场景,同时允许判断存在时有误差的情况。
  • 只用2G内存在20亿个整数中找到出现次数最多的数。k:整数,v:出现的次数。统计:用散列表进行统计。

12.5 应用分析

参数如下:

  • n ——预期布隆过滤器中元素的个数,如上图,只有str1和str2两个元素,那么n=2
  • p——假阳率(我们可以接收的),在0~1之间。
  • m——位图所占空间(位图的大小)
  • k——hash函数的个数
  • p和n的关系:随着插入元素的增加,假阳率越来越高
    在这里插入图片描述
  • p和m的关系:随着位图空间的增加,假阳率下降
    在这里插入图片描述
  • p和k的关系:假阳率随着hash函数的个数先降低后增高,在k=31时,假阳率最低(需要数学的验证)。
    在这里插入图片描述

12.5.1 确定n和p

在实际使用布隆过滤器时,首先需要确定n和p,通过上面的运算得出m和k;通常可以在下面的网站上选出合适的值:link

公式如下:(网上有证明思路,这里主要是从应用的角度出发,所以略过)

  • 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))

12.6 选择hash函数

选择一个hash函数,通过hash传递不同的种子偏移值,通过线性探寻的方式构造多个hash函数

#define MIN_UINT64(v) ((uint32_t)((v>>32)^(v)))
uint_t hash1 = MurmurHash2_x64(key, len, Seed);
uint_t hash1 = MurmurHash2_x64(key, len, MIX_UINT64(hash1));
for(int i=0; i < k; i++)
{
	Pos[i] = (hash1 + i*hash2) % m; //m是位图的大小
}

分布式一致性hash

hash扩容后有的结点缓存失效。
为什么要固定算法?因为随着节点的增加会发生缓存失效,原因是被取余数发生改变了。固定被取余数为 2 32 2^{32} 232,即 h a s h ( k e y ) hash(key) hash(key)% 2 32 = i n d e x 2^{32}=index 232=index .
改变一下映射关系:查找结点的映射关系。把具体结点的地址hash到圆环上。
对node进行hash: h a s h ( n o d e ) hash(node) hash(node)% 2 32 = i n d e x 2^{32}=index 232=index
对key进行hash: h a s h ( k e y ) hash(key) hash(key)% 2 32 = i n d e x 2^{32}=index 232=index
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

哈希算法不用研究怎么实现的(数学方面的知识),重要的是从工程的角度看哈希的应用。需要清楚哈希函数的特性、具体的插入、搜索的流程。

13. 问题

大文件:hash拆成小文件

  • 拆分成若干等份:把含有20亿个整数的大文件拆分到多个文件中。20亿个整数可能是散列分布的(第1个整数是10,第10亿个整数也是10),目的:把相同的整数放在同一个文件中。如何确保某个整数肯定落在某个文件中?——某个key通过hash函数 h a s h ( k e y ) hash(key) hash(key)% s i z e = i n d e x size = index size=index得到的index都是一样的。【注意利用hash函数的强随机分布性】

单台机器:hash分流到多台机器

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值