随机化与近似结构

布隆过滤器

它解决的那个问题

「这个 URL 爬过吗」「这个手机号注册过吗」「这个 key 在数据库里吗」—— 共同形状是海量集合的存在性判断,而且答「不在」时希望立刻返回、不要去查后端。

用 Set 存全部键当然可以,但键本身要占空间。100 万个短字符串(key-0…), Node v22 上实测 50.45 MB。布隆过滤器做同样的事,按位存只要 1.19 MB,约 1/42。

⭐ 代价写在名字里:它是个过滤器,不是集合。

查询返回「不在」  →  一定不在      ✅ 可以信
查询返回「在」    →  可能在,也可能是误判

📌 所以典型用法是挡在慢查询前面:布隆说不在就直接返回, 布隆说在才去查数据库。误判只会让你多查一次库,不会给出错误答案。

结构:一个位数组 + k 个哈希

function createBloom(m, k) {            // m 位,k 个哈希函数
  const bits = new Uint8Array(m);
  // 用一个哈希函数配不同的种子,省掉实现 k 个独立哈希
  const h = (s, i) => {
    let x = (2166136261 ^ Math.imul(i + 1, 2654435761)) >>> 0;
    for (let j = 0; j < s.length; j++) {
      x ^= s.charCodeAt(j);
      x = Math.imul(x, 16777619);
    }
    x ^= x >>> 16;  x = Math.imul(x, 2246822507);   // ⭐ 这三行是关键,别省
    x ^= x >>> 13;  x = Math.imul(x, 3266489909);   //   省掉它 k 个哈希就不独立
    return ((x ^ (x >>> 16)) >>> 0) % m;
  };

  return {
    add(s) { for (let i = 0; i < k; i++) bits[h(s, i)] = 1; },
    has(s) { for (let i = 0; i < k; i++) if (!bits[h(s, i)]) return false; return true; },
  };
}

⭐ 全部逻辑就这两行:加入时把 k 个位置全置 1;查询时只要有一位是 0 就一定没加过。

⚠️ 那三行收尾混合看着像装饰,其实是这个结构能不能用的分水岭 —— 省掉它,误判率会系统性地高出一到两成,见下面「k 个哈希真的独立吗」一节。

「有一位是 0 就一定没加过」这句是它不会漏判的全部依据。这一条是结构性的, 实测把位数组灌到 100% 全满也不漏判:

n=  800  m=10000  k=7   位填充  43%   漏判 0
n=  800  m= 1000  k=7   位填充 100%   漏判 0
n=  800  m=  100  k=7   位填充 100%   漏判 0
n=50000  m= 1000  k=7   位填充 100%   漏判 0     合计 57400 次查询,漏判 0

📌 位填充 100% 时它对所有键都说「在」—— 完全没用,但依然不漏判。 ⭐ 这正是布隆的保证的形状:它只会变得更没用,不会变得不正确。

🚨 上面那份代码占 9.54 MB,不是 1.19 MB

new Uint8Array(m) 里的 m 是位数,而 Uint8Array 一个格子是一个字节 —— 这份代码是一个字节存一位,白白多用 8 倍。100 万键、m/n=10 时实测:

Set 存一百万个短字符串              50.45 MB   (三次测量完全一致)
上面那份 Uint8Array(m)               9.54 MB   ← 一字节一位,8 倍浪费
真按位存 Uint8Array(m >> 3)          1.19 MB   ← 开头那个数字说的是它

⭐ 上面那份留成一字节一位是为了讲清楚逻辑。真要省空间,改三处就行:

const bits = new Uint8Array(m >> 3);                       // 位数 / 8
add(s) { for (let i=0;i<k;i++){ const p=h(s,i); bits[p>>3] |= 1 << (p&7); } },
has(s) { for (let i=0;i<k;i++){ const p=h(s,i); if(!(bits[p>>3] & 1<<(p&7))) return false; } return true; },

⚠️ 顺带一个量内存时的坑:TypedArray 算在 external 里,不进 heapUsed。 只看 heapUsed 会量出布隆「占 0.01 MB」,看着像天大的胜利,其实是量具没读到。

误判率:能算,也对得上

误判发生在「k 个位置恰好都被别的键置过 1」。概率是:

p_误判 = (1 - e^(-k·n/m))^k        n = 已插入元素数,m = 位数,k = 哈希个数

⚠️ 公式好看不算数,实测才算。换 20 组不同前缀的 key 各测一遍,每组查 20 万个 不存在的键(只测一组毫无意义,原因见下一节):

