总体知识脉络
背景
- 使用 word 文档时,word 如何判断某个单词是否拼写正确?
- 网络爬虫程序,怎么让它不去爬相同的 url 页面?
- 垃圾邮件过滤算法如何设计?
- 公安办案时,如何判断某嫌疑人是否在网逃名单中?
- 缓存穿透问题如何解决?
需求
从海量数据中查询某字符串是否存在
平衡二叉树
不论是AVL还是红黑树,在“海量数据”数据面前都是不合适的,因为红黑树会将key,即数据存储起来,而海量的数据会导致内存不足。并且设计到字符串比较,效率也是很慢的。所以在这个需求下,用树相关的数据结构是不合适的。
扩展:
- c++标准库(STL)中的set和map结构都是采⽤红⿊树实现的,它增删改查的时间复杂度是O(log2N)。set和map的关键区别是set不存储val字段。
- 优点:存储效率⾼,访问速度⾼效
- 缺点:对于数据量⼤且查询字符串⽐较⻓且查询字符串相似时将会是噩梦
散列表hashtable
散列表构成:数组+hash函数。它是将字符串通过hash函数⽣成⼀个整数再映射到数组当中(所以散列表不需要”比较字符串“,而红黑树需要),它增删改查的时间复杂度是o(1)。
注意:散列表的节点中 kv 是存储在一起的
struct node { void *key; void *val; struct node *next; };
扩展:
- c++标准库(STL)中的unordered_map<string, bool>是采⽤hashtable实现的
- hashtable中节点存储了key和val,hashtable并没有要求key的⼤⼩顺序,我们同样可以修改代码让插⼊存在的数据变成修改操作
- 优点:访问速度更快;不需要进⾏字符串⽐较
- 缺点:需要引⼊策略避免冲突,存储效率不⾼;空间换时间
hash函数
hash函数:映射函数 Hash(key)=addr ;hash 函数可能会把两个或两个以上的不同 key 映射到同一地址,这种情况称之为冲突(或者hash 碰撞);
hash函数的作用:避免插⼊的时候字符串的⽐较,hash函数计算出来的值通过对数组⻓度的取模能随机分布在数组当中。
选择hash
如何选取hash函数?
- 选取计算速度快
- 强随机分布(等概率、均匀地分布在整个地址空间)
murmurhash1,murmurhash2,murmurhash3,siphash(redis6.0当中使⽤,rust等大多数语言选用的hash算法来实现hashmap),cityhash 都具备强随机分布性。siphash 主要解决字符串接近的强随机分布性,所以如果要hash字符串的话,优先选用siphash。
负载因子
用来形容散列表的存储密度。数组存储元素的个数 / 数据长度;负载因子越小,冲突越小,负载因子越大,冲突越大。
冲突处理
- 拉链法
引用链表来处理哈希冲突,也就是将冲突元素用链表链接起来,这也是常用的处理冲突的⽅式。但是可能出现一种极端情况,冲突元素比较多,该冲突链表过长,这个时候可以将这个链表转换为红黑树。由原来链表时间复杂度 转换为红黑树时间复杂度 ,那么判断该链表过长的依据是多少?可以采⽤超过 256(经验值)个节点的时候将链表结构转换为红黑树结构。 - 开放地址法
将所有的元素都存放在哈希表的数组中,不使用额外的数据结构;一般使用线性探查的思路解决,具体步骤如下:
1. 当插⼊新元素的时,使⽤哈希函数在哈希表中定位元素位置
2. 检查数组中该槽位索引是否存在元素。如果该槽位为空,则插⼊,否则3
3. 在 2 检测的槽位索引上加⼀定步⻓接着检查2
加⼀定步⻓分为以下几种:
1. i+1,i+2,i+3,i+4, … ,i+n
2.
这两种都会导致同类hash聚集,也就是近似值它的hash值也近似。那么它的数组槽位也靠近,形成hash聚集。第⼀种同类聚集冲突在前,第⼆种只是将聚集冲突延后。
3. 可以使⽤双重哈希来解决上⾯出现hash聚集现象
在.net HashTable类的hash函数Hk定义如下:
Hk(key) = [GetHash(key) + k * (1 + (((GetHash(key) >> 5) + 1) % (hashsize – 1)))] % hashsize
在此 (1 + (((GetHash(key) >> 5) + 1) % (hashsize – 1))) 与 hashsize 互为素数(两数互为素数表示两者没有共同的质因⼦)
执⾏了 hashsize 次探查后,哈希表中的每⼀个位置都有且只有⼀次被访问到,也就是说,对于给定的 key,对哈希表中的同⼀位置不会同时使⽤ Hi 和 Hj;
Stl中unordered_*散列表实现
在 STL 中 unordered_map 、 unordered_set 、unordered_multimap 、 unordered_multiset 四兄弟底层实现都是散列表;
说明:因为STL中需要实现迭代器的原因,所以散列表中所有元素通过链表连在了一起。
关于hashtable中桶的数量相关的说明
hashtable中桶的个数最好为质数,并且在此基础上最好为奇数,这样能够保证数据能够更均匀的分布键到桶中,具体原因可以参考链接:hashtable中桶的数量为什么最好选质数
散列表总结
bitmap
先来介绍一下bitmap,下面再引出布隆过滤器。现在有一个需求:文件中有40亿个QQ号码,请设计算法对QQ号码去重,相同的QQ号码仅保留一个,内存限制1G。
- 如果先排序再去重,时间复杂度太高
- 如果用hashmap天然去重,空间复杂度太高
- 文件切割避免内存过大,太麻烦,效率不高
- 使用bitmap,可以顺利地同时解决时间问题和空间问题
一个unsigned int类型,共有32位,可以标识0 ~ 31这32个整数的存在与否。两个unsigned int类型,共有64位,可以标识0 ~ 63这64个整数的存在与否。
那么如果我们把整个整数范围都覆盖了,这样一来1代表第一个位,2代表第二个位,2的32次方代表最后一个位。40亿个数中,存在的数就在相应的位置1,其他位就是0。比如来了一个1234,就找一下第1234位,如果是1就存在,是0就不存在。
所以我们只要有足够的”位“,就可以判断0~4亿这4亿个整数是否存在了。2的32次是4,294,967,296。也就是说我们要有4,294,967,296个位。
4,294,967,296个位=2的32次方个位=2的29次方个字节=512MB。原来32位的整数,转化成了1位的布尔,所以数据空间就是原来的32分之一。
可以看到,使用bitmap不但自动去重,甚至这个需求我们还排了序,从小到大遍历正整数,当bitmap位的值为1时,就输出该值,输出后的正整数序列就是排序后的结果。
海量数据布隆过滤器
红⿊树和hashtable都不能解决海量数据问题,它们都需要存储具体字符串,如果数据量⼤,提供不了⼏百G的内存;所以需要尝试探寻不存储key的⽅案,并且拥有hashtable的优点(不需要⽐较字符串)。而布隆过滤器就刚好满足这一需求,它不需要存储具体字符串,也不需要比较。时间和空间复杂度都低。
介绍
布隆过滤器是一种概率型数据结构,它的特点是高效地插入和
查询,能确定某个字符串一定不存在或者可能存在;
优缺点:布隆过滤器相⽐传统的查询结构(例如:hash,set,map等数据结构)更加⾼效,占⽤空间更⼩,但是确定是它返回的结果是概率性的,结果存在一定的误差,误差可控,同时不支持删除操作
构成:位图(bit数组)+ n个hash函数。
原理
当一个元素加入位图时,通过 k 个 hash 函数将这个元素映射到位图的 k 个点,并把它们置为 1;
当检索时,再通过 k 个 hash函数运算检测位图的 k 个点是否都为 1;如果有不为 1 的点,那么认为该 key 不存在;如果全部为 1,则可能存在;
为什么不支持删除操作?
在位图中每个槽位只有两种状态(0 或者 1),一个槽位被设置为 1 状态,但不确定它被设置了多少次;也就是不知道被多少个 key 哈希映射而来以及是被具体哪个 hash 函数映射而来;
如果想实现删除操作,可以用两个布隆过滤器,将删除的元素,放入第二个布隆过滤器里面,然后查询的时候去第二个里面查,如果第二个里面能查到说明可能被删除(注意也是存在误差的)。
布隆过滤器应用分析
在实际应用中,该选择多少个 hash 函数?要分配多少空间的位图?预期存储多少元素?如何控制误差?
变量关系
n ---- 预期布隆过滤器中元素的个数,如上图 只有str1和str2 两个元素 那么 n=2
p ---- 假阳率,在0-1之间 0.000000
m ---- 位图所占空间
k ----- hash函数的个数
公式如下: n = ceil(m / (-k / log(1 - exp(log(p) / k)))) p = pow(1 - exp(-k / (m / n)), k) m = ceil((n * log(p)) / log(1 / pow(2, log(2)))) k = round((m / n) * log(2))
上面这几个变量关系如下所示:
这个图片可以看出,当hash函数到达31个时,假阳率会最低。
确定n和p
在实际使用布隆过滤器时,首先需要确定 n 和 p,通过上面的运算得出 m 和 k;通常可以在下面这个网站上选出合适的值
https://hur.st/bloomfilter/
现在假设n = 4000,p = 0.000000001。我们可以自己带入公式计算m和k,也可以带入网站计算得出变量值。
n = 4000 p = 0.000000001 (1 in 1000039473) m = 172532 (21.06KiB) k = 30
选择k个hash函数
我们发现上面计算出需要30个hash函数,难道我们要去找30个不同的hash函数来吗,显然不该这样。我们应该选择一个 hash 函数,通过给 hash 传递不同的种子偏移值,采用线性探寻的方式构造多个 hash函数。
// 采⽤⼀个hash函数,给hash传不同的种⼦偏移值 // #define MIX_UINT64(v) ((uint32_t)((v>>32)^(v))) uint64_t hash1 = MurmurHash2_x64(key, len, Seed); uint64_t hash2 = MurmurHash2_x64(key, len, MIX_UINT64(hash1)); // k 是hash函数的个数 for (i = 0; i < k; i++) { Pos[i] = (hash1 + i*hash2) % m; // m 是位图的⼤⼩ } //通过这种⽅式来模拟 k 个hash函数 跟我们前⾯开放寻址法 双重hash是⼀样的思路
题外话,面试百度:hash 函数实现过程当中 为什么 会出现 i * 31?
- i * 31 = i * (32-1) = i * (1<<5 -1) = i << 5 - i;
- 31 质数,hash 随机分布性很好
应用场景
常见的处理场景:① 缓存穿透的解决;② 热 key 限流;
总结
面试题
只用2GB内存在20亿个整数中找到次数最多的数
分布式一致性hash
背景
- 假设服务器只有一个缓存结点,当存储的数据越来越多时,效率就会变得很低,这时就需要增加结点分流分压
- 使用hash来控制节点分布:hash(key) % n,在扩容时出现算法改变:hash(key) % (n+1),会造成大面积的缓存失效(即扩容后,通过新的hash算法得到的结点的存储位置和实际结点的存储位置不一致)问题
原理
1. 映射空间可抽象为一个环,长度为 232,范围为[0, 232-1],每个服务器结点根据hash(node) % 232被映射到这个环上,其中node用ip:port方式来表示,比如用上图中的"192.168.1.100:6000"
2. 判断一条数据属于哪个服务器节点的方法:根据数据哈希值,去哈希环找到第一个机器哈希值大于等于数据哈希值的机器(假设约定按顺时针查找)。如果数据的哈希值大于当前最大的机器哈希值,那么就把这个数据放在位置最靠前(哈希值最小)的机器上
3. 由于实际机器结点往往较少,通过hash算法又具有随机性,容易导致哈希偏移问题(例如目前一共有3台机器,机器A、B的哈希值分别为1和2,而另一个机器C的哈希值为 2^32-1,那么大部分的数据都会被分给机器C)。
4. 因此引入了虚拟节点概念,虚拟节点相当于真实节点的分身,一个真实节点可以有很多个虚拟节点,当数据被分配给这些虚拟节点时,本质上是分给这个真实节点的。数量变多了,机器结点分布的随机性会有所提高,解决了数据结点存储分布不均的问题
5. 新增节点时:例如原本的节点哈希值列表为[1,500,1000,5000],新增节点3000后,在1001~3000范围内的数据原本是分给哈希值为5000的机器节点的,现在要把这部分数据迁移到节点5000,称为哈希迁移,参考分布式一致性hash增加或者删除节点如何进行数据迁移
6. 删除节点:例如原本的节点哈希值列表为[1,500,1000,5000],删除节点1000后,原本范围是501~1000的数据要迁移到节点5000
说明:
- 哈希偏移问题产生原因:
hash具有强随机分布性,说明它是一种概率型的算法,而概率的稳定性是跟样本数有关系的,样本数越多,概率才能稳定,因为实际机器节点往往较少(即样本数较少,会导致概率不稳定),所以很容易导致多台机器的hash值分布不均匀,甚至很可能造成有两个或多个机器的hash值非常接近。 - 如何解决哈希偏移问题?
可以通过增加虚拟结点的方式,比如原本实际节点为
[] = { "192.168.1.100:6000", "192.168.1.101:6001", "192.168.1.102:6002", "192.168.1.103:6003", }
可以通过在端口号后面增加编号的方式:比如改成如下的方式(让每个实际节点虚拟出250个节点),然后对各个节点(包括实际节点和虚拟节点)进行hash%2^32操作,对于这种字符串比较接近的可以考虑siphash的方式对key进行hash的计算,从而让其均匀的分布在圆环上
[] = { "192.168.1.100:6000:1", .... "192.168.1.100:6000:250", "192.168.1.101:6001:1", .... "192.168.1.100:6001:250", "192.168.1.102:6002:1", ... "192.168.1.102:6002:250", "192.168.1.103:6003:1", ... "192.168.1.103:6003:250", }
- 增加虚拟节点解决了什么问题?
- 解决hash偏移问题(节点增加会提高hash强随机分布中概率的稳定性(因为概率的稳定性是和样本数量息息相关的))
- 减少了hash迁移的数量(因为圆环上节点之间分布的很密集)当增加虚拟节点之后,如果后面出现扩容的情况,那么需要迁移的数据也会变少(数据迁移过程中,整个系统是不能提供服务的,直到数据迁移结束才行,所以迁移数量的减少会让系统不能提供服务的时间变短,系统的可用性就会变强)
- 怎么根据数据的key判断最终存储在哪个分布式存储服务器上?
首先需要有一个map结构(因为map是一个有序的结构)去存储虚拟节点的hash值以及虚拟节点的string(比如"192.168.1.103:6003:1"),所以这个map就应该是map<int, string>这种类型,类似map<hash(“192.168.1.103:6003:1”) % 2^32, “192.168.1.103:6003:1”>这种结构,然后根据数据的key去找到刚好大于这个key的虚拟节点的迭代器(通过调用map.upper_bound(key)),然后根据迭代器的->second取出类似"192.168.1.103:6003:1"的虚拟节点,最后对"192.168.1.103:6003:1"去sub操作取出服务器的ip地址和端口号即可。
- hash相关的固定套路
以后碰到大文件,马上要想到用hash拆成小文件
碰到单台机器处理不过来,马上要想到用hash分流到多台机器 - 为什么要使用hash?
对于大文件拆分成小文件这种来说,除了把相同的数据放到同一个位置,还有要想到利用hash的强随机分布性
应用场景
- 分布式缓存;将数据均衡地分散在不同的服务器当中,用来分摊缓存服务器的压力;
- 解决缓存服务器数量变化尽量不影响缓存失效;
总结