实验 04:位图索引与 Bloom 过滤器 —— 把 WHERE 子句变成位运算
> 用一张 50 万行的地址表,测出"逐行判断"和"位图 AND"的差距,再压缩位图、再把空间压到极限变成概率结构。
一、实验目标
完成本实验后,你应当能够:
1. 手工构造等值编码(equality encoding)位图索引,并用位运算回答组合条件的查询。
2. 解释同一份位图用"布尔列表"和"Python 大整数"两种表示时,为什么查询性能差一个量级。
3. 说清楚范围编码(range encoding)与等值编码的差别,知道什么时候该用哪一个。
4. 实现 WAH(Word-Aligned Hybrid)字对齐游程压缩,量化"数据越稀疏、越聚簇压缩越划算"。
5. 实现 Bloom 过滤器,实测假阳性率并与理论公式对比,理解它多出两个可控旋钮:位数 m 与哈希个数 k。
6. 理解三者的取舍:精确位图快但费空间,WAH 省空间,Bloom 最省但有假阳性、需要回盘复核。
二、预备知识
2.1 位序列
位序列(bit sequence)是长度为 N 的 0/1 串,第 j 位表示"第 j 行是否满足某个条件"。数据库里一张 N 行的表,一个谓词(predicate)就能生成一条位序列。
```
行号 j 0 1 2 3 4 5 6 7
city='NY' 1 0 0 1 0 1 0 1
```
这就是"第 0、3、5、7 行在纽约"。
位序列的关键性质:**逻辑运算可以按位并行做**。
| SQL 逻辑 | 位运算 | Python 写法 |
| --- | --- | --- |
| `p AND q` | 按位与 AND | `a & b` |
| `p OR q` | 按位或 OR | `a \| b` |
| `NOT p` | 按位取反 NOT | `~a` |
| `p AND NOT q` | AND NOT | `a & ~b` |
2.2 位序列的两种物理表示
| 表示方式 | 每个 0/1 占多少空间 | AND 一次要做多少事 | 备注 |
| --- | --- | --- | --- |
| 布尔列表 `list[bool]` | Python 里每个元素是对象引用,实际远大于 1 位(约 8 字节指针 + 单例对象) | N 次 Python 层循环 + N 次布尔判断 | 直观但极慢 |
| Python 大整数 `int` | 1 位(紧凑二进制位,连续的机器字) | 一次 CPU 位运算指令,覆盖全部 N 位 | `bin(x).count("1")` 统计置位数 |
第二种是数据库里的做法:把 N 行压缩成 N 个连续的比特,CPU 的一条 `AND` 指令一次处理 64 位,Python 的大整数运算在幕后把这件事扩展到任意长度。
2.3 等值编码与范围编码
设属性 A 有取值 v₁…v_d,表有 N 行。
**等值编码(equality encoding)**:每个取值一条位序列。
```
E_i[j] = 1 ⟺ A[j] = v_i
```
查 `A = v_i` 只需取第 i 条;查 `A ∈ {v_i, v_k}` 是 `E_i | E_k`;查 `A ≤ v_i` 需要把 ≤ i 的所有位序列都 OR 起来。
**范围编码(range encoding)**:每个取值一条位序列,存的是"小于等于"。
```
R_i[j] = 1 ⟺ A[j] ≤ v_i
```
查 `A ≤ v_i` 只需一次取 `R_i`;查 `A = v_i` 变成 `R_i & ~R_{i-1}`。
两种方案谁更好取决于查询混合比,系统的比较可以参考 Chan 与 Ioannidis 1998 年发表的位图索引比较工作。
2.4 WAH 压缩
WAH(Word-Aligned Hybrid)是一种对位图做游程压缩(run-length encoding)的方案,原始论文是 Wu、Otoo、Shoshani 发表于 ACM TODS 2006 的 *Optimizing Bitmap Indices with Efficient Compression*。
做法是把位序列按机器字长(本实验 32 位)切成若干字(word):
```
字类型 含义 存储开销
──────────────────────────────────────────────────────
0-fill 连续若干个字全是 0 1 个字(头里记录重复次数)
1-fill 连续若干个字全是 1 1 个字
literal 字内 0/1 混杂,无法压缩 1 个头 + 该字原样存
```
```
原始: 00000000...0000 | 0010...1001 | 1111...1111 | 0000...0000
└─ 30 个字全 0 ─┘ └ 混杂,原样 ┘ └ 20 个全 1 ┘ └ 剩余全 0 ┘
WAH: [0-fill, 30] [literal ...] [1-fill, 20] [0-fill, ...]
1 个字 2 个字 1 个字 1 个字
```
图 1:WAH 的编码方式。字对齐的好处是某些运算可以直接在压缩态上做,不必解压。
另一种主流实现是 Roaring Bitmap:把 32 位整数按高 16 位分到 65536 个桶,每个桶内根据稀疏程度选用 array(16 位整数数组)、bitset(1024 个 64 位字)或 run(游程)三种容器之一。它和 WAH 是同一问题的两条技术路线。
2.5 Bloom 过滤器
Bloom 过滤器(Bloom filter)是 Bloom 于 1970 年提出的近似集合成员判定结构。它的目标不是"回答准确",而是"用极小空间挡掉绝大多数不可能命中的查询"。
结构:长度为 m 的位数组 + k 个哈希函数。
• 插入键 x:算出 k 个位置 h₁(x)…h_k(x),把这些位置置 1。
• 查询键 y:算出 k 个位置,若其中有任何一个为 0 → **一定不在**(无假阴性);若全为 1 → **可能在**,需要回原始数据复核。
```
插入 x h1(x) h2(x) h3(x)
↓ ↓ ↓
位数组 ... 0 1 0 0 1 0 0 1 0 ...
↑ 这些位置可能被别的键碰出来
查询 y (y 没插过,但碰巧落在三个已置 1 的位置上)→ 判定"可能存在"
↑ 这就是假阳性(false positive)
```
假阳性率:
```
p ≈ (1 − e^{−kn/m})^k
```
符号含义:
• `n`:插入的元素个数(个)
• `m`:Bloom 过滤器的位数(bit)
• `k`:哈希函数个数(个)
• `e^{−kn/m}`:插入全部 n 个元素后,某个特定位仍为 0 的概率
• `p`:一次"不存在键"查询被误判为存在的概率
最优哈希函数个数:
```
k* = (m/n) · ln 2
```
给定目标假阳性率 p 时所需位数:
```
m = − n · ln p / (ln 2)²
```
应用:Google Bigtable(OSDI'06)用它避免读不存在的 SSTable;Apache Cassandra 用它减少磁盘读取;PostgreSQL 有 bloom 扩展索引,属于有损索引,命中后必须回表复核。

图 2:一个简单的例子,把三条谓词位图用 OR、AND 组合成结果。

图 3:50 万行表上的真实测量。左图是查询耗时对比,右图把"建索引"也放进来,说明索引的代价要靠多次查询摊薄。
三、实验原理
3.1 为什么位图查询能快一个量级
朴素逐行扫描要做 N 次 Python 解释层循环,每次做若干次比较和布尔判断。位图方案把这些全部下推到 CPU 的位运算上:`city[0] & (street[1] | street[2])` 这一句里,Python 只是发出一条指令,真正干活的是 C 层的大整数运算代码,一次覆盖 500,000 位(62.5 KiB)。统计命中数用 `bin(x).count("1")`(Python 3.8 之后可用 `int.bit_count()`)。
代价有两个:建索引要花钱(把 N 行扫一遍位操作);结果是有位点还要还原成行号(此处只统计数量,跳过了这一步,真实系统里要额外做一次位扫描)。
3.2 索引要多少次查询才回本
设:
• `C_build`:构建索引耗时
• `C_naive`:一次朴素查询耗时
• `C_bm`:一次位图查询耗时
则查询次数 Q 达到下面的值之后,索引才"赚回来":
```
Q ≥ C_build / (C_naive − C_bm)
```
代入本实验的参考实测值,后面会算出一个具体的数字。这是数据库决定是否建索引的核心算式:写多读少的表不该建索引。
3.3 空间的三档取舍
| 方案 | 每行的空间开销 | 是否精确 | 运算能否在压缩态上做 |
| --- | --- | --- | --- |
| 精确位图(大整数) | 1 bit × 取值个数 | 精确 | 天然支持 |
| WAH 压缩 | 取决于数据(稀疏时远小于 1 bit) | 精确 | 部分支持(字对齐设计的目的) |
| Bloom 过滤器 | m/n bit(通常 4–16) | 概率的(有假阳性) | 只支持成员判定 |
三者不是替代关系:位图索引用在列上(取值个数少的低基数列),Bloom 用作某个数据块或文件的摘要,回答"这个块里到底有没有这个键"。
四、实验环境与准备
4.1 软件要求
• Python 3.10 及以上
• 必装:`numpy`、`matplotlib`
• 选装:`pandas`(只用于把输出表格化,不装也能跑)
```bash
pip install numpy matplotlib pandas
```
4.2 目录约定
本指导书位于 `…/教学产出/03-实验指导书/实验04-位图与Bloom过滤器.md`,图件在 `…/教学产出/figures/`,用相对路径 `../figures/…` 引用。
4.3 两种学法
• **A 类(必做,自包含)**:只用 `numpy` + 标准库。
• **B 类(选做)**:调用 ExplainDB 教学库的 `system.bit_sequences`、`system.indexes.bitmap_indexes`、`system.indexes.bloom_filters`。
所有 A 类代码都可以直接 `python xxx.py` 运行,无需仓库文件。
五、实验步骤
步骤 1:造数据,先做一次最朴素的扫描
```python
import time
import numpy as np
rng = np.random.default_rng(11)
n = 500_000
city = rng.integers(0, 5, n) # 5 个不同的城市编号
street = rng.integers(0, 10, n) # 10 条不同的街道编号
def naive_scan(rep=5):
"""逐行判断:city=0 AND (street=1 OR street=2)"""
ts, cnt = [], 0
for _ in range(rep):
t0 = time.perf_counter()
cnt = 0
for i in range(n):
if city[i] == 0 and (street[i] == 1 or street[i] == 2):
cnt += 1
ts.append((time.perf_counter() - t0) * 1000)
return float(np.median(ts)), cnt
def one_pass():
"""只跑一次的干净版本,用于核对结果"""
cnt = 0
for i in range(n):
if city[i] == 0 and (street[i] == 1 or street[i] == 2):
cnt += 1
return cnt
print("命中行数 =", one_pass())
print("期望行数 =", n * (1 / 5) * (2 / 10))
naive_ms, hits = naive_scan()
print(f"朴素逐行扫描:{naive_ms:.3f} ms(5 次中位数),命中 {hits} 行")
```
输出的"期望行数"是 20,000(= 500,000 × 1/5 × 2/10),实测命中会在它附近浮动,这是随机数据的正常波动。
参考实测:命中 19,845 行,朴素扫描 153.588 ms(5 次中位数)。耗时和你的机器有关,但命中数是确定的——随机种子固定,所以每次应该都是 19,845。
步骤 2:构建位图索引,用一句话回答同一个查询
每个取值一条位序列,用 Python 大整数表示(第 i 行对应第 i 个比特)。
```python
import time
import numpy as np
rng = np.random.default_rng(11)
n = 500_000
city = rng.integers(0, 5, n)
street = rng.integers(0, 10, n)
---- 建索引:扫一遍数据,把每一行的位置置到对应取值的位序列上 ----
t0 = time.perf_counter()
city_bm = [0] * 5
street_bm = [0] * 10
for i in range(n):
city_bm[city[i]] |= (1 << i)
street_bm[street[i]] |= (1 << i)
build_ms = (time.perf_counter() - t0) * 1000
def bitmap_query(rep=5):
ts, cnt = [], 0
for _ in range(rep):
t0 = time.perf_counter()
mask = city_bm[0] & (street_bm[1] | street_bm[2])
cnt = mask.bit_count()
ts.append((time.perf_counter() - t0) * 1000)
return float(np.median(ts)), cnt
bm_ms, bm_hits = bitmap_query()
print(f"构建索引:{build_ms:.1f} ms")
print(f"位图查询(int.bit_count):{bm_ms:.4f} ms(5 次中位数),命中 {bm_hits} 行")
---- 同一个问题:换用字符串方式的 popcount ----
mask = city_bm[0] & (street_bm[1] | street_bm[2])
ts = []
for _ in range(5):
t0 = time.perf_counter()
cnt_bin = bin(mask).count("1") # 先转成二进制字符串再数字符
ts.append((time.perf_counter() - t0) * 1000)
print(f"仅统计数量:bin().count() {np.median(ts):.4f} ms,"
f"int.bit_count() 更快;两者结果一致:{cnt_bin == bm_hits}")
---- 核对:位图结果必须和朴素扫描完全一致 ----
naive_cnt = sum(1 for i in range(n)
if city[i] == 0 and (street[i] in (1, 2)))
assert naive_cnt == bm_hits, (naive_cnt, bm_hits)
print("结果与朴素扫描一致 ✓")
```
参考实测:
| 项目 | 耗时 | 备注 |
| --- | --- | --- |
| 构建索引 | 7,660 ms | 一次性投入,扫 50 万行做 50 万次位操作 |
| 朴素逐行扫描 | 153.588 ms | 每次查询都要全表走一遍 |
| 位图索引查询 | 1.5184 ms | AND + OR + 用 `bin(x).count("1")` 统计置位数 |
| 位图查询(换 `int.bit_count()`) | 比上一行快一到两个量级 | 不构造字符串,直接在整数上做 popcount |
| 加速比 | 约 101× | 153.588 ÷ 1.5184 |
| 命中行数 | 19,845 | 与朴素扫描一致 |
注意最后两行:查询耗时的大头往往不是 AND/OR 本身,而是"怎么数 1"。`bin(mask).count("1")` 要先生成一个 50 万字符的字符串,这就是那 1.5184 ms 的来源;Python 3.8 之后提供的 `int.bit_count()` 直接在二进制表示上做 popcount,快得多。参考实测表格采用的是前者(比较保守的口径),换成后者加速比会明显上升——请把你测到的两个数字都记录下来。
同一份代码在另一台机器(Python 3.12)上得到 7,247.9 / 136.793 / 1.4858 ms,加速比约 92×,命中数同样是 19,845。命中数不随机,是因为 numpy 的种子固定;耗时随机器波动很正常。
步骤 3:回本次数与"该不该建索引"
把步骤 1、2 的数字代入 3.2 节的算式:
```python
build_ms = 7660.0 # 换成你自己测出来的值
naive_ms = 153.588
bm_ms = 1.5184
breakeven = build_ms / (naive_ms - bm_ms)
print(f"需要查询 {breakeven:.1f} 次以上,索引才回本")
print(f"单次节省 {(naive_ms - bm_ms):.3f} ms")
print(f"加速比 {naive_ms / bm_ms:.1f}×")
print(f"索引读取压缩:{(500_000 / 8 / 1024):.1f} KiB / 每条位序列")
```
按参考实测,答案是约 50 次。也就是说这张表只要被按同样的模式查询 50 次以上,建位图索引就是划算的;如果这张表每天只查两三次,这笔投资要一年才回本。
步骤 4:布尔列表 vs 大整数,同一个结构的两种物理表示
```python
import time
import numpy as np
rng = np.random.default_rng(11)
n = 200_000 # 列表实现太慢,这里用小一号的 N
col = rng.integers(0, 5, n)
---------- 表示 1:布尔列表 ----------
t0 = time.perf_counter()
bm_list = [[False] * n for _ in range(5)]
for i in range(n):
bm_list[col[i]][i] = True
build_list = (time.perf_counter() - t0) * 1000
t0 = time.perf_counter()
r_list = [a and b for a, b in zip(bm_list[0], bm_list[1])]
q_list = (time.perf_counter() - t0) * 1000
---------- 表示 2:Python 大整数 ----------
t0 = time.perf_counter()
bm_int = [0] * 5
for i in range(n):
bm_int[col[i]] |= (1 << i)
build_int = (time.perf_counter() - t0) * 1000
t0 = time.perf_counter()
mask_int = bm_int[0] & bm_int[1]
q_int = (time.perf_counter() - t0) * 1000
print(f"{'':12s}{'构建 (ms)':>12s}{'一次 AND (ms)':>16s}{'存储空间(粗估)':>20s}")
print(f"{'布尔列表':<12s}{build_list:>12.1f}{q_list:>16.4f}{'≈ 每个 bool 1 字节以上':>20s}")
print(f"{'大整数':<14s}{build_int:>10.1f}{q_int:>16.5f}{'≈ N/8 字节':>20s}")
print(f"AND 加速比:{q_list / q_int:.0f}×")
```
两种表示的信息内容完全一样,差别只在物理布局:
• 布尔列表的 AND 要在 Python 层做 N 次循环,每次解释器都要耗费若干十几个字节码;
• 大整数的 AND 是一条指令,处理 N 位只需 N/64 个机器字的操作。
这也解释了为什么 Python 教学库里的 `BitListBitSequence` 和 `IntegerBitSequence` 是同一个抽象的两个实现:上层逻辑不动,换底层实现就能拿到数量级的性能差异。
步骤 5:等值编码 vs 范围编码
同一个范围查询,两种编码的工作量完全不同。
```python
import time
import numpy as np
rng = np.random.default_rng(11)
n = 200_000
house_number = rng.integers(1, 1001, n) # 1000 个不同取值
---------- 等值编码:E_i[j] = 1 ⟺ A[j] = v_i ----------
t0 = time.perf_counter()
eq_bm = [0] * 1001
for i in range(n):
eq_bm[house_number[i]] |= (1 << i)
build_eq = (time.perf_counter() - t0) * 1000
t0 = time.perf_counter()
acc = 0
for v in range(1, 300): # house_number < 300,要 OR 掉 299 条
acc |= eq_bm[v]
eq_cnt = acc.bit_count()
q_eq = (time.perf_counter() - t0) * 1000
---------- 范围编码:R_i[j] = 1 ⟺ A[j] ≤ v_i ----------
先按取值把行号分组,再从最小取值开始向前缀累加。
注意:分组时用 .tolist() 转成 Python int,避免 numpy 整数位移溢出。
t0 = time.perf_counter()
house_number_py = house_number.tolist()
groups = {v: [] for v in range(1, 1001)}
for row, v in enumerate(house_number_py):
groups[v].append(row)
range_bm = [0] * 1001
acc2 = 0
for v in range(1, 1001):
for row in groups[v]:
acc2 |= (1 << row)
range_bm[v] = acc2
build_range = (time.perf_counter() - t0) * 1000
t0 = time.perf_counter()
res = range_bm[299] # 直接取出 "A ≤ 299",即 house_number < 300
range_cnt = res.bit_count()
q_range = (time.perf_counter() - t0) * 1000
print(f"等值编码:构建 {build_eq:8.1f} ms,范围查询(OR 299 条){q_eq:9.3f} ms,命中 {eq_cnt}")
print(f"范围编码:构建 {build_range:8.1f} ms,范围查询(取 1 条){q_range:9.5f} ms,命中 {range_cnt}")
print("理论命中数(200,000 × 299/1000)=", 200_000 * 299 / 1000)
print("结果一致:", eq_cnt == range_cnt)
```
关键观察:
• **等值编码**做范围查询,需要把区间内所有位序列 OR 起来(本例 299 条),查询时间与区间宽度成正比;等值查询则只要 1 条。
• **范围编码**做范围查询只要 1 条位序列,代价是构建更贵(需要按序累加),且更新一个值可能影响后面所有位序列。
• 范围编码要求属性值可排序。这也是为什么教学库里 `RangeEncodedBitmapIndex` 只允许整数属性。
这两种方案的系统比较见 Chan & Ioannidis 1998 的工作。
步骤 6:WAH 压缩 —— 稀疏散布 vs 聚簇
```python
import math
import numpy as np
WORD = 32 # 机器字长(位)
FILL_0, FILL_1, LITERAL = 0, 1, 2
def wah_encode(bits: np.ndarray, word: int = WORD) -> int:
"""返回 WAH 编码后占用的字(word)数。bits 为 0/1 数组。"""
n_words = math.ceil(len(bits) / word)
out, i = 0, 0
while i < n_words:
w = bits[i * word: (i + 1) * word]
if len(w) < word:
w = np.concatenate([w, np.zeros(word - len(w), dtype=np.uint8)])
if np.all(w == 0):
kind = FILL_0
elif np.all(w == 1):
kind = FILL_1
else:
kind = LITERAL
if kind == LITERAL:
out += 1 # 1 个头 + 1 个字面字(本实现各计 1)
i += 1
else:
run = 0
while i < n_words:
w = bits[i * word: (i + 1) * word]
if len(w) < word:
w = np.concatenate([w, np.zeros(word - len(w), dtype=np.uint8)])
if (kind == FILL_0 and np.all(w == 0)) or (kind == FILL_1 and np.all(w == 1)):
run += 1
i += 1
else:
break
out += 1 # 一个填充字表示 run 个同值字
return out
n = 100_000
rng = np.random.default_rng(21)
print(f"{'密度':>8s}{'未压缩字数':>12s}{'WAH 随机分布':>14s}{'占比':>9s}"
f"{'WAH 聚簇分布':>14s}{'占比':>9s}")
for d in (0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5):
rand_bits = (rng.random(n) < d).astype(np.uint8)
clust_bits = np.zeros(n, dtype=np.uint8)
n_set = int(n * d)
start = rng.integers(0, n - n_set)
clust_bits[start:start + n_set] = 1
unc = math.ceil(n / WORD)
r = wah_encode(rand_bits)
c = wah_encode(clust_bits)
print(f"{d * 100:>7.1f}%{unc:>12d}{r:>14d}{r / unc * 100:>8.1f}%"
f"{c:>14d}{c / unc * 100:>8.2f}%")
```
参考实测(N = 100,000 位,机器字 32 位,未压缩 = 100,000/32 = 3,125 字):
| 置位密度 | 未压缩字数 | 随机分布 WAH | 占比 | 聚簇分布 WAH | 占比 |
| --- | --- | --- | --- | --- | --- |
| 0.1% | 3,125 | 204 | 6.5% | 5 | 0.16% |
| 0.5% | 3,125 | 878 | 28.1% | 5 | 0.16% |
| 1% | 3,125 | 1,532 | 49.0% | 5 | 0.16% |
| 5% | 3,125 | 3,016 | 96.5% | 5 | 0.16% |
| 10% | 3,125 | 3,123 | 99.9% | 5 | 0.16% |
| 25% | 3,125 | 3,125 | 100.0% | 5 | 0.16% |
| 50% | 3,125 | 3,125 | 100.0% | 5 | 0.16% |
两条结论很明显:
1. **随机分布**下,密度超过约 5% 之后压缩基本失效,因为几乎每个字里都既有 0 又有 1,无法凑出长游程。
2. **聚簇分布**下压缩率与密度无关,恒定在 4~5 个字左右:一个连续块只需要"前导 0 游程 + 字面字 + 尾随 0 游程"几个字就能描述。真实数据(按某个键排序后再建的位图)往往聚簇,这正是位图索引在实践中可行的基础。

图 4:WAH 随密度与分布变化的实测结果。
步骤 7:Bloom 过滤器 —— 实测假阳性率
用 md5 做 double hashing(一个哈希值拆出两个搬运,再线性组合出 k 个位置),这是 Kirsch–Mitzenmacher 提出的常见做法:不必真的实现 k 个独立哈希函数。
```python
import math
import hashlib
class BloomFilter:
"""教学用 Bloom 过滤器:double hashing,位数组用 Python 大整数。"""
def __init__(self, m: int, k: int):
self.m = m
self.k = k
self.bits = 0
def _indices(self, key):
d = hashlib.md5(str(key).encode()).digest()
a = int.from_bytes(d[0:4], "big")
b = int.from_bytes(d[4:8], "big") | 1
return [(a + i * b) % self.m for i in range(self.k)]
def add(self, key):
for i in self._indices(key):
self.bits |= (1 << i)
def may_contain(self, key):
for i in self._indices(key):
if not (self.bits >> i) & 1:
return False
return True
def theory_fpr(m, n, k):
return (1.0 - math.exp(-k * n / m)) ** k
def empirical_fpr(m, k, n_insert, n_probe, seed=3):
bf = BloomFilter(m, k)
for x in range(n_insert):
bf.add(f"key-{seed}-{x}")
# 检查抽样的一部分已插入键,确认没有假阴性
for x in range(0, n_insert, max(1, n_insert // 200)):
assert bf.may_contain(f"key-{seed}-{x}"), "出现了假阴性,实现有误"
fp = 0
for x in range(n_probe):
if bf.may_contain(f"absent-{seed}-{x}"):
fp += 1
return fp / n_probe
n_insert, n_probe = 5_000, 5_000
print(f"{'m/n':>6s}{'k*':>5s}{'实测 FPR':>12s}{'理论 FPR':>12s}")
for bpe in (4, 6, 8, 10, 14, 18, 24):
m = int(bpe * n_insert)
k_opt = max(1, round((m / n_insert) * math.log(2)))
emp = empirical_fpr(m, k_opt, n_insert, n_probe)
th = theory_fpr(m, n_insert, k_opt)
print(f"{bpe:>6d}{k_opt:>5d}{emp:>12.4f}{th:>12.4f}")
```
参考实测(n = 5,000 插入,5,000 次"不存在键"探测,k 取理论最优的取整值):
| m/n(每元素位数) | k | 实测假阳性率 | 理论值 (1−e^{−kn/m})^k |
| --- | --- | --- | --- |
| 4 | 3 | 0.1476 | 0.1469 |
| 6 | 4 | 0.0566 | 0.0561 |
| 8 | 6 | 0.0224 | 0.0216 |
| 10 | 7 | 0.0098 | 0.0082 |
| 14 | 10 | 0.0016 | 0.0012 |
| 18 | 12 | 0.0000 | 0.0002 |
| 24 | 17 | 0.0000 | 0.0000 |
实测值略高于理论值,是正常的:理论公式假设哈希完全随机且忽略了 k 个位置之间的相关性,double hashing 的位置之间存在线性关系,加上 5,000 次探测本身有采样噪声(m/n=14 时理论期望只有 6 个假阳性,多一个就差 0.0002)。

图 5:每元素位数越多,假阳性率指数级下降。
步骤 8:k 不是越大越好
固定一个空间预算,扫描不同的 k,看看假阳性率怎么变。
```python
import math
下面沿用步骤 7 里定义的 BloomFilter / theory_fpr / empirical_fpr
(把步骤 7 的代码保存成 bloom_lab.py 时改为:from bloom_lab import ...)
n_insert, n_probe, bpe = 5_000, 5_000, 12
m = bpe * n_insert
k_star = (m / n_insert) * math.log(2)
print(f"固定 m/n = {bpe},理论最优 k* = {k_star:.2f}")
print(f"{'k':>4s}{'实测 FPR':>12s}{'理论 FPR':>12s}")
emp_all = []
for k in range(1, 15):
emp = empirical_fpr(m, k, n_insert, n_probe)
th = theory_fpr(m, n_insert, k)
emp_all.append(emp)
print(f"{k:>4d}{emp:>12.4f}{th:>12.4f}")
print("实测最优 k =", emp_all.index(min(emp_all)) + 1)
```
参考实测:理论最优 k* = 8.32;在 k ∈ [1,14] 的网格里,实测最小值落在 k = 10。
这看起来像是"公式失效了",其实是采样噪声。k = 8 与 k = 10 的理论假阳性率只相差约 0.0002,换算成 5,000 次探测只差 1 个样本的期望计数(15.7 vs 16.7),而这次实验的标准差约 4 个样本。差异完全被噪声淹没。加大探测次数到 50 万,实测最优点会重新靠近 8。

图 6:固定 m/n = 12 时扫描 k 的结果。曲线是"先降后升",最小值附近很平。
步骤 9(选做):调用教学库的实现
课程自带的实现把这些抽象包装成了类,行为与本实验的 A 类代码一致,多了"位序列抽象"、"范围/等值编码的公共基类"、"用布尔列表或大整数两种后端"这些工程化内容。
```python
import sys, pathlib
import time
for _p in (pathlib.Path.cwd(), *pathlib.Path.cwd().parents):
if (_p / "system").is_dir():
if str(_p) not in sys.path:
sys.path.insert(0, str(_p))
break
import itertools as it
from system.data_classes import Address
from system.bit_sequences import IntegerBitSequence, BitListBitSequence, WAHBitSequence
from system.indexes.bitmap_indexes import (
SortedEqualityEncodedBitmapIndex,
RangeEncodedBitmapIndex,
)
from system.indexes.bloom_filters import BloomFilter
cities = ["New York", "Cuppertino", "Berlin", "Singapore"]
streets = ["Unistreet", "Macstreet", "Longstreet", "Msstreet", "BigDataStreet"]
house_numbers = list(range(1, 201))
addresses = []
for idx, comb in enumerate(it.product(cities, streets, house_numbers)):
addresses.append(Address(idx, comb[0], comb[1], comb[2]))
t0 = time.perf_counter()
idx_city = SortedEqualityEncodedBitmapIndex(bit_sequence_type=IntegerBitSequence)
idx_city.bulkload(((a.city, a.id) for a in addresses))
idx_street = SortedEqualityEncodedBitmapIndex(bit_sequence_type=IntegerBitSequence)
idx_street.bulkload(((a.street, a.id) for a in addresses))
build = (time.perf_counter() - t0) * 1000
t0 = time.perf_counter()
result = idx_city.get_equal("New York") & (
idx_street.get_equal("Unistreet") | idx_street.get_equal("BigDataStreet")
)
q = (time.perf_counter() - t0) * 1000
print(f"构建 {build:.1f} ms,查询 {q:.4f} ms,命中 {len(result.as_set())} 行")
范围编码:范围查询只需一次访问
idx_house = RangeEncodedBitmapIndex(bit_sequence_type=IntegerBitSequence)
idx_house.bulkload(((a.house_number, a.id) for a in addresses))
print("house_number < 100 命中:", len(idx_house.get_smaller(100).as_set()))
Bloom 过滤器:注意 contains_key 返回真时要回原始数据复核
bf = BloomFilter(
data_objects=addresses[:400],
key_attribute_name="id",
number_of_available_bits=4000,
bit_sequence_type=BitListBitSequence,
)
print("BloomFilter id=0 可能存在:", bf.contains_key(0))
print("BloomFilter id=3999 可能存在:", bf.contains_key(3999))
```
注意 `BloomFilter` 的构造函数里,哈希函数个数是按 `⌈ln2 · m/n⌉` 自动算的(见 `system/indexes/bloom_filters.py` 的 `__init__`),不需要手动传 k。
六、观测记录表
**表 6-1:位图索引 vs 朴素扫描(建议 N = 500,000,city 5 值,street 10 值)**
| 项目 | 你的实测 | 参考实测 |
| --- | --- | --- |
| 构建索引耗时 (ms) | | 7,660 |
| 朴素逐行扫描耗时 (ms) | | 153.588 |
| 位图查询耗时 (ms) | | 1.5184 |
| 加速比 | | 约 101× |
| 命中行数 | | 19,845 |
| 期望命中行数 N×(1/5)×(2/10) | | 20,000 |
| 相对期望的偏差 | | −0.78% |
| 回本所需查询次数 | | 约 50 |
**表 6-2:位序列表示对比(建议 N = 200,000)**
| 表示方式 | 构建耗时 (ms) | 一次 AND (ms) | 粗估存储(每 N 行) |
| --- | --- | --- | --- |
| 布尔列表 | | | ≥ N 字节 |
| Python 大整数 | | | N/8 字节 |
**表 6-3:WAH 压缩(N = 100,000 位,字长 32 位)**
| 密度 | 未压缩字数 | 随机分布字数 | 占比 | 聚簇分布字数 | 占比 |
| --- | --- | --- | --- | --- | --- |
| 0.1% | 3,125 | | | | |
| 1% | 3,125 | | | | |
| 10% | 3,125 | | | | |
| 50% | 3,125 | | | | |
**表 6-4:Bloom 过滤器(n = 5,000,探测 5,000 次)**
| m/n | k | 实测 FPR | 理论 FPR |
| --- | --- | --- | --- |
| 4 | 3 | | 0.1469 |
| 6 | 4 | | 0.0561 |
| 8 | 6 | | 0.0216 |
| 10 | 7 | | 0.0082 |
| 14 | 10 | | 0.0012 |
七、思考题
1. 为什么 Bloom 过滤器不会有假阴性(false negative),只会有假阳性?如果把"删除"也支持进去,会出现什么问题?
2. 参考实测里 m/n = 14、k = 10 时理论值是 0.0012,实测是 0.0016。这个差异合理吗?请用统计涨落的角度算一算,5,000 次探测、p = 0.0012 时,期望的假阳性个数和标准差分别是多少。
3. 固定 m/n = 12 时理论最优 k* = 8.32,但实测最优点落在 k = 10。请判断这是公式错了吗,并给出论证。
4. 表 6-3 里随机分布在密度 0.1% 时压缩到 204 个字。请估算这个数字的来源:一个 32 位的字里至少含一个 1 的概率是多少?由此推算"字面字"的期望个数。
5. 为什么聚簇分布下的 WAH 字数几乎与密度无关(一直是 5 个字左右)?请说明一条连续块被 WAH 编码成哪几个字。
6. 范围编码 schemata 构造出的 `R_i` 之间满足什么单调关系?这个关系对"更新一行数据"意味着什么代价?
7. 本实验中的位图查询快是因为`bin(x).count("1")`(或 `int.bit_count()`)。如果查询要求"返回命中的所有行号"而不是"返回数量",代价会变成什么?请写出提取行号的代码并口述其复杂度。
8. 位图索引适合低基数(取值个数少)的列。如果给一列 100% 唯一的键(例如主键)建`equality位图`,空间会是多大?请用 N = 500,000 算一算,并说明为什么这种情况要用 B+ 树或哈希索引。
八、思考题答案与解析
**1. Bloom 没有假阴性的原因与删除问题**
插入键 x 时会把 h₁(x)…h_k(x) 全部置 1,之后再也不会被清 0。下次查询同一个键时,这些位置必然全是 1,所以 `may_contain(x)` 一定返回真。**没有哪个操作会把已置的位清掉,就不存在假阴性。**
加入删除就会破坏这个性质:把某个位置清 0 时,不知道还有多少个其它键的哈希落在这个位置上,清多了会让别的键产生假阴性。解决路线有两条:
• 计数 Bloom(counting Bloom filter):每个位置用一个小计数器代替 1 位,删除时减 1。代价是空间放大(每个计数器通常 4 位)。
• 不允许删除:定期重建整个过滤器(很多系统就是这么做的)。
**2. m/n = 14 的差异是噪声吗**
一次"不存在键"判定为假阳性是伯努利试验,概率 p = 0.0012,试验次数 n_probe = 5,000:
```
期望个数 E = n_probe · p = 5000 × 0.0012 = 6.0
标准差 σ = √(n_probe · p · (1−p)) = √(5000 × 0.0012 × 0.9988) = √5.993 = 2.45
实测假阳性率 0.0016 ⇒ 实测个数 = 5000 × 0.0016 = 8.0 个
偏离 (8 − 6) / 2.45 = 0.82 σ
```
0.82 个标准差是很常见的涨落。反过来说,p 的测量值本身的标准差是 σ/n_probe = 2.45/5000 = 0.00049,所以实测 0.0016 ± 0.0005 完全覆盖理论值 0.0012。差异合理,不需要修正公式。
要想把这条曲线测准,把 n_probe 提到 500,000(增加 100 倍),相对误差就降到原来的 1/10。
**3. k* = 8.32 而实测最优落在 10**
公式没错。原因是这个区域的函数太平坦,被噪声捂住了。用同一个公式算两个点(m/n = 12,即 kn/m = k/12):
```
k = 8: p = (1 − e^{−8/12})^8 = (1 − 0.51342)^8 = 0.48658^8 = 0.003142
k = 10: p = (1 − e^{−10/12})^10 = (1 − 0.43460)^10 = 0.56540^10 = 0.003339
```
两者相差 0.0002。换算成 5,000 次探测的期望个数:
```
k = 8 → 5000 × 0.003142 = 15.7 个
k = 10 → 5000 × 0.003339 = 16.7 个
```
而这一次实验的涨落 σ = √(5000 × 0.0031 × 0.9969) ≈ 3.9 个。差 1 个归类噪声。
正确的验证办法是增大 n_probe,或者直接看理论曲线——理论曲线的最小值确实在 8.32 附近。图 6 里理论曲线的谷底形态很平滑,实测曲线的锯齿完全是噪声,两者的最低点是一致的。
顺带注意一个实用点:k 必须是整数,8.32 附近只能用 8 或 9,两者的理论差只有 0.00002,工程上取哪个都行。
**4. 为什么 0.1% 密度下是 204 个字**
一个字的 32 位全为 0 的概率是 (1 − p)^32,p = 0.001:
```
P(字内含至少一个 1) = 1 − (1 − 0.001)^32 = 1 − 0.999^32
0.999^32 = e^{32·ln 0.999} = e^{−0.032016} = 0.96849
⇒ P = 1 − 0.96849 = 0.03151
```
总共 3,125 个字,期望的"字面字"个数:
```
3,125 × 0.03151 = 98.5 个
```
WAH 遇到字面字要额外存一个头字,两个字面字之间还要穿插 0-fill 字。粗略地数:`98.5 个字面字 + 98.5 个头/间隔字 + 首尾两个边界` ≈ 200 上下,与实测 204 吻合。
密度升到 1% 时同一个算式给出:
```
P(字内含至少一个 1) = 1 − 0.99^32 = 1 − 0.72490 = 0.27510
⇒ 字面字期望 = 3,125 × 0.27510 = 860 个
```
按"每个字面字配一个间隔字"估算会是 1,720,但实测只有 1,532。原因是密度上升后相邻的字面字变多,它们会连成一片,共享一个头字,不需要每个都配间隔。这个估算在稀疏时准确,密集时偏高——正好说明 WAH 的字数由"字面字的游程结构"决定,而不只是字面字的个数。
**5. 聚簇分布为什么恒定在 5 个字**
聚簇位序列长这样(一整段连续的 1,其余全是 0):
```
0 … 0 | 1 1 1 … 1 1 1 | 0 … 0
└ 前导全 0 字 ┘ └ 若干全 1 字 + 最多两个混杂字 ┘ └ 尾随全 0 字 ┘
```
编码结果是:
```
1 个 0-fill 字(覆盖前导的全部 0 字)
1~2 个字面字(块的头尾例外):若块恰好字对齐则没有字面字
1 个 1-fill 字(覆盖块内部的全 1 字)
1 个 0-fill 字(覆盖尾随的 0 字)
```
字数是常数,与块的长度无关——这就是游程编码的本质。实测拿到 5 个字,说明这批数据的块边界没有字对齐,头尾各多出一个字面字。
这一条对实践很重要:把表按某个键排序(cluster)之后建位图,压缩率会有数量级的改善。多维聚簇、Z 序曲线之所以有用,根源也在这里。
**6. 范围编码的单调性与更新代价**
范围编码的定义直接给出包含关系:
```
R_1 ⊆ R_2 ⊆ … ⊆ R_d (因为 A ≤ v_i 蕴含 A ≤ v_{i+1})
```
也就是说这 d 条位序列构成一个单调链。好处是任意区间查询都能用两三次运算凑出来:
```
A ≤ v_i → R_i
A = v_i → R_i & ~R_{i-1}
v_i < A ≤ v_j → R_j & ~R_i
```
坏处在更新:改一行 A[j] 从 v_a 变成 v_b(设 v_a < v_b),那么 R_a, R_{a+1}, …, R_{b−1} 这 (b−a) 条位序列的第 j 位都要翻转。更新代价与取值距离成正比。对比等值编码,同样这次更新只碰两条位序列。
这是范围编码的定位:适合只读为主的分析型负载(数据仓库、OLAP),不适合高频点更新(OLTP)。Chan & Ioannidis 1998 的工作系统地比较了这些编码在不同查询/更新混合下的表现。
**7. 返回行号要付多少**
统计数量只需要一次 popcount。要拿到行号就得把每一个置位的位置找出来:
```python
def set_positions(x: int):
"""返回整数 x 中所有为 1 的位下标(从低位向高位)。"""
pos = []
while x:
lsb = x & -x # 取出最低的一个置位
pos.append(lsb.bit_length() - 1)
x ^= lsb # 清掉这位
return pos
造一个小例子:16 行里第 2、5、9 行命中
demo = (1 << 2) | (1 << 5) | (1 << 9)
print("命中行的行号:", set_positions(demo))
若已经算出了 city_bm / street_bm,则:
mask = city_bm[0] & (street_bm[1] | street_bm[2])
rows = set_positions(mask)
```
复杂度是 O(h),h 为命中行数(每条输出一次循环),而不是 O(N)。注意别写成 `[(mask >> i) & 1 for i in range(n)]`——那样是 O(N),会把位图查询的优势全丢掉。
真实系统里还有一层:拿到行号后要去堆表回表取完整记录,那才是真正的大头。
**8. 给唯一值列建位图有多大**
一列有 V 个不同取值就 V 条位序列,每条 N 位。位图索引的总位数为 V · N。
V = N = 500,000 时:
```
总位数 = 500,000 × 500,000 = 2.5 × 10^11 bit
= 2.5 × 10^11 / 8 字节 = 3.125 × 10^10 字节
≈ 31.25 GB(按 10^9 换算)/ 29.1 GiB(按 2^30 换算)
```
而原始数据区区几十 MB。空间放大了几千倍。
所以低基数列(性别、城市、状态码)适合位图(V 小 ⇒ V·N 小,且 V 条位序列足够表达);高基数列(主键、身份证号、时间戳)要用 B+ 树或哈希索引,它们的空间是 O(N) 而不是 O(N·V)。经验阈值视系统而定,通常 V 到几千以上就要重新考虑。
九、扩展任务(选做)
1. **把 WAH 的 AND 也在压缩态上实现**:不要在解压后做 AND,试着直接遍历两个 WAH 字序列并合并同类型字。比较"解压 → AND → 再压"这条路径的耗时。
2. **加入"脏数据"实验**:把 `street` 的分布从均匀改成 Zipf(幂律),重做 6-1。观察命中行数是否还接近 20,000,讨论倾斜数据下位图选择率的计算为什么更难。
3. **实现 Counting Bloom**:把位数组换成 4 位计数器的数组(可用 `array` 模块),支持 `remove()`,测空间膨胀的倍数。
4. **测 wordsize 的影响**:把 `WAHBitSequence.WORD_LENGTH` 从 4 一路调到 256,记录每种配置下的存储位数与一次 AND 的耗时,画出"字长 vs 空间"、"字长 vs 时间"两条曲线,讨论为什么字长存在拐点。
5. **对比 Roaring**:安装 `pyroaring`(若网络允许)或自己实现一个简化版(按高 16 位分桶 + 容器内用 sorted array),与 WAH 在同一批数据上比较空间与 AND 耗时。
6. **做一次端到端的流水线**:把位图查询结果还原成行号,用这些行号去"回表"取出完整记录,再用 Bloom 过滤器挡住不可能命中的 id。统计整条链路各阶段耗时,画出占比图,看看时间到底花在哪一步。
十、常见错误与排查
| 现象 | 原因 | 处理 |
| --- | --- | --- |
| 位图查询与朴素扫描结果不一致 | 位对应关系错位:习惯性的第 i 行写成 `1 << i` 却在某个循环里用了 `1 << (i+1)` | 保持"行号 = 比特下标"的唯一约定;在两种实现里都用一个小型的对拍脚本验证 |
| `1 << i` 这一步跑得特别慢 | 用位循环逐位构建 N = 500,000 的大整数会反复重新分配大对象 | 这是本实验里最贵的部分(构建 7,660 ms 主要花在这);减少 N 或用 `numpy.packbits` + `int.from_bytes` 一次性构造 |
| `int.bit_count()` 报 `AttributeError` | Python 版本低于 3.8(3.10 起 `int.bit_count` 稳定可用) | 换成 `bin(x).count("1")`;或升级到 Python 3.10+ |
| `np.floor` 类型错误:`ufunc 'bitwise_and' not supported` | 把 numpy 的 bool 数组和 Python 大整数混在一起做 `&` | 二者选一:要么全程纯 Python 大整数(本实验推荐),要么全程 numpy + `np.packbits` |
| 加了 `assert` 却依然不生效 | numpy 标量的比较结果不是 Python 的 bool,而是 numpy 布尔类型 | 断言前先转换:`bool(cond)` 或用 `int(...)` 比较 |
| Bloom 实测出现了"假阴性"(已插入的键查不到) | 哈希函数实现里 k 个位置重复,或 `add` 与 `may_contain` 用了不同的哈希逻辑 | `_indices` 必须只定义一次,`add` 与 `may_contain` 都调用它;确认 b 为奇数,防止退化成同一个位置 |
| Bloom 实测假阳性率明显高于理论值(比如 2 倍以上) | m 与 k 不匹配:m/n = 10 却用了 k = 2,或 n 的计数算错(把成功了插入的重算为不同值) | 用 `k_opt = round((m/n) · ln2)` 核对;检查 `n_insert` 实际插入了多少个不同的键 |
| Bloom 的 hashed key 写法变了以后,实测数字对不上表格 | 键串生成器("key-seed-x")改变会使哈希分布改变 | 保持与本文一致的 `f"key-{seed}-{x}"` 写法;这属于实验配置,修改后数字当然会变 |
| WAH 压缩率总是 100% | 传入的位序数组密度太高(比如随机分布、密度 50%),或 word 遍历时 `np.all(w == 0)` 判断块错位 | 确认 `bits` 是 0/1 的 `uint8` 数组;用人工构造的 `[0]*100 + [1]*5 + [0]*100` 样例先验证 `wah_encode` 是否返回预期的小数字 |
| 调用 `system.indexes.bitmap_indexes` 时 `bulkload` 卡住 | 数据量设得太大:house_number 到 1000 会生成约 4000 行,范围编码要建 1000 条位序列 | 先用 `range(1, 201)` 的小规模跑通,再调大 |
| 使用 `system` 包时 `ModuleNotFoundError` | 未把仓库根加入 `sys.path` | 按 4.3 节/步骤 9 的代码片段先插入路径 |
| `RangeEncodedBitmapIndex` 传入字符串属性报类型错误 | 范围编码要求可排序,教学库限定整数属性 | 给 house_number 这类有序属性建范围编码;字符串属性用 `SortedEqualityEncodedBitmapIndex` |