n      m       k   m/n    理论      实测中位数   最小~最大      位填充率(实测/理论)
1000   10000   7   10.0    0.82%      0.81%     0.78 ~  0.94      50% / 50%
1000    8000   6    8.0    2.16%      2.19%     1.96 ~  2.32      53% / 53%
1000    4000   3    4.0   14.69%     14.69%    13.68 ~ 15.73      53% / 53%
2000   10000   7    5.0   13.78%     13.70%    13.00 ~ 14.53      75% / 75%
5000   10000   7    2.0   80.68%     80.63%    79.45 ~ 82.22      97% / 97%

⭐ 五行的中位数都落在理论值上(相对偏差最大 1.6%),这个公式是真的能用来定参数的。

⭐ 最后一行是这张表的重点:m/n 从 10 掉到 2,误判率从 0.8% 飙到 81%。 位填充率也从 50% 涨到 97% —— 位数组几乎全是 1,那 has 自然什么都说「在」。

🚨 所以布隆过滤器必须按预估的元素总量提前把 m 算好。 它不像哈希表能扩容 —— 位数组一旦装满,唯一的办法是新建一个更大的重新灌一遍。 ⚠️ 「先小点,不够再说」在这里是行不通的,因为没有报错, 只是误判率悄悄从 1% 变成 81%,而调用方完全感觉不到。

🚨 k 个哈希真的独立吗:病根不在种子,在出口

「用一个哈希配 k 个种子」这个省事办法是对的。最容易写出来的版本是 let x = 2166136261 ^ i,配上裸的 FNV-1a 循环,跑起来一切正常 —— 但它有一个可以精确证明的毛病:

FNV-1a 每轮做 x = (x ^ c) * 16777619,乘数是奇数
  → 乘法的进位只往高位走,异或不跨位
  → 最终 bit0 = 种子的 bit0 ⊕ 各字符的 bit0,一条线性式子
而种子的 bit0 随 i 变(2166136261 ^ i 是这样,i 乘个奇数也是这样)
再加上 m=10000 是偶数,x % m 与 x 同奇偶
  → k 个位置的奇偶被 i 的奇偶完全锁死

不是推测。实测 2000 个键 × k=7,位置奇偶严格交替,零反例:

key-0 的 7 个位置   8857  6286  2043  5184   525  9746  7503
        奇偶         1     0     1     0     1     0     1
key-1 的 7 个位置   1238  3905  4424  2803  2906  7365  9884
        奇偶         0     1     0     1     0     1     0

⭐ 修哪儿?直觉会说「种子太弱,换个散一点的」—— 实测这条路是错的。 把「种子打散」和「出口加收尾混合」拆开各测一遍(每格 20 组 key 的中位数):

                            n=1000       n=1000       n=2000       n=5000
                            m=10000      m=4000       m=10000      m=10000
理论                          0.82%       14.69%       13.78%       80.68%
① ^i 种子 + 无混合             0.99% +21%  16.35% +11%  15.33% +11%  82.59%  +2%
② ^i 种子 + 有混合             0.81%  -1%  14.66%  -0%  13.64%  -1%  80.28%  -0%
③ 打散种子 + 无混合            0.88%  +8%  16.39% +12%  15.73% +14%  82.44%  +2%
④ 两处都做                     0.81%  -1%  14.69%  -0%  13.70%  -1%  80.63%  -0%

🚨 只换种子(③)几乎没用,只加收尾混合(②)就完全修好了。 奇偶锁死的检出率也一样:①③ 都是 100%,②④ 掉到 1.7% / 1.5%(随机水平)。 📌 原因是上面那条线性式子 —— Math.imul(i+1, 2654435761) 乘的还是奇数, bit0 照样随 i 变。种子怎么换都躲不开,只有在出口把位彻底搅一遍才行。

🚨 除了均值偏高,更要命的是散布:① 的第三行在 20 组 key 上从 13.20% 跳到 19.44%,而抽样噪声只有 ±0.079%。换一批 key 就换一个误判率, 公式失去预测力。加了混合之后同一行是 13.68~15.73%,中位数精确等于理论值。

⭐ 判据:实测误判率系统性高于公式(而不是围着公式上下抖),就是哈希在结块。 📌 最省事的自测:m 取质数(比如 10007)再跑一遍。奇偶那个结构会消失 (实测符合率从 100% 掉到 1.4%),如果误判率跟着降了,问题就在哈希不在参数。

⭐ k 取多少:一条 U 形曲线

固定 m/n = 10,k 从 1 试到 14(20 组 key,每组查 10 万次):

 k    理论     实测中位数    最小~最大        k    理论     实测中位数
 1    9.52%      9.52%     9.36~9.65        7    0.82%      0.81%   ← 最低
 2    3.29%      3.25%     3.17~3.42        8    0.85%      0.83%
 3    1.74%      1.71%     1.64~1.85        9    0.91%      0.91%
 4    1.18%      1.16%     1.09~1.32       10    1.02%      1.00%
 5    0.94%      0.92%     0.88~1.02       12    1.36%      1.35%
 6    0.84%      0.82%     0.77~0.90       14    1.90%      1.85%

