从 Bitmap、BloomFilter到 Count-Min Sketch:三个数据结构,一条“用误差换空间“的演进线

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 >> 3i // 8:右移 3 位 = 除以 2³,得到字节下标
  • i & 7i % 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 << 30b0000_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):

  1. 取字节:self.bits[1]0b0010_1001
  2. >> 5:整体右移,把 5 号位挪到最右边(0 号位),低位被"挤"出边界丢弃
  3. & 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 是真值,误判为"存在" 。加上 & 12 & 1 = 0,正确报告目标位是 0。& 1 的作用就是屏蔽掉"跟着移下来的其他位"。

这套公式可以推广到任意 w 位存储单元(w 为 2 的幂):单元索引 = i >> log2(w)单元内偏移 = i & (w-1)。64 位字就是 i >> 6i & 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"(不会漏报)

靠两条公理推出来的:

  • 公理 1add(x) 时,x 的 k 个坑位被全部 置 1(add 的实现保证了这一点)
  • 公理 2 :位图里的 bit 只会 0→1,永远不会 1→0(没有删除操作)------旗子一旦插上就永远在

推论 :只要 x 来过,它的 k 个坑位此刻必然全是 1 → all(...) 必然返回 True。"x 来过、但查询却说不在"在逻辑上不可能发生

反过来,"一定不存在"是用反证法判定的:

  1. 假设 x 来过 → 它必然在坑 9 插过旗 → 坑 9 现在必是 1(旗不会被拔)
  2. 实际观察到坑 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、频次估计、倾斜检测

本质区别的三条主线

  1. 信息保真度递减 :Bitmap 记住"确切的每一位" → BloomFilter 记住"带指纹的近似存在性" → CMS 记住"带噪声的近似计数"。换来的都是空间
  2. 哈希的角色不同:Bitmap 根本不需要哈希(元素直接当地址);BloomFilter 用哈希把任意元素压进位数组;CMS 用哈希把计数误差"摊薄"到多个维度再取 min。
  3. 误差是有方向的、可设计的 :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_bitsw×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 从哪来):

  1. m 位数组、k 个哈希、插入 n 个元素后,某个 bit 仍是 0 的概率:P(bit=0) = (1−1/m)^(kn) ≈ e^(−kn/m)
  2. 假阳性 = k 个位置恰好全是 1:p = (1 − e^(−kn/m))^k
  3. 固定 m/n 求最优 k:k_opt = (m/n)·ln2;代回后 e^(−kn/m) = 1/2,所以最优时 p = (1/2)^k,即 k = log₂(1/p)
  4. 反解 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 这些亲戚,就都是同一套思想的不同变体了。

相关推荐
旖旎夜光27 分钟前
LeetCode 974:和可被 K 整除的子数组(前缀和) —— 题解
数据结构·c++·算法·leetcode·前缀和
SelectDB技术团队40 分钟前
StarRocks 适合做日志分析吗?
大数据·数据结构·后端·python·doris·日志分析·starrock
AI情绪识别开源1 天前
检信 ALLEMOTION OS 加密打包可执行程序 — 全面测试报告版本: v1.3功能测试 / 性能测试 /
开发语言·数据结构·人工智能·功能测试
伟大的车尔尼2 天前
贪心的概念
数据结构·算法·贪心
AI情绪识别开源2 天前
检信AI高一分班分科智选评估系统 v2.0测试报告
开发语言·数据结构·人工智能·科技
CoderYanger2 天前
A.每日一题:输入单词需要的最少按键次数 Ⅰ+Ⅱ
java·数据结构·算法·leetcode·面试
雪碧聊技术2 天前
KMP算法详解
数据结构
positive_zpc2 天前
进阶数据结构图——关键路径(四)
数据结构·图论·关键路径
孙克旭_2 天前
单链表进阶实操:5 道常考面试题详细解析【Java 实现】
java·开发语言·数据结构·单链表