开始前,会这些就够了
- 知道哈希能把输入映射到编号
- 理解数组下标与集合成员
- 会运行Python文件
- 需要补基础时:先分清哈希的用途
布隆过滤器把一条记录映射到几个格子,插入时把这些格子置为1。查询时,如果有一个格子仍为0,就可以排除;全部为1只能说可能存在,因为别的记录也会点亮同样的位置。
那删除时把对应格子清成0不就行了吗?关键在于一个格子没有写着它属于谁。本文用固定哈希位置构造两次删除失误:普通位图误删共享位,以及计数版误删从未插入过的记录。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- bloom_delete.py:完整可运行示例
- README.md:运行说明与独立练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和产物验证记录
- SHA256SUMS.txt:内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B bloom_delete.py解压到独立练习文件夹,在含 bloom_delete.py 的目录打开终端运行。16格、3次固定SHA-256映射的原创布隆删除教学实验;每键映射位置去重并对称加减。精确成员集合是演示真值与删除前提来源,不是免费内存;未测理论误判率、生产性能或并发一致性。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
用计数代替一个位,能记录还有多少贡献
计数版把每个格子改为整数。插入alpha与gamma后,位置6的计数是2。删除已确认插入的alpha时,只减去它的贡献,6从2变成1,因此gamma还保有全部所需的正计数。
本篇每个键做3次固定映射,若同一键的多个映射碰到同一格,就先去重;插入和删除都用同一组去重后位置。也可以选择按每次映射计数,但两边必须遵守一致规则。Python整数在这里不会像小位宽计数器那样溢出,生产实现的容量与溢出策略需要单独设计。
计数全为正,也不证明这条记录真的来过
程序继续搜索一个没有插入、但所有映射位置都已有正计数的名字。固定参数下找到ghost-130,它使用位置[0,6],因此查询会说可能存在。可是那些计数实际来自alpha和gamma。
若仅凭“可能存在”就执行删除,位置0会从1变成0,alpha随即被错误排除。过程中没有任何负计数,所以下溢检查也抓不到这个问题。remove_known_inserted这个名字强调调用前提:调用者必须有一个尚未抵消的真实插入事实;布隆查询自身不能提供这份事实。
运行完整程序,把两种错误与守卫对照起来
保存为bloom_delete.py,执行python -B bloom_delete.py。报告会列出三个名字的映射位置,显示清零后gamma查不到、按计数删除后gamma仍可查到,以及错误删除ghost-130后alpha丢失。
guarded_remove先检查独立的精确成员集合,因此会拒绝ghost-130且保持计数不变。它在本例中假设每个键只插入一次。程序还遍历六个固定键的全部子集,对每个已插入键执行删除并核对所有剩余成员、每一格的精确贡献,以及重复插入的成对计数,共872项核对。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Fixed SHA-256 positions; show why deletion needs counts AND membership facts."""
import hashlib
import itertools
import json
def positions(key, width=16, hashes=3):
if type(key) is not str or width < 1 or hashes < 1:
raise ValueError("invalid key or filter parameters")
return sorted({int.from_bytes(hashlib.sha256((str(i) + ":" + key).encode("utf-8")).digest()[:8],
"big") % width for i in range(hashes)})
class BitFilter:
def __init__(self):
self.bits = [0] * 16
def add(self, key):
for index in positions(key):
self.bits[index] = 1
def maybe(self, key):
return all(self.bits[index] for index in positions(key))
def wrong_clear(self, key):
for index in positions(key):
self.bits[index] = 0
class CountingFilter:
def __init__(self):
self.counts = [0] * 16
def add(self, key):
for index in positions(key):
self.counts[index] += 1
def maybe(self, key):
return all(self.counts[index] > 0 for index in positions(key))
def remove_known_inserted(self, key):
"""Caller must prove an unmatched insertion exists; positive counts do not prove it."""
indexes = positions(key)
if any(self.counts[index] <= 0 for index in indexes):
raise ValueError("counter underflow")
for index in indexes:
self.counts[index] -= 1
def guarded_remove(filter_, exact_members, key):
if key not in exact_members:
return False
filter_.remove_known_inserted(key)
exact_members.remove(key)
return True
def main():
first, survivor = "alpha", "gamma"
bits = BitFilter()
for key in (first, survivor):
bits.add(key)
assert bits.maybe(first) and bits.maybe(survivor)
before_bits = list(bits.bits)
bits.wrong_clear(first)
assert not bits.maybe(survivor)
counts = CountingFilter()
for key in (first, survivor):
counts.add(key)
counts.remove_known_inserted(first)
assert counts.maybe(survivor)
checks = 3
# A false positive whose deletion steals at least one unique contribution.
unsafe = CountingFilter()
members = {first, survivor}
for key in members:
unsafe.add(key)
ghost = next("ghost-" + str(i) for i in range(10000)
if unsafe.maybe("ghost-" + str(i))
and any(unsafe.counts[j] == 1 for j in positions("ghost-" + str(i))))
assert ghost not in members
saved = list(unsafe.counts)
assert not guarded_remove(unsafe, members, ghost) and unsafe.counts == saved
checks += 2
unsafe.remove_known_inserted(ghost) # Deliberate precondition violation.
lost = sorted(key for key in members if not unsafe.maybe(key))
assert lost
checks += 1
# All subsets of six fixed keys; delete each known member and recheck all survivors.
keys = ["key-" + str(i) for i in range(6)]
for size in range(7):
for subset in itertools.combinations(keys, size):
for deleted in subset:
filter_ = CountingFilter()
truth = set(subset)
for key in subset:
filter_.add(key)
assert guarded_remove(filter_, truth, deleted)
checks += 1
for key in truth:
assert filter_.maybe(key)
checks += 1
expected = [sum(index in positions(key) for key in truth) for index in range(16)]
assert filter_.counts == expected
checks += 1
duplicate = CountingFilter()
duplicate.add(first)
duplicate.add(first)
duplicate.remove_known_inserted(first)
assert duplicate.maybe(first)
duplicate.remove_known_inserted(first)
assert not duplicate.maybe(first)
checks += 2
print(json.dumps({"checks_passed": checks,
"positions": {first: positions(first), survivor: positions(survivor), ghost: positions(ghost)},
"shared_positions": sorted(set(positions(first)) & set(positions(survivor))),
"bit_vector_before_wrong_delete": before_bits,
"survivor_after_wrong_clear": bool(bits.maybe(survivor)),
"survivor_after_counted_delete": bool(counts.maybe(survivor)),
"never_inserted_false_positive": ghost, "lost_after_unsafe_ghost_delete": lost,
"guard_rejected_ghost": True,
"boundary": "tiny deterministic example; exact truth set is test/guard data, not free memory"}, indent=2))
if __name__ == "__main__":
main()
怎样运行:在独立练习文件夹中执行 python -B bloom_delete.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。16格、3次固定SHA-256映射的原创布隆删除教学实验;每键映射位置去重并对称加减。精确成员集合是演示真值与删除前提来源,不是免费内存;未测理论误判率、生产性能或并发一致性。
运行后对照结果
{
"checks_passed": 872,
"positions": {
"alpha": [
0,
6,
12
],
"gamma": [
3,
6,
9
],
"ghost-130": [
0,
6
]
},
"shared_positions": [
6
],
"bit_vector_before_wrong_delete": [
1,
0,
0,
1,
0,
0,
1,
0,
0,
1,
0,
0,
1,
0,
0,
0
],
"survivor_after_wrong_clear": false,
"survivor_after_counted_delete": true,
"never_inserted_false_positive": "ghost-130",
"lost_after_unsafe_ghost_delete": [
"alpha"
],
"guard_rejected_ghost": true,
"boundary": "tiny deterministic example; exact truth set is test/guard data, not free memory"
}额外的精确名单是有成本的,不能悄悄当成免费能力
演示里使用set保存真实成员,是为了让失败与正确行为能够独立核对,也让删除入口得到可靠依据。这个set本身会占用存储;不能一边保留全部键,一边把整个方案宣称为只用了16个格子。真实系统可能从数据库删除结果或已确认的事件记录得到事实,但需要明确同步关系。
若同一个键允许插入多次,就必须定义删除一次究竟抵消一次插入,还是删除该键全部出现。普通set无法表达次数;本文单独的重复插入核对只证明两次增加后应对称减少,不把只存唯一键的guarded_remove冒充多重集合实现。
固定哈希让反例可重现,但没有证明理论误判率
为了跨进程复查位置,代码用明确的UTF-8输入和SHA-256,没用可能随进程改变种子的Python内置hash。固定同一算法和参数后,反例中的位置保持一致;改变格子数或映射规则后,要重新构建已有数据的表示。
这张16格小图没有证明哈希独立性,也没有测量真实工作负载的误判率。Python列表更不是紧凑位图内存布局,程序也未处理并发加减、持久化或计数饱和。本篇只建立删除必须保护共享贡献、并且依赖真实插入依据的两条规则。
记住这三点
- 一个置为1的格子可能由多条记录共同使用,直接清零会制造假阴性。
- 计数版能保留其他项的贡献,但只能扣除真实存在且未被抵消的插入。
- 可能存在不等于确实插入,正计数与下溢检查都不能替代成员事实。
别把例子用过头
- 把普通位图的插入过程逐位反过来,当作安全删除。
- 因为每个计数都大于0,就允许删除一个仅被近似查询命中的键。
- 保存完整真值集合却不计算其存储成本,或把小图验证当作生产误判率和性能证明。
自己试一小步
如果同一个文件名可以被登记三次,你会怎样规定并验证删除一次?
- 先写明对象是集合还是多重集合,再设计精确插入次数的来源。
- 对同一键执行多次登记、逐次取消以及额外一次取消,预测每一步是否允许修改计数。
- 再加入一个共享位置的不同键,确认它的有效存在性没有被连带破坏。
做到这些,就算完成
- 每次扣减都有尚未抵消的插入依据。
- 重复取消与从未插入的键不能修改已有计数。
- 说明额外真值记录的存储与一致性成本;本节不提供实现答案。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Broder、Mitzenmacher:Network Applications of Bloom Filters (新标签页)↗
普通布隆不能反向清位删除,以及计数布隆用增加/减少贡献支持删除,并需要处理计数溢出。
- NIST算法词典:Bloom Filter (新标签页)↗
多个哈希函数映射到同一个位数组,以近似方式检测集合成员的基本结构。
- Python3.14:hashlib.sha256 (新标签页)↗
用确定的字节输入计算SHA-256摘要,作为可复核位置映射的实现依据。