Bitmap、BloomFilter、Count-Min Sketch------其实不是三个孤立的知识点,而是一条完整的演进线:如何用可控的误差,换数量级的空间。这篇文章把三者的原理、代码、数学、空间账本和选型一次讲透,顺便把位图那几行"天书"般的位运算逐行拆开。
先给结论:一句话定位
| 数据结构 | 回答的问题 | 误差方向 |
|---|---|---|
| Bitmap | 元素 x 精确在不在集合里? | 无误差 |
| BloomFilter | 元素 x 可能 在 / 一定不在? | 只会误报"存在"(假阳性) |
| Count-Min Sketch | 元素 x 大约出现了多少次? | 只会高估,不会低估 |
三者的演进关系:Bitmap 是地基 → BloomFilter 用"多哈希 + 位共享"把空间压到极致(牺牲精确性)→ Count-Min Sketch 把"bit"换成"counter"、把"判定"换成"计数"。
一、Bitmap:精确集合,一切的起点
1.1 原理
用一个 bit 数组的第 i 位表示整数域中元素 i 的存在性:
元素: 0 1 2 3 4 5 6 7
bit: [ 0 ][ 1 ][ 0 ][ 0 ][ 1 ][ 0 ][ 0 ][ 1 ]
↑ ↑ ↑
{1, 4, 7} 这个集合
Python 实现(bytearray 最小寻址单位是字节,8 个 bit 一批塞进一个 byte):
python
class Bitmap:
def __init__(self, n_bits):
self.bits = bytearray((n_bits + 7) // 8)
def set(self, i): self.bits[i >> 3] |= (1 << (i & 7))
def clear(self, i): self.bits[i >> 3] &= ~(1 << (i & 7))
def test(self, i): return (self.bits[i >> 3] >> (i & 7)) & 1
这三行位运算值得逐行拆开------它就是 C++ std::bitset、Linux 内核 set_bit/clear_bit/test_bit、Redis SETBIT 底层的同款套路。
1.2 逐行解析:set / clear / test
每个逻辑位编号 i 都要拆成两段信息:在哪个字节、在字节内第几位 。以 i = 13 为例:
i = 13 = 0b1101
└─┬┘└┬┘
高位部分 低 3 位
i >> 3=1 i & 7=5
"哪个字节" "字节内哪一位"
逻辑位: 0 1 2 3 4 5 6 7 | 8 9 10 11 12 13 14 15
└────────┬───────────┘ └────────┬───────────┘
物理存储: bits[0] bits[1]
位 13 → bits[1] 的 5 号位
两个等价关系(这就是"为什么是 3 和 7"):
i >> 3≡i // 8:右移 3 位 = 除以 2³,得到字节下标i & 7≡i % 8:7 =0b111,按位与只保留低 3 位------对 2 的幂取模可以换成与运算,这是位图代码的传统写法
字节内编号从右(低位)往左数,因为 1 << k 天然把"第 k 位"设为 1,偏移量与位编号直接对齐。
set:只点亮一盏灯,别碰其他灯
python
def set(self, i):
self.bits[i >> 3] |= (1 << (i & 7))
| 片段 | 作用 | 结果(i=13) |
|---|---|---|
i >> 3 |
定位字节 | 1 |
i & 7 |
定位字节内第几位 | 5 |
1 << 5 |
造掩码:只有目标位是 1 | 0b0010_0000 |
| ` | =` | 把掩码"刷"到字节上 |
|= 能只改一个位,靠的是 OR 的运算性质:
0 | 0 = 0 ← 其他位:0 遇 0 不变
0 | 1 = 1 ← 目标位:0 → 1(点亮)
1 | 0 = 1 ← 其他位:1 遇 0 不变
1 | 1 = 1 ← 目标位:本来就是 1,再刷一次也不变(幂等)
直观记忆:OR 里 1 是油漆(刷到哪哪变 1),0 是透明(刷了等于没刷) 。为什么不能 = 直接赋值?因为 bits[1] = 0b00100000 会把同字节里另外 7 个位全部抹掉:
bits[1] 原值 0 0 0 0 1 0 0 1 (0 号、3 号位已是 1)
掩码 0 0 1 0 0 0 0 0 (1 << 5)
────────────── OR ──────────────
结果 0 0 1 0 1 0 0 1 ← 只有 5 号位变了
clear:只吹灭一盏灯
python
def clear(self, i):
self.bits[i >> 3] &= ~(1 << (i & 7))
多了一步取反 。以 i=3 为例:1 << 3 → 0b0000_1000,取反 → 0b1111_0111(目标位是 0,其余全是 1),再用 AND 刷上去。记忆:AND 里 0 是橡皮擦(擦到哪哪归零),1 是透明。
bits[0] 原值 0 0 0 0 1 0 0 1 (0、3 号位是 1)
反掩码 1 1 1 1 0 1 1 1 (~(1 << 3))
────────────── AND ─────────────
结果 0 0 0 0 0 0 0 1 ← 3 号位清零,0 号位保留
set / clear 的掩码是一对镜像:set 造"目标位 1"的掩码配 OR;clear 造"目标位 0"的掩码配 AND。
test:看一眼某盏灯亮没亮
python
def test(self, i):
return (self.bits[i >> 3] >> (i & 7)) & 1
三步走(i=13,bits[1] = 0b00101001):
- 取字节:
self.bits[1]→0b0010_1001 >> 5:整体右移,把 5 号位挪到最右边(0 号位),低位被"挤"出边界丢弃& 1:只保留最低位 →1
为什么最后必须 & 1?看反例:
bits[1] = 0b0100_0000 (只有 6 号位是 1,5 号位是 0)
test(13): 0b01000000 >> 5 = 0b10 = 2
右移后,原来的 6 号位被移到了 1 号位 。不 & 1 的话函数返回 2------if bitmap.test(13) 里 2 是真值,误判为"存在" 。加上 & 1:2 & 1 = 0,正确报告目标位是 0。& 1 的作用就是屏蔽掉"跟着移下来的其他位"。
这套公式可以推广到任意 w 位存储单元(w 为 2 的幂):单元索引 = i >> log2(w),单元内偏移 = i & (w-1)。64 位字就是 i >> 6 和 i & 63。
1.3 关键特征与典型作用
- 精确 :无假阳性、无假阴性;O(1) 置位/查位,支持 AND/OR/XOR/NOT 整体位运算,天然表达集合交并补;支持删除(直接清零)。
- 空间取决于"值域大小"而非"元素个数"------这是它与哈希表的本质区别,也是软肋:值域 0~2³²-1 的 int 需要 512MB,哪怕只存 100 个数。
- 前提:元素必须能映射到 [0, n) 的整数(用户 ID、日期、IP 数值天然适合;字符串要先做映射)。
典型场景:
- 去重与基数统计 :UV 统计、
COUNT(DISTINCT)加速 - 日活/月活:每天一张 bitmap,一个用户一年仅需 365 bit ≈ 46 字节;任意时间窗活跃用户 = 多张 bitmap OR 合并
- 索引与扫描过滤 :Lucene 内部大量使用 Roaring Bitmap(对稀疏位图分块压缩,工程中位图几乎都以这种形态出现)
- 操作系统资源管理:内存页分配、inode 分配
二、BloomFilter:把"存在性"压到 10 bit 一个元素
2.1 原理
m 位 bit 数组 + k 个独立哈希函数。插入 时把元素哈希出 k 个位置全部置 1;查询 时 k 个位置全为 1 → "可能存在" ,任一位为 0 → "一定不存在"。
插入 "apple": h1→bit3, h2→bit7, h3→bit11 (置1)
查询 "banana": h1→bit3=1, h2→bit9=0 → 一定不存在
查询 "orange": h1→bit3=1, h2→bit7=1, h3→bit15=1 → "可能存在"
(其实是 apple 的位被撞上了 → 假阳性)
python
class BloomFilter:
def __init__(self, m_bits, k, hash_family):
self.bits = Bitmap(m_bits)
self.m = m_bits
self.k = k
self.h = hash_family
def add(self, x):
for i in range(self.k):
self.bits.set(self.h(i, x) % self.m)
def might_contain(self, x): # 注意:没有 false negative
return all(self.bits.test(self.h(i, x) % self.m)
for i in range(self.k))
2.2 通俗理解 might_contain:插旗游戏
把 x 用同样的 k 个哈希函数 再算一遍,得到 k 个坑位编号,挨个检查这些坑位上是不是都插着旗(=1)。只要有一个坑是空的,当场宣判:x 一定没来过 ;k 个坑全有旗,才说:x 可能来过。
一面墙有 m 个格子。每个元素进场时领 k 枚旗,往墙上 k 个指定格子各插一面(add)。查询 x 时,看 x 名下的 k 个格子是否都有旗。
2.3 为什么"没有 false negative"(不会漏报)
靠两条公理推出来的:
- 公理 1 :
add(x)时,x 的 k 个坑位被全部 置 1(add的实现保证了这一点) - 公理 2 :位图里的 bit 只会 0→1,永远不会 1→0(没有删除操作)------旗子一旦插上就永远在
推论 :只要 x 来过,它的 k 个坑位此刻必然全是 1 → all(...) 必然返回 True。"x 来过、但查询却说不在"在逻辑上不可能发生。
反过来,"一定不存在"是用反证法判定的:
- 假设 x 来过 → 它必然在坑 9 插过旗 → 坑 9 现在必是 1(旗不会被拔)
- 实际观察到坑 9 = 0 → 矛盾 → 假设不成立 → x 一定没来过 ✅
这个结论 100% 可信,一个空坑就是"铁证"。这也是 all() 短路的意义:遇到第一个 0 立即返回 False,不用看剩下的坑。
而 false positive 从哪来?坑位是共享的------别人的旗可能恰好插在你的坑里。数字例子(m=16,k=3):
add("apple"): 旗插在 3, 7, 11
add("grape"): 旗插在 3, 11, 15 ← 与 apple 重叠了 3 和 11,正常现象
might_contain("apple") → 查 3,7,11 → 全 1 → True ✅ 真阳性,正确
might_contain("banana") → 查 9,... → 坑9 = 0 → False ✅ 100% 没加过
might_contain("orange") → 查 3,11,15 → 全 1 → True ❌ 但 orange 从没来过!
(3、11 是 apple 的旗,15 是 grape 的旗)
orange 的三个坑恰好被别人插满了旗------灯都亮着,但没有一盏是你开的。
might_contain 返回 |
含义 | 可信度 |
|---|---|---|
| False | 一定不在 | 100%,可直接据此拦截 |
| True | 可能真在,也可能是撞坑 | 要打折扣,需回源验证 |
方法名敢叫 might_contain 而不叫 contains,就是这个原因。这个不对称性正是布隆过滤器全部工程价值的来源。
2.4 数学核心
插入 n 个元素后某一位仍为 0 的概率 ≈ e^(−kn/m),因此假阳性率:
p ≈ (1 − e^(−kn/m))^k- 最优哈希数:
k = (m/n)·ln2 - 代入后:
m/n ≈ 1.44·log₂(1/p)bits/元素
直观例子 :要求 1% 误判率 → 每个元素仅约 9.6 bit ,k=7。存 1 亿个 URL(哪怕每个 URL 100 字节)只需约 115MB,而哈希表要几个 GB,且空间与元素本身大小无关。
2.5 特征与典型作用
- 无假阴性、有假阳性:"不存在"侧 100% 可信,"存在"侧需回源验证
- 不支持删除:bit 是多个元素共享的,删一个会误伤别人(Counting Bloom Filter 用 counter 替代 bit 支持删除;Cuckoo Filter 是另一个支持删除的现代变体)
- 不能枚举元素、不存原始数据------只记得"指纹"
典型场景:
- 防缓存穿透:请求打到根本不存在的 key,先用 BF 挡掉,避免打穿缓存压垮数据库
- LSM-Tree 存储引擎(LevelDB / RocksDB / HBase / Cassandra):每个 SSTable 附一个 BF,读 key 前先问"这个文件里可能有吗?",避免无意义的磁盘 IO------BF 在工业界最经典的落地
- 爬虫 URL 去重、垃圾邮件/恶意 URL 黑名单、比特币 SPV 轻节点过滤交易
工程小注:实际实现通常不用 k 个独立哈希函数,而是 double hashing------
h1(x) + i·h2(x) mod m,两次哈希拼出 k 个位置,省计算。
三、Count-Min Sketch:只高估的近似计数器
3.1 原理
d 行 × w 列的计数器矩阵 ,每行配一个独立哈希函数。插入元素 x → d 个哈希位置全部 +1 ;查询频次 → 取 d 个位置的最小值。
w=8 列
[2, 0, 5, 1, 0, 3, 1, 0] ← h1
[1, 3, 0, 5, 2, 0, 1, 0] ← h2
[0, 1, 2, 4, 0, 3, 0, 5] ← h3
插入 "x": h1→col2 +1, h2→col3 +1, h3→col3 +1
count(x) = min(第1行col2, 第2行col3, 第3行col3)
python
class CountMinSketch:
def __init__(self, d, w, hash_family):
self.table = [[0] * w for _ in range(d)]
self.h = hash_family
def add(self, x, count=1):
for row in range(len(self.table)):
self.table[row][self.h(row, x) % len(self.table[0])] += count
def estimate(self, x):
return min(self.table[row][self.h(row, x) % len(self.table[0])]
for row in range(len(self.table)))
3.2 为什么取 min?
哈希碰撞只会让某个位置的计数偏高 (别人的量加在了你的格子里),永远不会偏低。所以 d 个估计值都 ≥ 真实值,min 是最紧的上界。
3.3 数学核心
参数由精度 ε、置信度 δ 决定:
w = ⌈e/ε⌉,d = ⌈ln(1/δ)⌉- 估计值满足:
estimate ≤ true + ε·N(N 为总插入次数),该界以≥ 1−δ的概率成立
核心卖点 :空间只与 ε、δ 有关,与元素个数 n、值域大小完全无关------几 KB 就能统计上亿条流量的频次。这是任何精确计数结构都做不到的。
3.4 特征与典型作用
- 单侧误差 :只高估、不低估;标准版不支持删除(支持负计数的变体可做近似抵消)
- 不能枚举元素,但配合一个小顶堆 即可做 Top-K / Heavy Hitters
- 还能估计内积 / Join 大小(两行对应位置乘积求和)
典型场景:
- 流式高频统计:网络设备识别大象流、实时热榜 Top-K
- 大数据引擎近似聚合 :ClickHouse 的
countMinSketch、Apache DataSketches(Hive/Druid/Presto 在用) - Spark AQE 倾斜检测:Adaptive Query Execution 内部用 CMS 探测 skewed join key,再对倾斜分区拆分
四、全面对比
| 维度 | Bitmap | BloomFilter | Count-Min Sketch |
|---|---|---|---|
| 本质 | 精确集合(bit 向量) | 概率集合(bit 向量 + k 哈希) | 概率计数表(counter 矩阵 + d 哈希) |
| 回答的问题 | 在不在(精确) | 在不在(近似) | 出现几次(近似) |
| 误差 | 无 | 假阳性 ≤ p,无假阴性 | 只高估 ≤ ε·N,不低估 |
| 空间 | O(值域大小),与 n 无关 | O(n·log(1/p)) ≈ 10 bit/元素 | O(1/ε · ln(1/δ)),与 n、值域都无关 |
| 插入/查询 | O(1) | O(k) | O(d) |
| 删除 | ✅ 支持 | ❌(Counting BF 可) | ❌(近似抵消变体可) |
| 集合运算 | ✅ AND/OR/XOR | ✅ OR(并集 BF) | ✅ 矩阵相加(可合并) |
| 对元素要求 | 必须映射到整数域 | 可哈希即可 | 可哈希即可 |
| 软肋 | 稀疏值域时空间爆炸 | 不能删、不能枚举、有误判 | 只能查"点"、不能枚举、有高估 |
| 典型场景 | 日活、去重、索引、资源管理 | 缓存穿透、SSTable 过滤、URL 去重 | 流式 Top-K、频次估计、倾斜检测 |
本质区别的三条主线
- 信息保真度递减 :Bitmap 记住"确切的每一位" → BloomFilter 记住"带指纹的近似存在性" → CMS 记住"带噪声的近似计数"。换来的都是空间。
- 哈希的角色不同:Bitmap 根本不需要哈希(元素直接当地址);BloomFilter 用哈希把任意元素压进位数组;CMS 用哈希把计数误差"摊薄"到多个维度再取 min。
- 误差是有方向的、可设计的 :BF 的误差只发生在"存在"方向,CMS 的误差只发生在"变大"方向------单侧误差正是它们能在工程中放心使用的原因(BF 说"没有"可以直接信;CMS 的估计可以作为上限做保守决策)。
五、选型指南
你的需求是什么?
│
├─ 值域是有限整数(ID、日期)且较稠密,要求精确
│ → Bitmap(大规模/稀疏场景用 Roaring Bitmap)
│
├─ 值域巨大或元素是任意字符串,只需要"一定不存在"的判定,
│ 且能容忍少量误判(可回源验证)
│ → BloomFilter
│
└─ 需要频次/计数(尤其流式数据、存不下全量),允许高估
→ Count-Min Sketch(只关心 Top-K 时尤为高效)
几个工程上的补充:
- 三者可组合:例如"用户画像 + 去重 + 计数"链路里,Bitmap 做活跃度分片、BF 做冷数据预过滤、CMS 做内容热度统计,各司其职。
- 同属 sketch 家族的还有 HyperLogLog(基数估计,~1.5KB 估计任意规模基数)、Count Sketch、MinHash------它们和 BF/CMS 一样,都是"用可控误差换数量级的空间"。
- 想动手实现的话,建议按演进顺序写:先写 Bitmap(BF 和 CMS 都拿它当底层存储),再写 BloomFilter(k 个哈希 + 同一个位数组),最后写 CMS(位数组换 counter 矩阵、
all()换min())------三者代码不到 100 行,但数学设计完全不同。
六、空间账本:参数怎么定,空间怎么算
前文的对比表里出现了 m、k、w、d 这些参数,这一章专门回答两个问题:每个结构到底占多少空间、公式怎么推出来的;参数具体该怎么取值。
6.1 空间公式速查表
| 结构 | 空间公式 | 大小由什么决定 | 与元素个数 n 有关? |
|---|---|---|---|
| Bitmap | M / 8 字节 |
值域大小 M | ❌ 无关 |
| BloomFilter | n·ln(1/p)/(ln2)² / 8 字节 |
元素数 n、误判率 p | ✅ 线性 |
| Count-Min Sketch | ⌈e/ε⌉ × ⌈ln(1/δ)⌉ × c 字节 |
精度 ε、置信度 δ、计数器宽度 c | ❌ 无关 |
先澄清一个容易误解的点:m_bits 和 w×d 不是"不影响空间的旋钮"------它们就是空间本身:
BloomFilter 空间 = m_bits / 8 字节 ← 位数组是唯一的存储
CMS 空间 = w × d × c 字节 ← 计数器矩阵是唯一的存储(c=计数器宽度,典型 4B)
上面这些公式本质是在解方程:给定误差目标,反解出你必须分配多少空间。同一个公式有两个使用方向:
| 方向 | 已知 | 求解 | 用途 |
|---|---|---|---|
| 正向(设计期) | 误差目标 p、ε、δ | 该开多大的 m / w / d | 写代码前定参数 |
| 反向(验收期) | 已有内存预算 B | 实际能达到什么误差 | 空间受限时评估 |
反向公式很实用:BF: p ≈ 0.6185^(m·8/n);CMS: ε ≈ e/w,δ = e^(−d)。
真正不影响空间的是值域大小和元素内容:值域 10 亿和 1 万亿、元素是 100B 的 URL 还是 10KB 的文档,BF(n=1亿, p=1%)都只占 114 MiB,CMS(ε=0.1%, δ=1%)都只占 53 KB。BF 的哈希把任何元素压成"指纹",值域和元素长度根本不进公式;CMS 连 n 都不进公式。
6.2 Bitmap:空间 = 值域 ÷ 8
一个 bit 对应值域里的一个可能取值,M 个取值就需要 M 个 bit:字节数 = ⌈M / 8⌉。
这就是初始化代码里 bytearray((n_bits + 7) // 8) 的含义------+7 再 //8 就是向上取整 :13 个 bit 需要 (13+7)//8 = 2 个字节(第 2 个字节只用 5 位,浪费 3 位)。
| 值域 M | 空间 |
|---|---|
| 10 亿用户 ID(0 ~ 10⁹) | 10⁹ bit = 125 MB |
| 全部 IPv4 地址(2³²) | 512 MiB |
| 一个用户一年的活跃天 | 365 bit ≈ 46 字节 |
关键洞察:Bitmap 的有效成本要摊到实际存入的 n 个元素上------每元素成本 = M / n bits 。10 亿 ID 存 8 亿活跃用户是 1.25 bit/元素(无可匹敌);只存 100 万用户就是 1000 bit/元素,比 BloomFilter(约 10 bit)浪费 100 倍。所以 Bitmap 适合稠密值域,稀疏时空间爆炸(Roaring Bitmap 就是为此做的分块压缩)。
6.3 BloomFilter:m = n·ln(1/p)/(ln2)²
四步推导(1.44 从哪来):
- m 位数组、k 个哈希、插入 n 个元素后,某个 bit 仍是 0 的概率:
P(bit=0) = (1−1/m)^(kn) ≈ e^(−kn/m) - 假阳性 = k 个位置恰好全是 1:
p = (1 − e^(−kn/m))^k - 固定 m/n 求最优 k:
k_opt = (m/n)·ln2;代回后e^(−kn/m) = 1/2,所以最优时p = (1/2)^k,即k = log₂(1/p) - 反解 m:
m/n = log₂(1/p)/ln2 = 1.44·log₂(1/p)
1.44 = 1/ln2,就是"log 换底"漏出来的常数。
| 目标误判率 p | 每元素占用 | 最优哈希数 k |
|---|---|---|
| 10% | 4.8 bit | 3 |
| 1% | 9.6 bit | 7 |
| 0.1% | 14.4 bit | 10 |
| 0.01% | 19.2 bit | 13 |
规律:误判率每降 10 倍,每元素多花 4.8 bit。
取值实操(n=1 亿,p=1%):
python
n, p = 10**8, 0.01
m_bits = math.ceil(n * math.log(1/p) / math.log(2)**2) # 958,505,934 bit ≈ 114.3 MiB
k = round(math.log2(1/p)) # 7
两个工程惯例与警告:
- 向上取整到 2 的幂 :RocksDB、RedisBloom 等常把 m 取到 2 的幂,让
hash % m变成hash & (m-1)位运算。本例取 m=2³⁰(128 MiB),实际 p≈0.58%,比目标还好。 - ⚠️ 容量超载会雪崩 :m 是按预计的 n 设计的,超载后误判率指数恶化 (按 n=1亿、p=1% 设计)------插入 2 亿时 p=15.8%,插入 4 亿时 p=67.9%,基本废掉。原因:
p ≈ (1−e^(−kn/m))^k里 n 在指数位置。所以设计时给 n 预留 50%~100% 余量,或使用自动级联扩容的 Scalable Bloom Filter。
6.4 Count-Min Sketch:w = ⌈e/ε⌉,d = ⌈ln(1/δ)⌉
CMS 的保证是 估计值 ≤ 真实值 + ε·N(N 为总事件数),以 ≥ 1−δ 的概率成立。两个参数各管一件事:
- 列数 w 管"误差多小" :某一行里其他元素撞进 x 格子带来的噪声期望 ≤ N/w(Markov 不等式),要噪声 < εN 只需
w ≥ e/ε。误差减半 → w 翻倍,空间线性换精度 - 行数 d 管"多有把握" :单行超标概率 ≤ 1/e,估计取 d 行的 min,只有 d 行全部 超标才失败:
P(失败) ≤ (1/e)^d ≤ δ ⇒ d = ⌈ln(1/δ)⌉。置信度从 99% 提到 99.9%,d 只从 5 涨到 7------对数换置信,很便宜
| ε | δ | w | d | 空间(32 位计数器) |
|---|---|---|---|---|
| 1% | 1% | 272 | 5 | 5.3 KB |
| 0.1% | 1% | 2719 | 5 | 53 KB |
| 0.1% | 0.1% | 2719 | 7 | 74 KB |
取值建议:
- d 固定取 5~8(δ=1% 对应 5),主要靠调 w
- ε 挂钩业务阈值 :做 Top-K / 大象流时关心频次 ≥ φN 的元素,取 ε ≈ φ/10(噪声最多是阈值的 1/10,高频元素不会被淹没,更小只是白花钱);做计数展示时取"可接受绝对误差 ÷ 预期总流量"
- 注意 ε 是绝对误差 :误差上限 = εN,结构不变时它随 N 线性涨(流过 100 亿事件时 ±2700 万),但空间一比特不多------这正是"空间与数据量解耦"的另一面
- 反直觉但重要:w、d 与数据规模无关。明天用户从 1 亿涨到 10 亿,w=2719、d=5 一个字都不用改
6.5 同一场景对比:1 亿用户(用户 ID 值域 10 亿)
| 结构 | 具体参数 | 空间 | 换来的能力 |
|---|---|---|---|
| Bitmap | M = 10⁹ | 125 MB | 精确判定 + 删除 + 集合运算 |
| BloomFilter (p=1%) | m = 958,505,934 bit,k = 7 | 114 MiB | 近似判定,无删除 |
| CMS (ε=0.1%, δ=1%) | w = 2719,d = 5 | 53 KB | 近似计数(回答的是另一个问题) |
此场景下 Bitmap 的每元素成本 = M/n = 10 bit,与 BF 的 9.6 bit 几乎打平------这不是巧合。临界点:
Bitmap 更省 ⇔ M/n < 1.44·log₂(1/p)
p=1% 时 ⇔ 稠密度 n/M > 10.4%
即:值域是元素数的 10 倍以内,Bitmap 又小又精确;超过 10 倍,BloomFilter 反超------更何况 BF 还能直接吃字符串,不用先做 ID 映射。
6.6 参数 ↔ 空间换算器
python
import math
# BloomFilter:正向设计
def bf_design(n, p):
m = math.ceil(n * math.log(1/p) / math.log(2)**2)
k = round(math.log2(1/p))
return m, k, m/8/2**20 # bits, 哈希数, MiB
# BloomFilter:反向验收(给定内存预算,实际能达到什么 p)
def bf_check(n, budget_bytes):
return 0.6185 ** (budget_bytes*8 / n)
# CMS:正向设计
def cms_design(eps, delta, counter_bytes=4):
w = math.ceil(math.e / eps)
d = math.ceil(math.log(1/delta))
return w, d, w*d*counter_bytes # 列数, 行数, 字节数
# CMS:反向验收
def cms_check(w, d):
return math.e/w, math.exp(-d) # 实际 ε, 实际 δ
6.7 一张表总结"谁决定谁"
| 空间由谁算出 | 与值域关系 | 与元素个数关系 | 与元素长度关系 | 参数超载后果 | |
|---|---|---|---|---|---|
| Bitmap | M/8,M=值域 | 直接决定 | 无关 | 无关(需可映射) | 无此概念 |
| BloomFilter | m = n·ln(1/p)/(ln2)² | 无关 | 线性 | 无关 | 误判率指数恶化 |
| CMS | w·d·c = ⌈e/ε⌉·⌈ln(1/δ)⌉·c | 无关 | 无关 | 无关 | 无此概念(误差随 N 线性涨) |
一句话收束:m_bits / w×d 不是"不影响空间的旋钮",它们就是空间本身;公式只是告诉你"想要某个误差水平,空间必须开到多大"。真正与空间无关的是值域和元素内容------这正是 BF/CMS 敢于处理海量任意数据的原因。
写在最后
这三个结构共同回答了一个问题:当数据大到存不下时,你愿意用多少确定性换多少空间?
Bitmap 不愿意放弃任何确定性,所以它只适合稠密的整数域;BloomFilter 放弃了"存在判定的确定性",换来 10 bit/元素的极致压缩;Count-Min Sketch 连"计数的确定性"也放弃了,换来与数据规模无关的固定空间。理解了这条取舍主线,再遇到 HyperLogLog、MinHash 这些亲戚,就都是同一套思想的不同变体了。