在日常工作中,有一个比较常见的需求,就是需要判断一个元素是否在集合中。
例如以下场景:
给定一个IP黑名单库,检查指定IP是否在黑名单中?
在接收邮件的时候,判断一个邮箱地址是否为垃圾邮件?
在文字处理软件中,检查一个英文单词是否拼写正确?
遇到这种问题,通常直觉会告诉我们,应该使用集合这种数据结构来实现。例如,先将IP黑名单库的所有IP全部存储到一个集合中,然后再拿指定的IP到该集合中检查是否存在,如果存在则说明该IP命中黑名单。
通过一段Java代码,来模拟IP黑名单库的存储和检查。
public class IPBlackList {
public static void main(String[] args) {
Set<String> set = new HashSet<>();
set.add("192.168.1.1");
set.add("192.168.1.2");
set.add("192.168.1.4");
System.out.println(set.contains("192.168.1.1"));
System.out.println(set.contains("192.168.1.2"));
System.out.println(set.contains("192.168.1.3"));
System.out.println(set.contains("192.168.1.4"));
}
}
执行结果:
true
true
false
true
集合的内部,通常是使用散列表来实现。其优点是查询非常高效,缺点是比较耗费存储空间。
一般在数据量比较小的时候,我们会使用集合来进行存储。以空间换时间,在占用空间较小的情况下,同时又能提高查询效率。
但是,当存储的数据量比较大的时候,耗费大量空间将会成为问题。因为这些数据通常会存储到进程内存中,以加快查询效率。而机器的内存通常都是有限的,要尽可能高效的使用。
另一方面,散列表在空间和效率上是需要做平衡的。存储相同数量的元素,如果散列表容量越小,出现冲突的概率就越高,用于解决冲突的时间将会花费更多,从而影响性能。
而布隆过滤器(Bloom Filter)的产生,能够很好的解决这个问题。一方面能够以更少的内存来存储数据,另一方面能够实现非常高效的查询性能。
布隆过滤器(Bloom Filter)
布隆过滤器(Bloom Filter)是一个数据结构,由布隆(Burton Howard Bloom)于1970年提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。
布隆过滤器可以用于高效的检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远优于一般的算法,缺点是有一定的误识别率,而且难以删除(一般不支持,需要额外的实现)。
布隆过滤器之所以高效,因为它是一个概率数据结构,它能确认元素肯定不在集合中,或者元素可能在集合中。之所以说是可能,是因为它有一定的误识别率,使得无法100%确定元素一定在集合中。
基本原理
布隆过滤器的基本工作原理并不复杂,大致如下:
首先,建立一个二进制向量,并将所有位设置为0。
然后,选定K个散列函数,用于对元素进行K次散列,计算向量的位下标。
添加元素
当添加一个元素到集合中时,通过K个散列函数分别作用于元素,生成K个值作为下标,并对向量的相应位设置为1。
检查元素
如果要检查一个元素是否存在集合中,用同样的散列方法,生成K个下标,并检查向量的相应位是否全部是1。如果全为1,则该元素很可能在集合中;否则(只要有1个或以上的位为0),该元素肯定不在集合中。
这就是布隆过滤器的基本思想。
一个简单的例子
假设有一个布隆过滤器,容量是15位,使用2个哈希函数。
添加一个字符串a,2次哈希得到下标为4和10,将4和10对应的位由0标记为1。
然后添加一个字符串b,2次哈希得到下标为11和11,将11对应的位由0标记为1。
再添加一个字符串c,2次哈希得到下标为11和12,将11和12对应的位由0标记为1。
最后,添加一个字符串sam,2次哈希得到下标为0和7,将0和7对应的位由0标记为1。
上面,我们添加了4个字符串,每个字符串分别进行2次哈希,对应的2个位标记为1,最终被标记为1的共有6位而不是8位。
这说明,不同的元素,哈希后得到的位置是可能出现重叠的。如果元素越多,出现重叠的概率会更高。如果有2个元素出现重叠的位置,我们是无法判断任一元素一定在集合中的。
如果要检查一下元素是否存在集合中,只需要以相同的方法,进行2次哈希,将得到的2个下标在布隆过滤器中的相应位进行查找。如果对应的2位不是全部为1,则该元素肯定不在集合中。如果对应的2位全部为1,则说明该元素可能在集合中,也可能不存在。
例如,检查字符串b是否存在集合中,哈希得到的2个下标都为11。检查发现,11对应的位为1。但是,这并不能说明b一定在集合中。这是因为,字符串c哈希后的下标也包含11,有可能只是字符串c在集合中,而b却不存在,这就是造成了误识别,也称为假阳性。
再检查字符串foo,哈希得到的下标分别为8和13,对应的位都为0。因此,字符串foo肯定不在集合中。
数学原理
布隆过滤器背后的数学原理是:
两个完全随机的数字相冲突的概率很小,因此可以在很小的误识别率条件下,用很少的空间存储大量信息。
解决误识别率的2种方法
白名单
解决误识别率的常见方法,是建立一个较小的白名单,用来存储那些可能被误识别的数据。
以垃圾邮件过滤为例。假设我们有一个垃圾邮件库,用于在接收邮件的时候过滤掉垃圾邮件。
这时可以先将这个垃圾邮件库存储到布隆过滤器中,当接收到邮件的时候,可以先通过布隆过滤器高效的过滤出大部分正常邮件。
而对于少部分命中(可能为)垃圾邮件的,其中有一部分可能为正常邮件。
再创建一个白名单库,当在布隆过滤器中判断可能为垃圾邮件时,通过查询白名单来确认是否为正常邮件。
对于没在白名单中的邮件,默认会被移动到垃圾箱。通过人工识别的方式,当发现垃圾箱中存在正常邮件的时候,将其移入白名单。
回源确认
很多时候,使用布隆过滤器是为了低成本,高效率的拦截掉大量数据不在集合中的场景。
例如:
Google Bigtable,Apache HBase以及Apache Cassandra和PostgreSQL 使用Bloom过滤器来减少对不存在的行或列的磁盘查找。避免进行昂贵的磁盘查找,可大大提高数据库查询操作的性能。
在谷歌浏览器用于使用布隆过滤器来识别恶意URL的网页浏览器。首先会针对本地Bloom过滤器检查所有URL,只有在Bloom过滤器返回肯定结果的情况下,才对执行的URL进行全面检查(如果该结果也返回肯定结果,则用户会发出警告)。
拦截掉大量非IP黑名单请求,对于少量可能在黑名单中的IP,再查询一次黑名单库。
这是布隆过滤器非常典型的应用场景,先过滤掉大部分请求,然后只处理少量不明确的请求。
这个方法,和白名单库的区别是,不需要再另外建立一套库来处理,而是使用本来就已经存在的数据和逻辑。
例如Google Bigtable查询数据行本来就是需要查的,只不过使用布隆过滤器拦截掉了大部分不必要的请求。而IP是否为黑名单也是需要查询的,同样是先使用布隆过滤器来拦截掉大部分IP。
而上面垃圾邮件的处理,对于可能为垃圾邮件的情况,不是通过完整的垃圾邮件库再查询一次进行确认,而是用增加白名单来进行判断的方式。因为通常来说,白名单库会更小,便于缓存。
这里所说的回源,实际上是对可能被误识别的请求,最后要回到数据源头或逻辑确认一次。
参考
https://en.wikipedia.org/wiki/Bloom_filter
https://en.wikipedia.org/wiki/Bloom_filter
https://zh.wikipedia.org/zh-cn/布隆过滤器
https://llimllib.github.io/bloomfilter-tutorial
https://www.geeksforgeeks.org/bloom-filter-in-java-with-examples/
《数学之美》
题图:wikipedia.org极客教程:996geek.com个人博客:binarylife.icu