2026-10-20 布隆过滤器误判率实测

书:《数学之美》(吴军),布隆过滤器那一节。
本文所有数字都是我自己跑出来的,代码在文末。跑了两轮,第二轮改了什么,第四节会说。

一、先说书里的主张(我的概括,非原文)

布隆过滤器想解决一个问题:判断"某个东西在不在集合里",但不想真的存这个集合。

做法是准备一个很长很长的位数组,每个位置只有 0 和 1。插入一个元素时,用 k 个哈希函数算出 k 个位置,全置 1。查询时同样算 k 个位置,然后看:

  • 有任何一位是 0 → 一定不存在
  • 全是 1 → 可能存在(不能确定)

它不存元素本身,所以极省内存。代价就是"可能存在"这四个字——它会撒谎,但只朝一个方向撒。

二、我关心的是:它到底能错到什么程度

书里给了理论公式:

$$
p \approx \left(1 - e{-kn/m}\right)k
$$

其中 m 是位数,n 是已插入元素数,k 是哈希次数。

公式很干净,但我更想看一眼真实曲线。于是我写了三十行:一个 long[] 位数组(一个 long 管 64 位),两个哈希合成 k 个位置(双哈希法:第 i 个位置 = (h1 + i * h2) mod m),然后拿 10 万个从未插入过的字符串去查,数有多少被误判成"存在"。

参数:m = 100000 位(12.2 KB),k = 3。

三、第一轮跑出来是这样

n(插入数)       实际误判率          理论误判率          位填充率
1000         0.00%          0.00%          2.96%
5000         0.38%          0.27%          13.88%
10000        2.58%          1.74%          25.90%
20000        9.35%          9.18%          40.08%
50000        53.67%         46.89%         74.93%

n=10000 时实测 2.58%,理论 1.74%,差了 1.5 倍。n=50000 时更明显:53.67% vs 46.89%。

第二轮改 k(n 固定 10000),结果更奇怪:

k      实际误判率
1      14.26%
2      5.87%
3      2.78%
4      2.59%
5      2.02%
6      2.63%
7      2.47%
8      3.27%

k 从 4 到 8 之间来回跳,不但没降,反而在 k=8 时升到 3.27%。理论上 k 越大(在一定范围内)误判率越低,不该是这个形状。

四、问题出在我的两个哈希上

我一开始以为是查询次数太少、样本不够。算了一下:2.6% 的误判率、5 万次查询,标准差大约 0.07 个百分点——不足以解释 1 个百分点的跳动。所以不是噪声,是系统性的。

回去看位填充率那一列:25.90%,和理论值 1 - (1-1/m)^(kn) ≈ 25.9% 完全一致。也就是说,"置 1 了多少位"是对的,"1 分布得均不均匀"是错的。

原因在这里:我用的两个哈希是 String.hashCode() 和 FNV-1a。而我的测试字符串是 item-1234 这种——只差末尾几个数字。这类字符串经过这两个哈希后,低位分布有相关性。双哈希法 h1 + i*h2 的前提是这 k 个位置相互独立;一旦 h1、h2 相关,k 个位置就会挤在一块,等价于有效位数变少。位还是被填满了,只是填成了团。

于是我在两个哈希后面各加了一步混淆——一个 splitmix 风格的 mix 函数,把输入的每一位摊到输出上:

private static int mix(int x) {
    x ^= x >>> 16;
    x *= 0x7feb352d;
    x ^= x >>> 15;
    x *= 0x846ca68b;
    x ^= x >>> 16;
    return x;
}

再跑一遍:

n(插入数)       实际误判率          理论误判率          位填充率
1000         0.00%          0.00%          2.96%
5000         0.25%          0.27%          13.89%
10000        1.69%          1.74%          25.89%
20000        9.14%          9.18%          45.05%
50000        47.05%         46.89%         77.65%

四行全部贴住了理论值。改 k 的那一轮也变干净了:

k      实际误判率
1      9.68%
2      3.38%
3      1.77%
4      1.17%
5      0.98%
6      0.89%
7      0.89%
8      0.89%

