布隆过滤器
基本概念
如果想判断一个元素是不是在一个集合里,一般想到的是将所有元素保存起来,然后通过比较确定。链表、树、散列表(又叫哈希表,Hash table)等等数据结构都是这种思路,。但是随着集合中元素的增加,我们需要的存储空间越来越大。同时检索速度也越来越慢,上述三种结构的检索时间复杂度分别为。
布隆过滤器的原理是,当一个元素被加入集合时,通过K个Hash函数将这个元素映射成一个位阵列(Bit array)中的K个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检索元素一定不在;如果都是1,则被检索元素很可能在。这就是布隆过滤器的基本思想。
优点
相比于其它的数据结构,布隆过滤器在空间和时间方面都有巨大的优势。布隆过滤器存储空间和插入/查询时间都是常数()。另外, Hash函数相互之间没有关系,方便由硬件并行实现。布隆过滤器不需要存储元素本身,在某些对保密要求非常严格的场合有优势。
布隆过滤器可以表示全集,其它任何数据结构都不能;
和相同,使用同一组Hash函数的两个布隆过滤器的交并差运算可以使用位操作进行。
缺点
但是布隆过滤器的缺点和优点一样明显。误算率是其中之一。随着存入的元素数量增加,误算率随之增加。但是如果元素数量太少,则使用散列表足矣。
另外,一般情况下不能从布隆过滤器中删除元素. 我们很容易想到把位列阵变成整数数组,每插入一个元素相应的计数器加1, 这样删除元素时将计数器减掉就可以了。然而要保证安全的删除元素并非如此简单。首先我们必须保证删除的元素的确在布隆过滤器里面. 这一点单凭这个过滤器是无法保证的。另外计数器回绕也会造成问题。
在降低误算率方面,有不少工作,使得出现了很多布隆过滤器的变种。
布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。当年,布隆过滤器还是静态的,即只能处理一定容量的数据,不能处理未知规模的数据。
假如有1亿个不重复的正整数(大致范围已知),但是只有1G的内存可用,如何判断该范围内的某个数是否出现在这1亿个数中?最常用的处理办法是利用位图,1*108/1024*1024*8=11.9,也只需要申请12M的内存。但是如果是1亿个邮件地址,如何确定某个邮件地址是否在这1亿个地址中?这个时候可能大家想到的最常用的办法就是利用Hash表了,但是大家可以细想一下,如果利用Hash表来处理,必须开辟空间去存储这1亿个邮件地址,因为在Hash表中不可能避免的会发生碰撞,假设一个邮件地址只占8个字节,为了保证Hash表的碰撞率,所以需要控制Hash表的装填因子在0.5左右,那么至少需要2*8*108/1024*1024*1024=1.5G的内存空间,这种情况下利用Hash表是无法处理的。这个时候要用到另外一种数据结构-布隆过滤器(Bloom Filter),它是由Burton Howard Bloom在1970年提出的,它结合了位图和Hash表两者的优点,位图的优点是节省空间,但是只能处理整型值一类的问题,无法处理字符串一类的问题,而Hash表却恰巧解决了位图无法解决的问题,然而Hash太浪费空间。针对这个问题,布隆提出了一种基于二进制向量和一系列随机函数的数据结构-布隆过滤器。它的空间利用率和时间效率是很多算法无法企及的,但是它也有一些缺点,就是会有一定的误判率并且不支持删除操作。
下面来讨论一下布隆过滤器的原理和它的应用。
一.布隆过滤器的原理
布隆过滤器需要的是一个位数组(这个和位图有点类似)和k个映射函数(和Hash表类似),在初始状态时,对于长度为m的位数组array,它的所有位都被置为0,如下图所示:
对于有n个元素的集合S={s1,s2……sn},通过k个映射函数{f1,f2,……fk},将集合S中的每个元素sj(1<=j<=n)映射为k个值{g1,g2……gk},然后再将位数组array中相对应的array[g1],array[g2]……array[gk]置为1:
如果要查找某个元素item是否在S中,则通过映射函数{f1,f2…..fk}得到k个值{g1,g2…..gk},然后再判断array[g1],array[g2]……array[gk]是否都为1,若全为1,则item在S中,否则item不在S中。这个就是布隆过滤器的实现原理。
当然有读者可能会问:即使array[g1],array[g2]……array[gk]都为1,能代表item一定在集合S中吗?不一定,因为有这个可能:就是集合中的若干个元素通过映射之后得到的数值恰巧包括g1,g2,…..gk,那么这种情况下可能会造成误判,但是这个概率很小,一般在万分之一以下。
很显然,布隆过滤器的误判率和这k个映射函数的设计有关,到目前为止,有很多人设计出了很多高效实用的hash函数,具体可以参考:《常见的Hash算法》这篇博文,里面列举了很多常见的Hash函数。并且可以证明布隆过滤器的误判率和位数组的大小以及映射函数的个数有关,相关证明可参考这篇博文:《布隆过滤器 (Bloom Filter) 详解》。假设误判率为p,位数组大小为m,集合数据个数为n,映射函数个数为k,它们之间的关系如下:
p=2-(m/n)*ln2 可得 m=(-n*lnp)/(ln2)2=-2*n*lnp=2*n*ln(1/p)
k=(m/n)*ln2=0.7*(m/n)
可以验证若p=0.1,(m/n)=9.6,即存储每个元素需要9.6bit位,此时k=0.7*(m/n)=6.72,即存储每个元素需要9.6个bit位,其中有6.72个bit位被置为1了,因此需要7个映射函数。从这里可以看出布隆过滤器的优越性了,比如上面例子中的,存储一个邮件地址,只需要10个bit位,而用hash表存储需要8*8=64个bit位。
一般情况下,p和n由用户设定,然后根据p和n的值设计位数组的大小和所需的映射函数的个数,再根据实际情况来设计映射函数。
尤其要注意的是,布隆过滤器是不允许删除元素的,因为若删除一个元素,可能会发生漏判的情况。不过有一种布隆过滤器的变体Counter Bloom Filter,可以支持删除元素,感兴趣的读者可以查阅相关文献资料。
二.布隆过滤器的应用
布隆过滤器在很多场合能发挥很好的效果,比如:网页URL的去重,垃圾邮件的判别,集合重复元素的判别,查询加速(比如基于key-value的存储系统)等,下面举几个例子:
1.有两个URL集合A,B,每个集合中大约有1亿个URL,每个URL占64字节,有1G的内存,如何找出两个集合中重复的URL。
很显然,直接利用Hash表会超出内存限制的范围。这里给出两种思路:
第一种:如果不允许一定的错误率的话,只有用分治的思想去解决,将A,B两个集合中的URL分别存到若干个文件中{f1,f2…fk}和{g1,g2….gk}中,然后取f1和g1的内容读入内存,将f1的内容存储到hash_map当中,然后再取g1中的url,若有相同的url,则写入到文件中,然后直到g1的内容读取完毕,再取g2…gk。然后再取f2的内容读入内存。。。依次类推,知道找出所有的重复url。
第二种:如果允许一定错误率的话,则可以用布隆过滤器的思想。
2.在进行网页爬虫时,其中有一个很重要的过程是重复URL的判别,如果将所有的url存入到数据库中,当数据库中URL的数量很多时,在判重时会造成效率低下,此时常见的一种做法就是利用布隆过滤器,还有一种方法是利用berkeley db来存储url,Berkeley db是一种基于key-value存储的非关系数据库引擎,能够大大提高url判重的效率。
布隆过滤器的简易版本实现:
/*布隆过滤器简易版本 2012.11.10*/
#include<iostream>
#include<bitset>
#include<string>
#define MAX 2<<24
using namespace std;
bitset<MAX> bloomSet; //简化了由n和p生成m的过程
int seeds[7]={3, 7, 11, 13, 31, 37, 61}; //使用7个hash函数
int getHashValue(string str,int n) //计算Hash值
{
int result=0;
int i;
for(i=0;i<str.size();i++)
{
result=seeds[n]*result+(int)str[i];
if(result > 2<<24)
result%=2<<24;
}
return result;
}
bool isInBloomSet(string str) //判断是否在布隆过滤器中
{
int i;
for(i=0;i<7;i++)
{
int hash=getHashValue(str,i);
if(bloomSet[hash]==0)
return false;
}
return true;
}
void addToBloomSet(string str) //添加元素到布隆过滤器
{
int i;
for(i=0;i<7;i++)
{
int hash=getHashValue(str,i);
bloomSet.set(hash,1);
}
}
void initBloomSet() //初始化布隆过滤器
{
addToBloomSet(“http://www.baidu.com”);
addToBloomSet(“http://www.cnblogs.com”);
addToBloomSet(“http://www.google.com”);
}
int main(int argc, char *argv[])
{
int n;
initBloomSet();
while(scanf(“%d”,&n)==1)
{
string str;
while(n–)
{
cin>>str;
if(isInBloomSet(str))
cout<<”yes”<<endl;
else
cout<<”no”<<endl;
}
}
return 0;
}
____________________________________________________________________________________________
原理
布隆过滤器可以看成由哈希表演化而来,先后经历了由误差率换空间,再由空间换误差率的过程。在本节中,我们首先简单介绍哈希表,并用误差率换空间导出新的数据结构,最后反过来用空间换误差率得到布隆过滤器。
哈希表是存储集合常用的数据结构。添加元素时,我们将元素通过哈希函数映射到哈希表的某个存储单元上,并把该元素保存在此单元;要判断元素是否属于集合,可以使用相同的哈希函数找到该元素对应的存储单元,如果该单元为空,说明元素还未添加进集合;如果该单元不空,则取出内容与该元素进行比较,只有经过比较相同后才断定该元素属于集合。可以看出,哈希表最消耗空间的部分是将元素保存到存储单元上。因为不同元素通过哈希函数有可能对应相同的单元,所以我们必须将元素保存到单元上,才能保证有足够的信息准确区分对应相同单元的不同元素。
将元素保存到哈希表中,以便区分相同哈希值的不同元素
如果允许有误差,我们可以将需要保存的信息压缩到 b 个字节上,将压缩后的编码保存到存储单元,而不是保存整个元素,从而达到节约空间的目的。此时元素的存取大体相同,区别在于保存到存储单元的是元素的编码,并且由于不同元素可能有相同的编码,所以判断元素是否属于集合时可能出现误报。当b越小,不同元素对应相同编码的概率越大,误差率越大;反之,b越大,误差率越小,当b达到足以保存整个元素时,达到零误差率。通过调整b的大小权衡误差率和空间消耗。
对元素进行编码后保存进哈希表
如果b为1,那么存储单元成了标记位,完全不储存元素的信息。可以看出,误差率的瓶颈在于只有一个哈希值作为该元素存在的标记,只要是哈希值相同的元素,都无法判断是哪个元素使得对应的标记位为真。如果每个元素可以有k个不同的标记位,那么不同元素的k个标记位完全相同的概率会大大降低,不过这样会增加其他多个元素碰巧将该k个标记位设为真的概率,所以对于固定的存储单元的位数m和集合元素个数n,有一个k使得误差率最小。这种数据结构就是布隆过滤器。若要需要将元素添加进集合,分别用k个哈希函数得到元素对应的k个单元,并将其设为1;若要判断某元素是否属于集合,需要得到对应的k个单元,当k个单元有一个或以上为0,则可以断定该元素不属于集合。
布隆过滤器
误报率分析
对于误报率即元素不在集合中被误报为属于集合的概率,要分析起来比较直观。假定哈希函数是完全随机的,当集合中所有的n个元素被添加进布隆过滤器后,布隆过滤器特定位仍为零的概率为:
则误报率为:
对于给定的m和n,我们希望找到一个k,使得误报率最小。最值可以通过求导解得,令误报率为g,对k求导后得:
令上式为零,解得
可以验证,k取上式值时g最小。实际上由于k必须是整数,我们一般对上式的k值取下整,从而减少所需的计算量。
使用技巧
在布隆过滤器的结构基础上,可以很容易实现某些集合的操作,比如两个集合的并。当两个集合所使用的布隆过滤器所使用的位数相同,哈希函数的个数也相同,那么将两个布隆过滤器对应位上做或 (OR) 操作得到的数据就是两个集合的并集。
布隆过滤器还有另一个有用的特性,即可以很轻易地将存储空间减半。假设存储空间的位数是2的指数,只需将取前半部分和后半部分做或运算,就得到了空间减半的结构。
实现