两头都差:k 太小,位用得不够,随便撞上;k 太大,位数组填得太满, 反而更容易全中。最优值在 k = (m/n)·ln2 —— 这里是 10 × 0.693 ≈ 6.93, 实测最低点正是 k=7。

⚠️ 但 k=6、7、8 的实测区间(0.770.90 / 0.760.91 / 0.780.96)几乎完全重叠, 理论值也只差 0.03 个百分点。曲线在最低点附近是平的,纠结 6 还是 7 没有意义 —— 真正要避开的是 k=13 和 k≥12 那两头。

📌 记这一条就够:先按能接受的误判率定 m/n,再用 (m/n)·ln2 算 k。 常用档位(同样 20 组 key,每组 50 万次):

m/n=10  k=7    理论 0.819%    实测中位数 0.812%   (区间 0.746~0.912%)
m/n=15  k=10   理论 0.074%    实测中位数 0.076%   (区间 0.068~0.091%)

🚨 为什么不能删除

很自然的想法是「删除就把那 k 位置回 0」。不行。

位是共享的:某一位可能同时属于好几个键。置 0 会把那些键一起删掉。

del(s) { for (let i = 0; i < k; i++) bits[h(s, i)] = 0; }   // ❌ 会误伤别人

实测:m=10000、k=7、装入 500 个键,随机删掉其中 1 个,重复 2000 次 —— 剩下 499 个里中位数有 2 个查不到,最坏一次伤了 11 个:

   m      k    被误伤的键数:中位数  p10~p90   2000 次里最坏   理论期望 499·(1-(1-k/m)^k)
10000     7                    2      1~ 5          11              2.44
 5000     7                    5      2~ 8          14              4.87
 2000     7                   12      8~17          25             12.10
 1000     7                   24     18~30          37             23.94

⚠️ 报 p10p90 而不报「最小最大」是有原因的:极值不稳定,重复次数一变就变。 换 8 个随机种子各跑 2000 次,中位数与 p10/p90 逐格一模一样(只有最后一行的 p90 在 29 和 30 之间摆),而「最坏一次」每个种子都不同。 ⭐ 报统计量之前,先换几个种子看它稳不稳 —— 稳的才配写进正文。

⚠️ 位数组越挤,一次删除的杀伤面越大 —— 而这正是你会想删东西的场景。 ⚠️ 更糟的是这个错破坏了布隆唯一的保证:本来「说不在就一定不在」, 删除之后连这条都不成立了 —— 上游代码正是靠这条才敢直接返回。

⭐ 真要删除,用计数布隆过滤器:每个位置从 1 位换成 4 位计数器, 加入时 +1、删除时 -1。代价是空间变成 4 倍,也就退掉了布隆一半的优势。

面试怎么答

被问「海量数据判断是否存在」,一个完整的回答包含四段:

  1. 结构:位数组 + k 个哈希,加入置 1、查询全查
  2. 保证:说「不在」一定对,说「在」可能错 —— 所以只能做前置过滤
  3. 定参:按可接受误判率定 m/n,k = (m/n)·ln2;1% 大约 10 bit/键
  4. 限制:不能删除(位共享)、不能扩容(要重建)、装填过量会静默劣化

⭐ 第 4 点最能体现有没有真用过 —— 前三点背得到, 「误判率会悄悄从 1% 变成 81% 而不报错」这种话只有量过的人才说得出。

📌 还想再加一层就说第 5 点:k 个哈希必须真的独立。 拿 FNV-1a 配 k 个种子直接用,k 个位置的奇偶会被锁死,误判率系统性高出一到两成、 而且换批 key 就变 —— 公式还在,预测力没了。 ⭐ 加一句「而且换种子修不好,得在出口做一遍收尾混合」,就说明是真量过的。

⚠️ 关于配套题

力扣上没有布隆过滤器的题 —— 它是面试问答题,不是判题器上的题。 下面那两道是「最接近的思路题」:设计哈希集合是它的确定性版本, 对照着写一遍能想清楚「省掉键本身」到底省了什么。

下一步

布隆用误判率换空间。下一篇的一致性哈希 换的是另一样东西:它放弃「绝对均匀」,换来扩容时只迁移少量数据 —— 实测简单取模要搬 83.3%,一致性哈希只搬 16.6%,恰好是理论下界。 ⭐ 那一篇会撞上同一个坑的另一面:哈希不够散,虚拟节点加多少个都没用。

练习

勾选记录做过哪些,0 / 2 道。进度只存在这台设备的浏览器里;同一道题在别篇勾过,这里也会显示已做。