单调下降,到 k=6 之后基本走平。

两次唯一的差别就是"哈希够不够散"。这件事让我对公式的态度变了一点:公式描述的是理想哈希下的行为;写代码时,哈希质量决定了你能不能走到公式那一行。

五、结论

它确实省内存:12.2 KB 装下 5 万个字符串,平均每个元素 0.25 字节。同样的数据装进 HashMap,光对象头和引用就要几 MB。

但省得有代价,而且代价不是线性的。填充率一旦过 50%,误判率就往上飙——n=50000 时填充率 77.65%,一半的查询都会给出错误答案。所以用它之前得先算好能装多少,不是装上了再说。

k 也不是越大越好。理论最优是 (m/n)·ln2,这里算出来约 6.93;实测到 6 之后就不再降了。再往上加,只是每次查询多算几遍哈希。

最要紧的是它不对称:能保证"一定不在",但永远保证不了"一定在"。所以它能用的地方,必须是可以接受偶尔误报的地方。

六、什么场景能用,什么场景不能用

场景 能不能用 误判的后果
爬虫 URL 去重 ✅ 漏抓一个页面,可以接受
缓存穿透防护 ✅ 多查一次数据库,可以接受
判断用户名是否被注册 ❌ 误判 = 告诉用户"已被占用",而它其实没被占
判断文件是否真的存在 ❌ 误判 = 该读的文件读不到

判断标准其实就一条:误判的代价,是否低于省下的内存。

附:完整代码

public class BloomFilterTest {
    static final int M = 100_000;       // 位数组大小(bit)
    static final int K = 3;             // 哈希次数
    static final int QUERIES = 100_000; // 每轮用多少个不存在的字符串去查

    public static void main(String[] args) throws IOException {
        StringBuilder sb = new StringBuilder();
        for (int n : new int[]{1000, 5000, 10000, 20000, 50000}) {
            BloomFilter f = new BloomFilter(M, K);
            for (int i = 0; i < n; i++) f.add("item-" + i);

            int falsePositive = 0;
            for (int i = 0; i < QUERIES; i++) {
                if (f.mightContain("other-" + i)) falsePositive++;
            }
            double actual = (double) falsePositive / QUERIES;
            double theory = Math.pow(1 - Math.exp(-(double) K * n / M), K);
            sb.append(String.format("%-12d %-14s %-14s %-12s%n",
                    n, pct(actual), pct(theory), pct(f.fillRatio())));
        }
        System.out.print(sb);
        Files.writeString(Path.of("report.txt"), sb.toString(), StandardCharsets.UTF_8);
    }
}

class BloomFilter {
    private final long[] words;
    private final int m, k;

    BloomFilter(int m, int k) {
        this.m = m; this.k = k;
        this.words = new long[(m + 63) >>> 6];
    }

    void add(String s) {
        for (int pos : positions(s)) words[pos >>> 6] |= 1L << (pos & 63);
    }

    boolean mightContain(String s) {
        for (int pos : positions(s)) {
            if ((words[pos >>> 6] & (1L << (pos & 63))) == 0) return false;
        }
        return true;
    }

    double fillRatio() {
        long ones = 0;
        for (long w : words) ones += Long.bitCount(w);
        return (double) ones / m;
    }

    private int[] positions(String s) {
        int h1 = mix(s.hashCode());
        int h2 = mix(fnv1a(s));
        int[] pos = new int[k];
        for (int i = 0; i < k; i++) pos[i] = Math.floorMod(h1 + i * h2, m);
        return pos;
    }

    private static int mix(int x) {
        x ^= x >>> 16; x *= 0x7feb352d;
        x ^= x >>> 15; x *= 0x846ca68b;
        x ^= x >>> 16; return x;
    }

    private static int fnv1a(String s) {
        int h = 0x811C9DC5;
        for (byte b : s.getBytes(StandardCharsets.UTF_8)) {
            h ^= (b & 0xFF);
            h *= 0x01000193;
        }
        return h;
    }
}
posted @ 2026-10-20 15:30  呱哒  阅读(11)  评论(0)    收藏  举报