布隆过滤器 BloomFilter 是一种概率型数据结构,主要用于判断某个元素是否存在于集合中。
核心思想
布隆过滤器的目标是解决大规模数据存在性问题,在节省空间的同时,允许一定程度的误判。
- 肯定不存在:如果布隆过滤器判定一个元素不存在,那么它一定不存在。
- 可能存在:如果布隆过滤器判定一个元素存在,它有可能是误判,实际上不存在。
基本原理
数据结构
布隆过滤器由两部分组成:
位数组:大小为 m ,初始化时全为 0,只存储 0 或 1。
哈希函数:k 个独立的哈希函数,将输入映射到位数组的索引上(范围 0 到 m-1 )。
操作过程
添加
- 使用 k 个哈希函数对元素 x 进行哈希运算,得到 k 个哈希值。
- 将对应位数组的 k 个位置设为 1。
查找
- 使用 k 个哈希函数对元素 x 进行哈希运算,得到 k 个哈希值。
- 如果位数组中的 k 个位置均为 1,则返回“可能存在”;若有一个为 0,则返回“一定不存在”。
示例
假设位数组大小为 m = 10,有 3 个哈希函数。
元素 x,哈希值分别为 h1(x) = 1, h2(x) = 2, h3(x) = 3。
元素 y,哈希值分别为 h1(y) = 1, h2(y) = 2, h3(y) = 3。
元素 z,哈希值分别为 h1(z) = 3, h2(z) = 6, h3(z) = 9。
x 和 y 具有相同的哈希值,用来演示误判。
添加 x 后,位数组状态:
| 索引 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 值 | 0 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
查找 x,位数组中 1,2,3 位置均为 1 ,返回可能存在。
查找 y,位数组中 1,2,3 位置均为 1 ,返回可能存在,但实际上并没有添加 y。
查找 z,位数组中 6 的位置为 0 ,返回不可能存在。
误判原因
- 哈希冲突:不同元素的哈希值可能映射到相同的位数组位置。
- 位数组共享:多个元素可能映射到相同的位数组位置,查询时无法区分。
误判率优化
误判率
e 为自然对数的底数,约等于 2.71828。如 ln(2) = x,也就是 ex = 2, x ≈ 0.693 。
k 为哈希函数的数量。
m 为位数组的大小。
n 为需要添加的元素。
- 随着 n 的增加,扩容位数组大小 m可以减少哈希碰撞的机会,从而降低误判率,但也会增加存储空间。计算公式
- 调整哈希函数的数量 k,如果 k 太小,会有较高的误判率;如果 k 太大,哈希冲突增加,会导致性能下降,计算公式
。
优缺点和应用场景
优点
- 插入和查询的时间复杂度为 O(k),速度快。
- 相对于存储完整数据,布隆过滤器更节省空间。
- 适合处理大规模数据。
缺点
- 存在误判,但不会漏判真实存在的元素。
- 一旦元素添加进去,就无法删除。
- 需要设计高效的哈希函数,否则可能增加误判率。
- 不适用于需要删除或更新元素的场景
应用场景
- 数据去重:比如内容推荐系统,用布隆过滤器来标记用户浏览过的内容,避免重复推荐。无需存储完整的浏览记录,节省空间且查询高效。
- 缓存穿透防护:比如高并发环境下,使用布隆过滤器避免 Redis 缓存穿透,如果布隆过滤器表明某个数据不存在,则不用再去查询数据库,降低数据库负载,从而提高性能。
- 文件内容相似度检测:比如在文件系统中,布隆过滤器可用于快速匹配文件的哈希值,判断是否有重复内容。
扩展
计数布隆过滤器
在标准布隆过滤器中,一旦被设置为 1,就无法修改。而在某些应用场景中,可能需要删除元素。为了解决这个问题,可以使用计数布隆过滤器,它为每个位置存储一个计数值,从而允许对已插入的元素进行删除,
双重布隆过滤器
双重布隆过滤器是通过组合两个布隆过滤器来减少误判率。首先用第一个布隆过滤器检查元素是否存在,如果第一个布隆过滤器判断元素存在,则使用第二个布隆过滤器进行进一步确认。
Java 实现
在一些开源工具包中,如 Google Guava 中, 使用了 MurmurHash 算法,并且支持多线程并发操作。
下面的示例中,只是实现一个简单的 String 布隆过滤器,不考虑多线程并发等问题。
主要是利用加密中的 MD5 算法当做哈希函数,BitSet 当做位数组。
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.BitSet;
/**
* 布隆过滤器
* <br>
* 元素存储基于 BitSet Hash 函数基于 MD5 算法
*
* @author sheng
*/
public class BloomFilter {
/*位数组*/
private final BitSet bitSet;
/*位数组的大小*/
private final int bitSetSize;
/*哈希函数数量*/
private final int hashCount;
/**
* @param elements 预期元素数量
* @param falsePositiveRate 误判率
*/
public BloomFilter(int elements, double falsePositiveRate) {
// 计算位数组的大小
this.bitSetSize = (int) Math.ceil(
(-elements * Math.log(falsePositiveRate)) / (Math.pow(Math.log(2), 2)));
// 计算哈希函数的数量
this.hashCount = (int) Math.ceil((this.bitSetSize / (double) elements) * Math.log(2));
// 初始化位数组
this.bitSet = new BitSet(this.bitSetSize);
}
/*MD5 哈希算法*/
private byte[] md5(String element) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
return md.digest(element.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 计算异常", e);
}
}
/*计算多个哈希值对应的索引位置*/
private int[] getIndexes(String element) {
int[] indexes = new int[hashCount];
byte[] md5Bytes = md5(element);
/*
* MD5 结果为 16 字节 128 位,int 类型 32 位,所以每 4 个字节作为一个 hash 值,充当一个哈希函数的计算结果。
* 如果 hashCount > 4 则会循环上面的操作
* hash << 8,左移 8 位,为下一个字节腾出位置
* & 0xFF 等于 & 1111 1111,结果不会改变,byte 是有符号位的数据类型,转为无符号的位操作
* */
for (int i = 0; i < hashCount; i++) {
int hash = 0;
for (int j = 0; j < 4; j++) {
hash = (hash << 8) | (md5Bytes[(i * 4 + j) % md5Bytes.length] & 0xFF);
}
/*映射到位数组索引位置*/
indexes[i] = Math.abs(hash % bitSetSize);
}
return indexes;
}
/**
* 添加元素
*
* @param element 元素值
*/
public void add(String element) {
int[] indexes = getIndexes(element);
for (int index : indexes) {
// 将指定的索引位置设为true,也就是1
bitSet.set(index);
}
}
/**
* 判断元素是否存在
*
* @param element 元素值
* @return false 一定不存在,true 可能存在
*/
public boolean contains(String element) {
int[] indexes = getIndexes(element);
for (int index : indexes) {
// 如果某个索引位置为 0,表示一定不包含该元素
if (!bitSet.get(index)) {
return false;
}
}
// 如果所有索引位置都是 1,则可能包含该元素
return true;
}
public int size() {
return size;
}
public int hashCount() {
return hashCount;
}
}
测试:
public class BloomFilterTest {
public static void main(String[] args) {
BloomFilter bloomFilter = new BloomFilter(100, 0.05);
System.out.printf("布隆过滤器:位数组长度=%s,哈希计算次数=%s%n",
bloomFilter.size(),
bloomFilter.hashCount());
bloomFilter.add("hello");
System.out.printf("hello 查找结果:%s%n", bloomFilter.contains("hello"));
System.out.printf("world 查找结果:%s%n", bloomFilter.contains("world"));
}
}
结果:
布隆过滤器:位数组长度=624,哈希计算次数=5
hello 查找结果:true
world 查找结果:false
误判率直接影响位数组长度和哈希计算次数,误判率越低,位数组越长,哈希次数越多。