前言
现实生活中,存在很多key_value的模型,我们可以使用哈希
或者红黑树
存储这些数据。但是二者只是内存的存储方式,无法处理海量数据。
海量数据的处理我们可以使用位图
处理。但是位图的局限性是,其只能映射整型
,对于浮点型,字符串无法解决。但是我们可以同哈希表那样,将浮点型,字符串转换成整型
,然后再映射。这样就可以解决问题
这就是布隆过滤器
的原理,其本质还是位图
文章目录
- 前言
- 一. 布隆过滤器
- 二. 布隆过滤器的实现
- 三. 哈希切割
- 结束语
一. 布隆过滤器
布隆过滤器是 哈希+位图
的结合
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数
,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
位图和哈希都是一个值映射一个位置,但是布隆过滤器需要映射字符串,字符串的组合非常之多,所以冲突的可能性很大,但是如果我们映射多个位置,那么误判的几率就降低很多了
如下图:
我们只需要使用多个哈希函数
,将字符串转换成不同的整型映射即可。
但是,布隆过滤器可能存在误判
的情况
因为我们查找一个字符串是否存在,是查看多个哈希函数转换成的整型地址,如果有一个是0,就代表不存在
,因为如果存在,那这几个地址都是1。但是可能出现别的字符串已经映射
到了这些地址,查找出来都是1。
所以布隆过滤器对于一个字符串存在是可能误判的,对于不存在是准确的。
- 应用场景
新用户注册的昵称,就可以应用布隆过滤器。因为昵称是否误判,用户不知道,反之,电话号码就不能只用布隆过滤器,因为是否误判,用户可以知道。
布隆过滤器的实际使用正如其名,起到一个过滤器
的作用。
如果一个数据使用布隆过滤器是不存在,是准确的,如果存在,再去数据库中查找,就保证了正确性。
因为数据库是在磁盘的,数据读取效率较低,先使用布隆过滤器刷选不存在的值,省去去数据库查找的开销。
二. 布隆过滤器的实现
上述我们讲到,布隆过滤器是具有多个哈希函数的位图,此位图与传统位图有所不同。
传统位图只能映射整型类型,整型可大可小,所以无论映射多少数据个数,位图的大小都必须开整型最大值。但是布隆过滤器映射的并不是整型,最常用的是字符型,所以并不需要像传统位图那样开空间。
但是这样就引出两个问题:开多大空间?要几个哈希函数?
很显然,过小的布隆过滤器很快所有的 bit 位均为 1,那么查询任何值都会返回“可能存在”,误判的几率很高。布隆过滤器的长度会直接影响误报率,布隆过滤器越长其误报率越小。
哈希函数的个数越多则布隆过滤器 bit 位置位 1 的速度越快,因为一个字符串会映射多个位置,且布隆过滤器的效率越低;但是如果太少的话,那我们的误报率会变高。
详细分析见详解布隆过滤器的原理,使用场景和注意事项
文章中有这样一张图
该图展示了,在映射数据个数和空间一定是,越多的哈希函数,误判率更低
。
哈希函数也不是越多越好,还需要综合来看:
虽然哈希函数更多,误判率更低,但是每映射一个数据,都需要用多个哈希函数转换,那想必时间也消耗更多。所以不是单纯的哈希函数越多越好
。
接下来的实现,我们使用3个哈希函数,根据公式,开的空间大致是数据个数的4倍。
//哈希函数1
struct BKDRHash
{
size_t operator()(const string& s)
{
// BKDR
size_t value = 0;
for (auto ch : s)
{
value *= 31;
value += ch;
}
return value;
}
};
//哈希函数2
struct APHash
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (size_t i = 0; i < s.size(); i++)
{
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ s[i] ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ s[i] ^ (hash >> 5)));
}
}
return hash;
}
};
//哈希函数3
struct DJBHash
{
size_t operator()(const string& s)
{
size_t hash = 5381;
for (auto ch : s)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
//三个仿函数
template<size_t N,class K=string,
class HashFunc1=BKDRHash,
class HashFunc2=APHash,
class HashFunc3=DJBHash>
class BloomFilter
{
public:
//映射
void set(const K&key)
{
size_t len = N * _X;
size_t hash1 = BKDRHash()(key) % len;
_bs.set(hash1);
size_t hash2 = APHash()(key) % len;
_bs.set(hash2);
size_t hash3 = DJBHash()(key) % len;
_bs.set(hash3);
//可以将这三个哈希地址打印一下
cout << hash1 << " " << hash2 << " " << hash3 << endl;
}
//查找
bool test(const string&key)
{
size_t len = N * _X;
//有一个比特位是0,就是不存在
size_t hash1 = DJBHash()(key) % len;
if (_bs.test(hash1) == false)
return false;
size_t hash2 = APHash()(key) % len;
if (_bs.test(hash2) == false)
return false;
size_t hash3 = DJBHash()(key) % len;
if (_bs.test(hash3) == false)
return false;
//都为1,可能存在,会误判
return true;
}
private:
static const size_t _X = 4;//开空间的倍数
bitset<N*_X>_bs;
};
- 测试
PS:布隆过滤器一般不支持删除,因为映射一个比特位的字符串可能不只一个,删除很可能影响其他的数据。
要支持删除,就需要使用多个位图,标识一个比特位被映射的次数,如果删除,只是减少一次映射次数,直到减为0,才是真正的删除。
但是这样判断数据存在的误判率就更高了,所以布隆过滤器一般都不支持删除
三. 哈希切割
- 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?
query是数据库的查询语句。可以理解为字符串
100亿个query数据量很大,不能直接在内存中存储。所以我们可以将这个大文件切分成很多小文件,比如我们切分成1000个小文件,分为小文件Ai
,和小文件Bi
,而但是这样小文件的内容是不确定的。查找交集,需要比如A1和所有的小文件B查找交集,这样的效率实在太低了。
所以我们可以在切分时做一些改动,我们还是切分成1000份,但是对于每一个query,我们可以进行一个哈希函数的转换,确定其要放置在哪个小文件。这样,A1只要和B1,A2只要和B2匹配交集即可。
这就是哈希切割
但是这样还会面临一个问题,因为本质也是哈希映射,就可能会有冲突,而冲突过多就可能导致小文件的大小膨胀
一个小文件有大量重复的数据
一个小文件有大量不同的数据
如何分辨这两种情况呢?如何解决这两种问题呢?
因为我们已经将文件分割小了,可以尝试使用unordered_set/set
,依次存储该小文件的query。因为set可以对数据实现去重
。而当内存满时,继续存储会抛bad_alloc异常
。
如果出现该异常
,说明是情况2
(有大量不同数据),因为去重没有达成效果。此时我们可以将小文件切分成更小的文件,继续哈希切割
而如果没有出现异常
,说明是情况1
(有大量重复数据),去重达到效果,那么该文件的哈希切割成功
。
- 给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?与上题条件相同,如何找到top K的IP?
该题也类似,先通过哈希函数将IP地址转换成哈希地址,然后切分成小文件,然后依次处理小文件,使用unordered_map/map统计IP地址出现的次数。
如果抛异常,说明冲突太多,还需要继续切分
如果没有抛异常,那么统计成功。
然后汇总所有小文件出现次数最多的IP,最后使用priority_queue获取到TopK
结束语
本篇内容到此就结束了,感谢你的阅读!
如果有补充或者纠正的地方,欢迎评论区补充,纠错。如果觉得本篇文章对你有所帮助的话,不妨点个赞支持一下博主,拜托啦,这对我真的很重要。