c++进阶——位图、布隆过滤器
文章目录
- 哈希表的进阶使用
- 位图的使用
- 位图的引入
- 实现位图的思路
- 位图代码实现
- 基本框架
- 核心接口
- 合并代码
- 基础位图的优缺点
- 布隆过滤器
- 布隆过滤器的介绍
- 布隆过滤器的指标
- 布隆过滤器的实现
- 基础框架
- 核心接口的实现
- 合并代码
- 布隆过滤器的误判率测试
- 测试代码
- 布隆过滤器的应用
- 总结与思考
哈希表的进阶使用
在学习完哈希表后,我们已经基本明白了哈希的概念。已经哈希表的使用方法。现在我们来针对哈希表的使用做一些进阶的学习。
位图的使用
第一个部分就是位图。这个在c++标准库中是有这个数据结构的,即std::bitset。
何为位图呢?直接说肯定死不明白的。我们需要引入一个例子来看才能明白。
位图的引入
假设现在有40亿个无符号整型存储在一个文件当中。注意这里的无符号整形是unsigned int,在x64平台下是8字节,在x86平台下是4字节。我们这里实现的位图是在x86平台下实现的。选择x64平台也可以,只不过在定位图大小的时候可能会有一些麻烦。这些后面讲。
这40亿个无符号整形,假设在x86平台下是4个字节,四十亿个无符号整型就是160亿个字节,我们知道:1GB = 1024MB = 1024 * 1024 KB = 1024 * 1024 * 1024Byte。所以经过换算,大约直到这这40亿个无符号整形需要占用15GB的内存。这是十分恐怖的。栈区的空间是很少的。堆区是我们能操作的最大的空间。通常来讲也就是4GB。所以是不可能把这一堆数据通通地导入进来然后存储在一个哈希表或者红黑树中的。这时候怎么办呢?
这个时候就得想到,我们要做的事情是什么。是判断一个数是否存在。也就是数据完全就不需要存储在容器当中国。只需要记录状态就好了。有什么办法能尽可能的减少占用空间,还能表示在与不在的状态呢?这时候我们灵光乍现一下,这不正好很像二进制表示高电平和低电平嘛?正好我们可以只使用一个比特位存储。一个比特位已经是计算机系统中能够使用到的最小的内存单位了。所以我们来探索一下这一条思路。
如果把40亿个数据映射到比特位上,那我们就不需要开你妈的的空间了。一个无符号整型字节 = 32个比特位。四十亿个数据,也就是四十亿个比特位,经过换算也就是0.46~0.47GB的内存。这些内存是完全可以接受的。因为堆上空间完全足够处理这些比特位的空间。所以这条思路是可行的。接下来就需要直到如何进行映射这些数据到比特位上。
实现位图的思路
上面的引入部分证明了位图这一种实现思路是可行的,而且大大降低了需要使用的空间。
现在我们就需要知道,如何实现位图呢?
假设现在开了如下这么多个比特位。如果给定一个数X,怎么样能够找到它对应的第X个比特位呢?这个时候就需要根据每个字节的特性来操作。我们知道,每个字节是有32个比特位的。也就是说,每32个分为一组。我们可以先找到第几组,然后再具体的在这一组进行搜索。
假设现在给一个数字70,先找到它在第几组:size_t i = 70 / 32 == 2,也就是说,其实第70个比特位是存储在第二组。可是看图是第三组啊,需要让i再+1吗?答案是不需要。因为虽然这里说是位图,但是实际上是一个个整形。只不过我们把每个整形拆分成一个个的比特位来看而已。这些整形都是存放在数组当中的。那要找到第70个比特位存放的位置,那不正好是下标为2的那一组吗?(数组下标从0开始)。所以直接让X / 32就能得到是第几组。
前面只是确定了是哪一组,现在还得确定在这一组的哪一位。这其实很简单,对32取模不就好了,比如70 % 32 == 6,处于从低位往高位数的第6位。还是以70作为例子,第2组中前面几个坐标是:64 65 66 67 68 69 70,乍一看,这是第七个。但是道理还是一样的,第一个位置称为第0位。至于具体为什么这么做,我们先做个铺垫。在后面实现核心接口的时候会说到为什么要这样子做。
位图代码实现
这个部分,我们来重点实现以下位图这个结构
基本框架
template<size_t N>
class bitset {
public:
//核心接口(待实现)...
private:vector<int> _bs;
};
一般来讲,实现位图的时候,底层空间要开多少个比特位是需要预先确定的。所以这里可以给一个非类型的模板参数N,用来控制底层空间大小。标准库里面也是这么玩的,只不过标准库实现的底层是一个静态数组,这个数组默认是在栈上开辟空间的。如果需要处理大量数据就会出现问题,需要手动的将数据转移到堆上去。
但是使用vector是很方便的,这种适配器模式我们已经使用过很多次了,是非常爽的,上层调用接口就行了。而且vector默认是在堆上开空间。虽然数据量过大的时候还是会导致空间不够抛异常,但是总归是比栈上好太多。
核心接口
位图的核心接口其实就如下三个:
- bitset() //默认构造函数
- void set(size_t x);
- void reset(size_t x);
- bool test(size_t x);
接下来我们会一个个的进行实现。
默认构造函数:
实现一个默认构造就可以了。虽然说底层的vector有默认构造,是可以不用在bitset这一层来写默认构造了。但是vector的默认构造不太符合要求。通常位图用来测试数据是否存在的时候,是会知道有多少个值的。对于bitset的构造函数来说,我们希望的是能够通过测试的数据个数N来开辟N个比特位。也就是底层的vector开辟一些空间,至少有N个比特位。
所以我们需要计算一下N个数字测试,也就是需要N个比特位。但是vector的底层一个整形是四个字节,我们需要规划一下底层vector的空间个数。
加入N个比特位正好是32的倍数,那非常好办,直接N/32个空间就可以了。加入N不是32的倍数呢,N/32必然会有余数。不过这个余数必然小于32,所以多开一个整形的空间也足够了。
但是无论什么情况,直接开N/32 + 1个空间就可以了。哪怕最后浪费一个空间也无妨,也就浪费四个字节罢了,这种消耗完全可以接受:
bitset() {_bs.resize(N / 32 + 1);
}
直接调用一下vector的resize接口就好了,这非常爽。
set接口:
set接口的道理很简单,就是将第x个比特位置成1,也就代表记录x这个数存在。这个如何实现呢?我们一起来看看:
首先我们得先找到这个比特位,找到的方式非常好办,我们规定两个数i、j,表示x处于vector的位置。i = N/32, j = N%32,表示数X处于位图的第i组的第j位(从低位往高位数)。
假设我们当前已经找到这个位置,应该怎么操作才能让这一位变成1,其它的不变呢?
按照这个图操作就好了,就找到一个数,这个数的对应位为1,其余位均为0。然后这个整形与这个数字按位或一次,再把这个值重新赋值给数组中的那个整形。这不就完成了set操作吗?
只不过说,这个数应该怎么找是需要研究一下的。其实很好找。不就是1这个数字进行左移操作吗?只不过这里会有人提出一个问题,就是大小端存储会不会影响这里的操作行为。
小端存储时低位内容存储在低地址,高位存储在高地址。
大端存储时低位内容存储在高地址,高位存储在低地址。
但是无论怎么放也好,我们要找到那个比特位就是从低位往高位数的。而且左移操作的本质不是数位往左边移动,而是往高位移动。只不过底层实现的时候,每个整形的四个字节是倒过来写的。那麻烦的是编译器本身。它可能需要进行一些操作才能找到下一个位。但是我们这里关注的就是从低位到高位。不需要关注这个底层的。很多人担心这个问题的原因是底层低地址到高地址可能是图中的从左到右,也可能是从右到左。但是无所谓啊,我们要关注的是从低位到高位而已。底层怎么放和我们无关。
还是以70为例子:
这里的比特位和上面的反着放的。但是无所谓,因为数据无论是大端还是小端,操作的都是低位到高位。对于70,处于第2组的第6个位置,让1左移6再按位与,就能达到set的效果了。所以到这里,我们就大致知道了set接口的实现了:
void set(size_t x) {size_t i = x / 32, j = x % 32;_bs[i] |= (1 << j);
}
这里一定是或等而不是单纯的或。因为要改动原来位图中的数据。
reset接口:
reset和set是相反的,就是把第x位置为0。
这个时候的操作又该如何呢?
既然结构的行为是相反的,那底层的操作逻辑就可以猜测一下是否是相反的。
其实很好理解,对于set接口来讲,为了让某一位无论如何变成1,我们就很容易想到任何数与1进行或操作都是1,其余位要保持不变,又是或操作,发现其他位为0就可以了。
对于reset接口,正好相反,为了让某一位无论如何变为0,与操作和0进行与得到的结果必然为0,其他位为1即可。二者的行为正好是相反的。
void reset(size_t x) {size_t i = x / 32, j = x % 32;_bs[i] &= (~(1 << j));
}
test接口:
第三个接口是test接口,即判断x对应的位是否为1,这个也就是判断x这个数在不在。
要怎么样返回正确的、对应的返回值呢?
我们还是从位操作进行思考,假设要找的那一位是第i组的第j位,那么我们现在希望的是能够把那一位提取出来。假设保证要找的那一位是1,按位与操作后,如果它本身这一位是1,那与出来的结果就是1,反之是0。这就找到差异了。但是其他位上应该如何操作呢?
我们发现,如果其他位与0进行与操作,得到的全是0。至此,得到两种情况:
如果第x位是0,按位与的结果是0
如果第x位是1,按位与后得到了一个数,这个数第x位是1,其余为0。但是可以确认的是,这个值必然不是0。所以这不就刚好能作为bool类型的返回值了嘛?
bool test(size_t x) {size_t i = x / 32, j = x % 32;return _bs[i] & (1 << j);}
这些代码都十分简单,只需要理解清楚原理就能快速实现出来。当然bitset中也不只是这些接口,比如filp接口,有两个版本,一个是对具体的位进行取反,一个是对位图中所有的位取反。本质上都是可以通过位运算实现的。在这里就不一一展示了,重点还是掌握前面的几个接口。
合并代码
namespace myspace {template<size_t N> class bitset {public:bitset() {_bs.resize(N / 32 + 1);}void set(size_t x) {size_t i = x / 32, j = x % 32;_bs[i] |= (1 << j);}void reset(size_t x) {size_t i = x / 32, j = x % 32;_bs[i] &= (~(1 << j));}bool test(size_t x) {size_t i = x / 32, j = x % 32;return _bs[i] & (1 << j);}bitset<N>& flip(size_t pos) {//将pos位置按位取反size_t i = pos / 32, j = pos % 32;_bs[i] ^= (1 << j);return *this;}bitset<N>& flip() {//所有位取反for (size_t i = 0; i < _bs.size(); ++i) {_bs[i] = ~_bs[i];}return *this;}private:vector<int> _bs;};template<size_t N>class Twobitset {public:void set(size_t x) {bool bit1 = _bs1.test(x);bool bit2 = _bs2.test(x);//0 0出现0次//0 1出现1次//1 0出现2次//1 1出现>=3次if (!bit1 && !bit2) {//0 0 -> 0 1_bs2.set(x);}else if (!bit1 && bit2) {//0 1 -> 1 0_bs1.set(x);_bs2.reset(x);}else if (bit1 && !bit2) {//1 0 -> 1 1_bs2.set(x);}}//获取出现次数int get_count(size_t x) {bool bit1 = _bs1.test(x); bool bit2 = _bs2.test(x); if (!bit1 && !bit2) {//0 0return 0;}else if (!bit1 && bit2) {//0 1return 1;}else if (bit1 && !bit2) {//1 0return 2;}else return 3;}private:bitset<N> _bs1; bitset<N> _bs2;};}
前面一个部分是bitset的代码,我们可以用它来测试大量数据是否存在的问题。当把-1赋值给N的时候,N就是无符号整形的最大值(把-1的全1补码给一个无符号整形)。当然如果要传这个无符号整型的最大值还可以传0xffffffff,又或是UINT_MAX这个经过宏定义的无符号整型最大值。
这里还添加了一个部分的类,叫Twobitset,用来干什么的呢?这是用来处理寻找多次出现的数据的。假设要找出只出现2次的数据呢?如果要找出现2次及以上的数据呢?用上面的那个基础的位图肯定是没办法做到的。
我们可以用两个比特位来表示:
00表示出现0次,01出现1次,10出现2次,11出现>=3次。
当然可以控制在一个位图中操作,把每两个当成一个整体。但是那样写还是有点麻烦,直接复用一下位图的逻辑不就好了,让两个基础位图来表示上面的各种状态就好了。具体代码实现很简单,在这里就不细讲了,感兴趣的可以去看看。
基础位图的优缺点
这个基础位图还是有一些优缺点的,我们一起来看看:
优点:对于判断数据是否存在的效率非常高,占用空间小。访问和修改效率高
缺点:只能操作整形,如果碰到字符串或者是其它的数据类型就歇菜了。
针对于这些问题,后面会讲解新的解决方案。
布隆过滤器
前面我们说到,对于普通的位图,是只能针对于整形进行操作的。如果现在换成一系列字符串中来判断是否存在呢?这个场景十分常见,比如爬虫获取网站数据的时候。我们很多时候需要判断一下某个网站是否被搜索过。又或是我们登录某个网站的信息,输入账号密码的时候后台数据库需要在数据库中进行检索是否有我们的登录信息。
针对于这种情况,如果还是使用位图的话,那就需要进行哈希映射了。比如某个字符串,经过转整形,再取模就能得到它对应的位处在的位置。但是这里和整形不一样,整形数据本身就是整形,不需要经过转整形和取模的操作就能找到对应的位,且开辟了足够空间,这就导致对于整形数据而言,一个数据是对应着一个位,不存在冲突。
字符出啊怒就不一样了,字符串会有转整形和取模操作才能找到对应的位,这就必然会有冲突。有冲突应该怎么办呢?这个时候就需要用到这个部分要讲解的——布隆过滤器了。
布隆过滤器的介绍
布隆过滤器是一个叫布隆的科学家设计出来的,这个过滤器的底层还是位图,只不过针对于其他数据类型,映射的操作不一样。前面基础位图都是整形和对应位一一映射,但是其他数据可能会存在冲突,所以一一对应是不行的。所以布隆过滤器就提出了,一个数据通过多个哈希函数映射到不同的位上,比如:
假设一个字符串映射三个位,也就是说,对于一个字符串来讲,要判断它在不在,要看它映射的三个位置是不是都为1。如果是才存在,反之认为不在。但是这样做一定不会发生误判吗?有没有可能会导致误判呢?
假设出现上面这种情况,唐僧不存在,但是经过映射后发现三个位都是1,如果这个认为存在那就发生误判了。判断不存在只需要三个位置有1个0就可以。如果存在,那它对应的位置一定是被映射成1了。所以不存在是不会发生误判的,只有判断存在才会发生误判。
所以误判率是布隆过滤器的一个很重要的指标。是否能够通过改进来让布隆过滤器的误判率为0,也就是完全准确的呢?
答案是这很困难,也可以说是不太可能的。中间的一系列操作导致了这个过滤器必然是会出现误判的情况,对于布隆过滤器,只能说是尽可能地降低误判率。
布隆过滤器的指标
布隆过滤器地最关键的指标就是误判率,我们现在来稍微讲解一下:
首先先规定几个量:
n为存储的数据个数(查找的个数)
m为底层位图开辟的位的个数
k为选取的哈希函数的个数(也代表着一个数据映射在位图中的位数)
设布隆过滤器的误判率为p,p(n,m,k) = (1 − e−kn/m)k = f(k)
这个推导过程是比较复杂的,需要用到许多数学知识,感兴趣的可以自行去搜索推导过程。
由误判率公式大致可知:当k不变的时候,存储的数据个数n越多,误判率越大。如果底层的位个数m越多,那误判率越小。这个其实很好理解。存储的数据越多,那某个位被重复利用的次数也就越多,这就很容易导致误判的发生。但是反过来如果位数组长度越大,那映射的位置会更分散,误判率就会降低。
当m、n确定的时候,对上述f(k)求导,利用数学知识得到:
当k = (m/n) * ln2的时候,能够达到最低的误判率。也就是通过这个式子来控制哈希函数个数。
反之还可以上面两个公式的结合得到m = - (n * lnp) / (ln2)2,确定数组位长度。
通常来说,是通过设置当前布隆过滤器最低能够接受的误判率,结合存储的数据个数n,来控制底层位数组的长度m。控制了m,n后,就在m和n确定的情况下,得到k = (m/n) * ln2的时候,误判率是可以达到最低的。这样子就完成了布隆过滤器的指标设置。
布隆过滤器的实现
接下来我们就来简单的实现一下布隆过滤器
基础框架
正常来讲,布隆过滤器的设计应具备可调节性,即通过设定误判率p来动态调整其他关键参数。但是这里在此就不这么麻烦的操作了,我们先确定各项指标,最后再来测算误判率。
// BKDR Hash 算法 (一种简单高效的哈希算法,种子为31/131/1313/13131/131313等)
struct BKDRHash {size_t operator()(const string& str) {size_t hash = 0;size_t seed = 131; // 31 131 1313 13131 131313 etc..for (char c : str) {hash = hash * seed + c;}return hash;}
};// AP Hash 算法 (Arash Partow发明的一种哈希算法)
struct APHash {size_t operator()(const string& str) {size_t hash = 0;for (size_t i = 0; i < str.size(); ++i) {if ((i & 1) == 0) {hash ^= ((hash << 7) ^ str[i] ^ (hash >> 3));}else {hash ^= (~((hash << 11) ^ str[i] ^ (hash >> 5)));}}return hash;}
};// DJB Hash 算法 (Daniel J. Bernstein发明的哈希算法)
struct DJBHash {size_t operator()(const string& str) {size_t hash = 5381;for (char c : str) {hash = ((hash << 5) + hash) + c; // hash * 33 + c}return hash;}
};// FNV Hash 算法 (Fowler-Noll-Vo哈希算法)
struct FNVHash {size_t operator()(const string& str) {size_t hash = 2166136261U;for (char c : str) {hash = (hash * 16777619) ^ c;}return hash;}
};// SDBM Hash 算法 (在SDBM项目中使用的哈希算法)
struct SDBMHash {size_t operator()(const string& str) {size_t hash = 0;for (char c : str) {hash = c + (hash << 6) + (hash << 16) - hash;}return hash;}
};//X == M/N 布隆过滤器的空间个数/存储的数据个数
template<size_t N, size_t X = 5, class K = string,
class Hash1 = BKDRHash,
class Hash2 =APHash,
class Hash3 = DJBHash>
//Hash算法选前三个
//当然可以根据理论去设计比较灵活的bloomfilter
//但是这里只是为了理解原理所以实现简单点再测试就好了class bloomfilter {
public:
//核心接口void Set(const K& key) {}bool Test(const K& key) {}//False Positive Rate (FPR) ——误判率//布隆过滤器的误判率公式: ((1 - e^(-kn/m)))^kdouble BloomFliter_FPR() {}private:size_t M = N * X;bitset<N * X> _bf;
};
模板参数里面有一个N,这个是数据的个数。我们在这里将M/N的比值设定为X,默认为5。为什么不传M和N两个参数而是N和X呢?
因为通常来说,M是通过N来确定的。真正实际应用的时候,是通过期望误判率p和数据个数N一起来控制M的大小。也就是说,M的大小并不需要我们人为的控制,而是根据实际情况来确定的,是过滤器这一层来决定的,所以传入一个比值X更为合适。
当然需要测试的数据集群的类型有很多种,但是布隆过滤器最常用的就是针对于字符串的使用。所以设置默认数据类型为string,默认给三个哈希函数。
关于哈希函数的具体实现,可以通过各大技术博客平台或算法论坛进行查询。本文选取了三个较为常用的算法进行介绍,也可以根据实际需求自行选择其他算法。
默认的比值X == 5,因为当M/N = X = 5的时候,k = (M/N) * ln2 ≈ 3.5,选取三个哈希函数。
核心接口的实现
void Set(const K& key) {size_t bit1 = Hash1()(key) % M;size_t bit2 = Hash2()(key) % M; size_t bit3 = Hash3()(key) % M;_bf.set(bit1); _bf.set(bit2); _bf.set(bit3);
}bool Test(const K& key) {size_t bit1 = Hash1()(key) % M;if (!_bf.test(bit1)) return false;size_t bit2 = Hash2()(key) % M;if (!_bf.test(bit2)) return false;size_t bit3 = Hash3()(key) % M;if (!_bf.test(bit3)) return false;//这个返回为真不一定是真的存在 可能是误判return true;
}//False Positive Rate (FPR) ——误判率
//布隆过滤器的误判率公式: ((1 - e^(-kn/m)))^k
double BloomFliter_FPR() {double ret = pow(1 - pow(2.71, -3.0 / X), 3);return ret;
}
有了前面基础位图的实现过程,实现布隆过滤器的核心接口就不是什么难事了。
合并代码
namespace myspace {// BKDR Hash 算法 (一种简单高效的哈希算法,种子为31/131/1313/13131/131313等)struct BKDRHash {size_t operator()(const string& str) {size_t hash = 0;size_t seed = 131; // 31 131 1313 13131 131313 etc..for (char c : str) {hash = hash * seed + c;}return hash;}};// AP Hash 算法 (Arash Partow发明的一种哈希算法)struct APHash {size_t operator()(const string& str) {size_t hash = 0;for (size_t i = 0; i < str.size(); ++i) {if ((i & 1) == 0) {hash ^= ((hash << 7) ^ str[i] ^ (hash >> 3));}else {hash ^= (~((hash << 11) ^ str[i] ^ (hash >> 5)));}}return hash;}};// DJB Hash 算法 (Daniel J. Bernstein发明的哈希算法)struct DJBHash {size_t operator()(const string& str) {size_t hash = 5381;for (char c : str) {hash = ((hash << 5) + hash) + c; // hash * 33 + c}return hash;}};// FNV Hash 算法 (Fowler-Noll-Vo哈希算法)struct FNVHash {size_t operator()(const string& str) {size_t hash = 2166136261U;for (char c : str) {hash = (hash * 16777619) ^ c;}return hash;}};// SDBM Hash 算法 (在SDBM项目中使用的哈希算法)struct SDBMHash {size_t operator()(const string& str) {size_t hash = 0;for (char c : str) {hash = c + (hash << 6) + (hash << 16) - hash;}return hash;}};//X == M/N 布隆过滤器的空间个数/存储的数据个数template<size_t N, size_t X = 5, class K = string, class Hash1 = BKDRHash, class Hash2 =APHash,class Hash3 = DJBHash>//Hash算法选前三个 //当然可以根据理论去设计比较灵活的bloomfilter 但是这里只是为了理解原理所以实现简单点再测试就好了class bloomfilter {public://上面的仿函数是类型 真正要调用的时候需要构造对象(实例化 匿名)void Set(const K& key) {size_t bit1 = Hash1()(key) % M;size_t bit2 = Hash2()(key) % M; size_t bit3 = Hash3()(key) % M;_bf.set(bit1); _bf.set(bit2); _bf.set(bit3);}bool Test(const K& key) {size_t bit1 = Hash1()(key) % M;if (!_bf.test(bit1)) return false;size_t bit2 = Hash2()(key) % M;if (!_bf.test(bit2)) return false;size_t bit3 = Hash3()(key) % M;if (!_bf.test(bit3)) return false;//这个返回为真不一定是真的存在 可能是误判return true;}//False Positive Rate (FPR) ——误判率//布隆过滤器的误判率公式: ((1 - e^(-kn/m)))^kdouble BloomFliter_FPR() {double ret = pow(1 - pow(2.71, -3.0 / X), 3);return ret;}private:size_t M = N * X;bitset<N * X> _bf; };
}
布隆过滤器的误判率测试
简单的布隆过滤器就实现完成了,但是我们最好还是来测试一下它的一些性能指标。
测试样例将分类为如下:
第一大类:相似字符串的误判率测试:
改变数据个数N(100000 1000000 10000000)
改变M/N的比值(5 7 10)
第二大类:非相似字符串的误判率测试:
改变数据个数N(100000 1000000 10000000)
改变M/N的比值(5 7 10)
数据量较大的情况下,在debug版本下测试太慢了,所以通通采用release版本进行测试。
采用控制变量法,一次只能改变一个参数的一个值。
测试代码
//相似字符串的测试:
int main() {string url = "https://blog.csdn.net/2301_79705195/article/details/147960992?spm=1001.2014.3001.5501";//const size_t N = 100000;const size_t N = 1000000;//const size_t N = 10000000;//M/N == X == 5//myspace::bloomfilter<N> bf1;//M/N == X == 7//myspace::bloomfilter<N, 7> bf1;//M/N == X == 10myspace::bloomfilter<N, 10> bf1;vector<string> Vstr(N);//生成一堆字符串 并存到vector中for (size_t i = 0; i < N; ++i) {string URL1 = url + to_string(i);Vstr.push_back(URL1);}//将上述字符串映射到bloomfliterfor (auto& str : Vstr) {bf1.Set(str);}//清除 为了存储另一波不一样的字符串//再开一个vector可能内存不够Vstr.clear();//生成一堆和上面类似(前缀相同 后缀不同的字符串)for (size_t i = 0; i < N; ++i) {string URL2 = url + to_string(9999999 + i);Vstr.push_back(URL2);}//第二波生成的字符串必然是和第一波没有任何重复的//将第二波的测试一下在不在 如果测试出来是在,就是误判size_t FalseNum = 0;for (auto& str : Vstr) {if (bf1.Test(str)) ++FalseNum;}double false_rate = (double)FalseNum / N;cout << "公式测算误判率:" << bf1.BloomFliter_FPR() << endl;cout << "相似字符串误判率(统计计算)" << false_rate << endl;return 0;
}
上面是相似字符串的测试,就是先将一个字符串构造出一系列不重复的字符串放在一个vector中,在布隆过滤器中记录后,就清除这个vector(数据量太大,再开一个vector可能不够内存),再去构造出与前面的设置的字符串集群完全不重复的新的字符串集群。然后再来调用Test接口判断新的字符串集群是存在。因为是完全没有交集的两个集群,所以测试出来是存在的话必然是误判了。然后分别用公式测算(过滤器的接口),和统计出来的次数进行测算。
//非相似字符串的测试
int main() {//const size_t N = 100000;//const size_t N = 1000000;const size_t N = 10000000;string ur1 = "https://blog.csdn.net/2301_79705195/article/details/147960992?spm=1001.2014.3001.5501";string ur2 = "猪八戒";//M/N == X == 5//myspace::bloomfilter<N> bf1;//M/N == X == 7//myspace::bloomfilter<N, 7> bf1;//M/N == X == 10myspace::bloomfilter<N,10> bf1;vector<string> Vstr(N);for (size_t i = 0; i < N; ++i) {string UR1 = ur1 + to_string(i);Vstr.push_back(UR1);}for (auto& str : Vstr) {bf1.Set(str);}Vstr.clear();for (size_t i = 0; i < N; ++i) {string UR2 = ur2 + to_string(i + 999999);Vstr.push_back(UR2);}size_t FalseNum = 0;for (auto& str : Vstr) {if (bf1.Test(str)) ++FalseNum;}double false_rate = (double)FalseNum / N;cout << "公式测算误判率:" << bf1.BloomFliter_FPR() << endl;cout << "相似字符串误判率(统计计算)" << false_rate << endl;return 0;
}
和上面的道理是一样的,经过大量的测试发现,随着参数的各种变化。误判率基本上处于1%~10%这个区间。测试代码就放在这里了,感兴趣的读者可以拷贝到本地编译器运行一下。
但是布隆过滤器一般是不支持删除操作的,因为删除一个可能会导致影响到其它的数据。这个自行画图理解就知道了。当然有些会使用引用计数的方式,这样能大大减少误删二段概率。但是终归是有误删的风险在。所以就不实现reset接口了。
布隆过滤器的应用
当然布隆过滤器是有很多方面的应用的:
- 预防缓存穿透
在分布式缓存系统中,布隆过滤器可以用来解决缓存穿透的问题。缓存穿透是指恶意用户请求一系列不存在的数据,导致请求直接访问数据库。而数据库的数据量太庞大,搜索起来是比较耗费时间和降低效率的。但是使用布隆过滤器就可以很好的解决这个问题。
比如这是一个缓存系统,缓存里曼放的一般都是比较常用的数据。假设现在这个是登陆系统,用户输入账号密码后,系统会先看看缓存里面是否有这个数据,有就直接返回。没有的话进需要进入数据库检索。若是在数据库中找到了,那就在返回网络层的同时,将这个数据标记为热数据,加载到缓存中。以便下次使用能够快速返回这个数据到网络层。
但是如果在数据库中搜索出的结果是不存在,就返回网络层告知用户。但是数据库的数据量十分庞大,检索出来是不存在的效率很低。所以会有恶意的用户请求一系列不存在的数据,这就会让数据库一直在做低效的检索。如果恶意请求过多可能会导致后台崩溃。缓存穿透也就是这么个情形。
解决方案就是在缓存前加一层布隆过滤器,过滤器内标记的是数据库中所有的数据。网络层发出请求的时候,就先经过布隆过滤器看看在不在,如果不在就直接返回了。虽然可能会发生误判,但是在经过一系列参数的调控后误判率会很低(不到百分之1),针对于误判的情况再来特殊处理一下就好了。这样子就能很好的解决缓存穿透的问题。
-
对数据库查询提效
其实和上面讲的差不多,就是在数据库前加一层布隆过滤器,查询的时候先经过过滤器。如果返回的是不存在,那就不用再去做无效的查找了。 -
快速判重
这个常用于爬虫的时候,爬虫就是爬取网页的一些数据。但是网站和网站经常是相互嵌套的,很可能爬着爬着就爬回原点。所以可以使用布隆过滤器来进行快速判重操作。用来标识网页是否被访问过。当然也可能会有误判。但是在极低的概率下,个别网站数据即使没有访问到也是没有所谓。大不了特殊处理一下。
总结与思考
布隆过滤器是针对于基础位图的缺陷而进一步设计的位图,它的效率特别高,访问效率高,且极大程度的节省了某些问题下的存储空间。
但是布隆过滤器是有一点缺陷的,就是会有误判。这是需要我们做出权衡取舍的。使用布隆过滤器需要接受一定的误报率,这实际上是在准确性和存储成本之间的一种折衷。这种思想可以推广到其他领域,比如在开发软件时,我们需要在功能全面性与系统复杂度之间找到最佳的平衡点。没有所谓的“最好”方案,只有最适合特定情境的解决方案。