1.布隆过滤器的引出
一个有趣的现象
不知道大家有没有发现这么一个现象,当我们在使用一些软件的时候,比如像 CSDN、这种具有推荐算法的应用,他并不会给我们推送我们已经浏览过的内容,这是怎么做到的呢?
说白了就是人家的程序中实现了一个去重算法。大致的原理就是,在服务器的内部记录用户的浏览记录,每次推送新内容的时候,去用户的浏览记录中进行查找,看有没有重复的内容,有的话就pass掉,这样一来,我们就不会收到重复的内容了。
在实际应用中,查找的数据往往是字符串类型的,所以,高效查找是否有重复的字符串内容就显得尤为重要,问题来了,如何做到高效的查询字符串是否存在呢?
如何快速查找非整形的数据
如果你数据结构学得不错的话,你可能已经想到一些适合查找的数据结构和算法,比如:二分查找算法、二叉搜索树、哈希表。是的,这些数据结构的查询效率确实不错,尤其是哈希表,能到达O(1) 的时间复杂度,但是,这些数据结构存储的是元素本身,当数据量比较大的时候,消耗的内存资源是非常恐怖的,因此,我们可以参考位图的思想,用比特位来标记元素是否存在,从而降低数据存储所要耗费的内存空间。(如果你不了解位图的话,推荐阅读这篇文章,这篇文章中对位图做了详细讲解——文章链接)
但是,又有新问题了,位图是有缺陷的,位图只能处理整形的数据,而我们要处理的数据可不只是整形的;没事,我们可以通过字符串哈希算法,将字符串映射为整形数据,再将整形数据通过哈希函数和存储位置建立一 一 映射的关系,这样一来就可以进行查找喽。等等,我怎么感觉这就是一个位图呀?没错,上面讲的就是利用位图来解决问题的,但是位图能完全解决判断字符串是否存在的问题吗?
举个例子:用户注册的时候,假如孙悟空这个数据存在,映射的bit位为1,没毛病;但是,如果齐天大圣这个数据不存在,却和孙悟空映射到了同一个bit位,那么就会造成误判。
如下图所示:
当我们使用某种字符串哈希算法的时候,将字符串映射为整形数据时,我们并不能保证,不同的数据就一定映射到不同的位置上,哈希算法是会产生哈希冲突的。而且字符串类型的数据充满不确定性,映射到一个bit位上,产生冲突的概率是比较大的;
这个时候,有一位名叫布隆的大佬就想到了一个办法,既然映射一个比特位容易冲突,那我就多映射几个比特位,并且映射每个bit位的哈希算法不一样,当我们想要判断一个数据是否存在,需要判断该数据所映射的每个bit位,这个时候就大大减小了冲突的概率。 这就是实现布隆过滤器的思想。
比如:还是用户注册的场景,孙悟空映射了三个bit位,并且三个bit位都为1,所以孙悟空是存在的,但是齐天大圣映射的bit位中,有一个bit位不为1,所以齐天大圣是不存在的。
2.布隆过滤器的介绍
什么是布隆过滤器
通过上面的介绍可以看出,布隆过滤器是通过位图和哈希思想实现的数据结构,可以进行高效的查询操作。
- 通过使用位图,大大降低了存储数据所需要的空间。
- 通过使用哈希思想,大大提高了查询的效率。
可以看出,布隆过滤器是一种兼顾时间复杂度和空间复杂度的数据结构。但是布隆过滤器也有不完美的地方,因为布隆过滤器其实是存在误判的。
比如孙悟空和齐天大圣是已经注册过的了,并且二者映射的bit位都位1,如果猪八戒不存在,但是其映射的bit位被孙悟空和齐天大圣映射过了,也都为1,所以判断猪八戒是否存在的时候,就存在误判。误判的概率有大有小,所以布隆过滤器还是一种概率型的数据结构。
布隆过滤器的特点
- 判断数据不存在,没有毛病,判断的很准确。
- 判断数据存在,可能会出现误判。
所以布隆过滤器的使用场景是要能够容忍误判的;如果说误判总是可能会存在,那我们就需要通过一定的手段控制误判率。
控制布隆过滤器的误判率
1.控制布隆过滤器的长度。
- 如果布隆过滤器的长度比较短,布隆过滤器就很容易被填满,当布隆过滤器接近填满状态的时候,出现误判的可能性就非常大。所以布隆过滤器的长度越长,误判的可能性就越小。
2.控制哈希函数的个数。
- 如果哈希函数比较多的话,一个数据就要映射更多的bit位,布隆过滤器的效率难以保证;如果哈希函数比较少的话,误判的可能性又会变大。
3.控制元素个数。
- 如果元素个数比较多,映射的bit位接近布隆过滤器的长度,此时的误判率较大,所以数据元素的个数越少,误判率越低。
于是,有人研究了这个问题,并得出了以下结论:
其中,K位哈希函数的个数,m为布隆过滤器的长度,n为插入的元素个数,p为误判率。当k、m、n 满足上述公式时,误判率较低。假设k位3,我们可以计算出,布隆过滤器的长度大约等于数据个数的四倍左右,此时的误判率较低。
3.布隆过滤器的使用场景
前面简单的提了一下,布隆过滤器是会发生误判的,所以想要使用布隆过滤器,需要忍受误判,如果不想容忍误判,就需要作出相应的措施了。
容忍误判的场景
比如:还是用我们注册的例子,当用户提交输入的用户名时,程序可以去布隆过滤器中查找,如果判断为不存在,那么判断是准确的,直接返回注册成功即可;如果判断为存在,此时可能发生误判,用户提交的数据可能存在,也可能不存在,我们可以当做存在处理,也就是让用户重新输入用户名。这种应用场景下,是可以容忍误判的,因为,即使是发生误判,也就是让用户重新输入一下的事。
不容忍误判的场景
如果我们不想容忍误判,我们可以对判断存在的场景特殊处理。还是用户注册的场景;如果判断不存在,判断是准确的,将用户提交的用户名添加进数据库中;如果判断是存在,可能发生误判,此时,我们不想容忍误判,我们可以再去数据库中进行查找,将查找的结果进行返回。
可以看出,布隆过滤器在用户和数据库之间作为一个检查官,当这个检察官能够判断准确的事情,自己就做主返回了,不能判断准确的事情,还得请教一下数据库大哥,大哥说了算。
同时,我们还可以看出,布隆过滤器是一种提高用户访问速度的策略,不存在误判的时候,不需要访问数据库,减少了很多访问数据库的操作,提高了访问速度。存在误判的时候,在去访问数据库,因为,布隆过滤器的查询效率很高,对比于访问数据库的操作微乎其微,因此,使用布隆过滤器是一种稳赚不赔的 “买卖”。
4.布隆过滤器的实现
布隆过滤器中的成员总览
template<size_t N, class K = std::string, class Hash1 = HashFuncBKDR,
class Hash2 = HashFuncAP, class Hash3 = HashFuncDJB>
class BloomFilter
{
public:
void Set(const K& key) // 通过几个不同的哈希函数映射出 几个不同的存储位置,减少冲突的概率
{}
bool Test(const K& key) // 布隆过滤器 存在误判,因为冲突可以减少,但不能避免
{}
private:
static const size_t M = 4 * N;
std::bitset<M> _bs;
};
模板参数中的介绍
- N为数据的个数。
- K位数据的类型,因为,通常用来判断字符串类型的数据,所以我们将缺省值设为string。
- Hash1、Hash2、Hash3 为三个字符串哈希算法的函数,将字符串数据转换成对应的整形数据。
成员变量的介绍
- M是布隆过滤器的长度, 当长度的大小为数据个数的4倍左右,误判率较小。
- _bs是一个位图,布隆过滤器是在位图的基础之上来实现的。
成员方法的介绍
- Set方法:用于将查询的数据映射的bit位设为1。
- Test方法:用于判断数据是否存在。
布隆过滤器的插入
布隆过滤器的插入过程是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。布隆所以,我们先要提供几个不同的哈希函数,我们提供以下几个字符串哈希函数
- HashFuncBKDR
- HashFuncAP
- HashFuncDJB
这几个哈希函数经过测试,产生冲突的概率较小;在代码中以仿函数的形式实现,代码如下:
struct HashFuncBKDR
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
struct HashFuncAP
{
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;
}
};
struct HashFuncDJB
{
size_t operator()(const string& s)
{
size_t hash = 5381;
for (auto ch : s)
{
hash = hash * 33 ^ ch;
}
return hash;
}
};
经过哈希函数的映射,数据被映射成了一个个的整形数据,但是这些整形数据有可能会超过布隆过滤器的长度,所以我们还需要将哈希值对M取模,得到其在布隆过滤器中的所映射的位置,然后分别调用位图的置一接口,将对应的位置设为1。
插入代码如下:
void Set(const K& key) // 通过几个不同的哈希函数映射出 几个不同的存储位置,减少冲突的概率
{
size_t hash1 = Hash1()(key) % M;
size_t hash2 = Hash2()(key) % M;
size_t hash3 = Hash3()(key) % M;
_bs.set(hash1);
_bs.set(hash2);
_bs.set(hash3);
}
布隆过滤器的查找
在布隆过滤器中查找一个值,我们只需要判断该数据所映射的bit位是否都为1,只要有一个bit位不为1,就可判断该数据不存在。
注意:经过前面的学习,我们知道,布隆过滤器判断结果为不存在 是准确的,判断结果为存在 是不准确的。
查找代码如下:
bool Test(const K& key) // 布隆过滤器 存在误判,因为冲突可以减少,但不能避免
{
size_t hash1 = Hash1()(key) % M;
size_t hash2 = Hash2()(key) % M;
size_t hash3 = Hash3()(key) % M;
// 判断结果为存在时 会有误判
return _bs.test(hash1) && _bs.test(hash2) && _bs.test(hash3);
}
布隆过滤器的删除
在布隆过滤器中,一般不实现删除,假如孙悟空,齐天大圣,猪八戒都存在位图中,但是三者映射的bit位有重复
如果我们此时删除猪八戒,当我们判断孙悟空,齐天大圣时,也会判断不存在。
引用计数实现删除:
如果非要实现删除,我们可以使用引用计数方式实现,将布隆过滤器中的每个比特位扩展成一个小的计数器,一个哈希函数不要只映射一个bit位,而是映射多个bit位,我们这里映射三个bit位,三个bit位表示的数值范围是0~7,因此,每个bit位的引用计数的最大值为7。当不同的数据映射到相同的位置时,我们就可以增加其引用计数,当删除一个数时,我们就可以将其映射位置的引用计数减1。这样一来,删除一个数的时候就不会影响其他值的判断。
但是 这种方式实现的删除是有缺陷的
缺陷一:误判一个数存在,删除的时候,将其映射位置的引用计数错误的减少了。
缺陷二:产生计数回绕问题。计数回绕指的是在使用计数布隆过滤器时,计数器达到其最大可能值后发生溢出(或称为回绕)的情况。这通常发生在计数器的数据类型大小有限的情况下,比如使用了一个固定大小的整数(如int
或uint32_t
)来作为计数器。当计数器的值增加到其最大值(如INT_MAX
或UINT32_MAX
)时,再增加就会回绕到最小值(对于无符号整数是0,对于有符号整数可能是负的最大值)。
布隆过滤器的完整代码
#include <vector>
#include <string>
#include <bitset>
struct HashFuncBKDR
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
struct HashFuncAP
{
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;
}
};
struct HashFuncDJB
{
size_t operator()(const string& s)
{
size_t hash = 5381;
for (auto ch : s)
{
hash = hash * 33 ^ ch;
}
return hash;
}
};
template<size_t N, class K = std::string, class Hash1 = HashFuncBKDR,
class Hash2 = HashFuncAP, class Hash3 = HashFuncDJB>
class BloomFilter
{
public:
void Set(const K& key) // 通过几个不同的哈希函数映射出 几个不同的存储位置,减少冲突的概率
{
size_t hash1 = Hash1()(key) % M;
size_t hash2 = Hash2()(key) % M;
size_t hash3 = Hash3()(key) % M;
_bs.set(hash1);
_bs.set(hash2);
_bs.set(hash3);
}
bool Test(const K& key) // 布隆过滤器 存在误判,因为冲突可以减少,但不能避免
{
size_t hash1 = Hash1()(key) % M;
size_t hash2 = Hash2()(key) % M;
size_t hash3 = Hash3()(key) % M;
return _bs.test(hash1) && _bs.test(hash2) && _bs.test(hash3); // 判断结果为存在时 会有误判
}
// 布隆过滤器不能实现置0,因为可能会影响其他的值 的存在情况
private:
static const size_t M = 5 * N;
std::bitset<M> _bs;
};
5.位图和布隆过滤器对比
布隆过滤器是对位图的一种改进,通过引入多个哈希函数来降低哈希冲突的概率,从而提高查询的准确性和效率。
改进的地方:
- 多个哈希函数:布隆过滤器使用多个哈希函数将数据映射到位图中的多个位置,而不是像传统位图那样只使用一个哈希函数。这样做的好处是可以降低哈希冲突的概率,从而提高查询的准确性。
- 误判与空间效率:布隆过滤器允许存在一定的误判率,这是为了换取更高的空间效率。通过调整哈希函数的数量和位图的大小,可以在误判率和空间效率之间找到平衡。这种折衷方案使得布隆过滤器在处理大规模数据集时具有优势。