从海量数据中查询某字符串是否存在
不论是AVL还是红黑树,在“海量数据”数据面前都是不合适的,因为红黑树会将key,即数据存储起来,而海量的数据会导致内存不足。并且设计到字符串比较,效率也是很慢的。所以在这个需求下,用树相关的数据结构是不合适的。
扩展:
散列表构成:数组+hash函数。它是将字符串通过hash函数⽣成⼀个整数再映射到数组当中(所以散列表不需要”比较字符串“,而红黑树需要),它增删改查的时间复杂度是o(1)。
注意:散列表的节点中 kv 是存储在一起的
struct node {
void *key;
void *val;
struct node *next;
};
扩展:
hash函数:映射函数 Hash(key)=addr ;hash 函数可能会把两个或两个以上的不同 key 映射到同一地址,这种情况称之为冲突(或者hash 碰撞);
hash函数的作用:避免插⼊的时候字符串的⽐较,hash函数计算出来的值通过对数组⻓度的取模能随机分布在数组当中。
如何选取hash函数?
用来形容散列表的存储密度。数组存储元素的个数 / 数据长度;负载因子越小,冲突越小,负载因子越大,冲突越大。
拉链法
引用链表来处理哈希冲突,也就是将冲突元素用链表链接起来,这也是常用的处理冲突的⽅式。但是可能出现一种极端情况,冲突元素比较多,该冲突链表过长,这个时候可以将这个链表转换为红黑树。由原来链表时间复杂度 转换为红黑树时间复杂度 ,那么判断该链表过长的依据是多少?可以采⽤超过 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;
具体原理:https://www.cnblogs.com/organic/p/6283476.html
在 STL 中 unordered_map 、 unordered_set 、unordered_multimap 、 unordered_multiset 四兄弟底层实现都是散列表;
说明:因为STL中需要实现迭代器的原因,所以散列表中所有元素通过链表连在了一起。
hashtable中桶的个数最好为质数,并且在此基础上最好为奇数,这样能够保证数据能够更均匀的分布键到桶中,具体原因可以参考链接:hashtable中桶的数量为什么最好选质数
先来介绍一下bitmap,下面再引出布隆过滤器。现在有一个需求:文件中有40亿个QQ号码,请设计算法对QQ号码去重,相同的QQ号码仅保留一个,内存限制1G。
红⿊树和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,通过上面的运算得出 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
我们发现上面计算出需要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?
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
说明:
[] = {
"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",
}
首先需要有一个map结构(因为map是一个有序的结构)去存储虚拟节点的hash值以及虚拟节点的string(比如"192.168.1.103:6003:1"),所以这个map就应该是map
hash相关的固定套路
以后碰到大文件,马上要想到用hash拆成小文件
碰到单台机器处理不过来,马上要想到用hash分流到多台机器
为什么要使用hash?
对于大文件拆分成小文件这种来说,除了把相同的数据放到同一个位置,还有要想到利用hash的强随机分布性