bitmap - bloom - skiplist

11. 位图、布隆过滤器与跳表

0. 本章先解决什么问题

前面讲的结构大多是“精确保存元素”。但高性能系统里经常有更强的约束:

  • 数据量巨大。
  • 内存有限。
  • 只需要判断是否存在。
  • 可以接受少量误判。
  • 需要有序但希望实现比平衡树简单。

本章讲三个实用结构:

  • 位图:用 bit 表示集合。
  • 布隆过滤器:用概率换空间。
  • 跳表:用多级索引实现有序查找。

它们体现的是数据结构选型中的空间、概率和层级 trade-off。

Bloom Filter 与 Skip List 结构

这张图怎么读

这张图把本章两个关键 trade-off 放在一起:Bloom Filter 用多个 hash 和 bit 数组做“可能存在/一定不存在”的预过滤,代价是假阳性;Skip List 用随机层级给有序链表加快速通道,代价是概率化的层级维护。

1. 位图 Bitmap

位图用一个 bit 表示一个状态。

如果要表示整数集合 {0, 2, 5}:

代码块PLAINTEXT · 2 行收起展开
index: 0 1 2 3 4 5
bit:   1 0 1 0 0 1

判断 x 是否存在:

看 bit[x] 是否为 1

插入 x:

bit[x] = 1

删除 x:

bit[x] = 0

2. 位图适合什么

位图适合:

条件说明
key 是整数或能映射到整数bit 下标需要可计算
范围不是极大范围太大位图也大
只需要存在/不存在一个 bit 表示布尔状态
需要节省空间8 个状态只占 1 byte

应用:

  • 用户签到。
  • 状态标记。
  • 去重。
  • 权限位。
  • 大范围布尔集合。

位图不适合 key 范围极大但实际元素很少的情况,因为会浪费大量空位。

3. 布隆过滤器

布隆过滤器解决:

如何用很少空间判断一个元素是否可能存在?

它使用:

  • 一个 bit 数组。
  • 多个哈希函数。

插入 key:

hash1(key) -> 位置 a,置 1
hash2(key) -> 位置 b,置 1
hash3(key) -> 位置 c,置 1

查询 key:

如果这些位置都为 1:
key 可能存在
如果任一位置为 0:
key 一定不存在

4. 布隆过滤器的误判

布隆过滤器有两条核心性质:

可能误判存在
不会误判不存在

也就是:

查询结果含义
不存在一定不存在
可能存在可能真存在,也可能是假阳性

为什么会误判?

不同 key 可能把相同 bit 位置置 1。后来查询某个未插入 key 时,它对应的位置可能刚好都被其他 key 置 1。

5. 布隆过滤器适合什么

适合:

  • 先过滤明显不存在的数据。
  • 减少昂贵查询。
  • 大规模去重的预判断。
  • 缓存穿透防护。
  • 黑名单/白名单预过滤。

不适合:

  • 不能接受误判的精确场景。
  • 需要删除元素的普通场景。
  • 需要列出所有元素。

如果需要删除,可以用计数布隆过滤器,但空间和复杂度会上升。

6. 布隆过滤器参数

误判率受这些因素影响:

因素影响
bit 数组大小越大误判越低
插入元素数量越多误判越高
哈希函数数量太少或太多都不好

布隆过滤器不是随便开个 bit 数组就结束。要根据预期元素量和可接受误判率设计参数。

机制深挖:Bloom 什么时候会失效

Bloom Filter 的误判率不是固定属性,它会随着插入元素数量变化。直觉上,bit 数组里 1 越多,未插入 key 查询时“所有 hash 位置都刚好为 1”的概率越高。

状态bit 数组特征查询表现
插入很少大量 bit 仍为 0很多不存在能被快速拒绝
接近设计容量0/1 分布较合理误判率接近预期
远超设计容量大量 bit 变成 1“可能存在”越来越多
几乎全 1过滤能力消失查询基本都要落到真实存储

所以 Bloom 的排障指标不只是“有没有这个结构”,还要看:

  • 设计容量是多少。
  • 实际插入了多少。
  • bit 占用率是否过高。
  • 误判率是否超过业务能接受的范围。
  • 是否需要重建或分层 Bloom。

一个常见工程错误是:上线初期 Bloom 很有效,数据增长后误判率上升,但系统没有容量告警。最后 Bloom 还在消耗 CPU,却已经挡不住昂贵查询。

7. 跳表 Skip List

跳表是有序链表加多级索引。

底层是完整有序链表:

1 -> 3 -> 5 -> 7 -> 9 -> 11

上层抽取部分节点作为快速通道:

Skip List 两层结构与查找路径

查找时从高层开始:

能向右就向右
不能向右就下沉一层

平均查找成本接近 O(log n)。

8. 跳表和树的对比

对比跳表平衡树
有序查找支持支持
范围遍历支持支持
平衡方式随机层级旋转/颜色/高度
实现复杂度相对直观较复杂
最坏情况依赖随机,理论可能退化严格控制更强

跳表适合希望实现相对简单、支持有序范围的场景。

9. 跳表插入

插入时:

  1. 查找插入位置。
  2. 随机决定新节点层数。
  3. 在每一层把它接入链表。

随机层数让高层节点数量逐层减少。

直觉:

底层所有节点
上一层约一半节点
再上一层约四分之一节点

这形成类似二分的跳跃能力。

跳表为什么要靠随机层级

跳表的目标是让高层节点逐渐变少。理想情况下:

  • 第 0 层:所有节点
  • 第 1 层:大约 1/2 节点
  • 第 2 层:大约 1/4 节点
  • 第 3 层:大约 1/8 节点

这样查找时可以先在高层快速跨过大段范围,再逐层下沉到精确位置。随机层级的意义是避免每次插入都做复杂旋转或全局调整,用概率分布维持“高层稀疏、低层完整”的结构。

但随机并不等于随便:

设计点影响
最大层数限制索引高度和内存开销
晋升概率影响高层稀疏程度
随机质量极端分布可能让结构退化
节点指针数量空间开销高于普通链表

读跳表时要抓住一句话:它用随机层级换掉复杂旋转,用额外指针换来有序查找和范围遍历。

10. 联系实际:如何避免昂贵查询

假设有一个昂贵存储,查询一次很慢。

你可以在前面加布隆过滤器:

请求 key
-> 布隆过滤器判断
-> 一定不存在: 直接拒绝
-> 可能存在: 再查真实存储

这样可以挡掉大量明显不存在的请求。

但必须接受:

布隆过滤器说可能存在时,仍要查真实存储确认。

它是预过滤,不是最终真相。

设计卡:Bloom 负责挡掉不存在,真实存储负责确认

Bloom Filter 的正确使用方式是:

Bloom 预过滤决策路径

常见错误:

错误后果
把“可能存在”当成“存在”假阳性会变成错误结果
插入数量远超设计容量bit 过多变 1,误判率上升
hash 函数太少或太多误判率不理想,计算成本浪费
想普通删除元素删除一个 key 可能影响其他 key

Skip List 的选型也要看语义:

需求跳表是否合适
精确 key 查询且不需要有序哈希表通常更简单
需要范围扫描、排名、前后继跳表或平衡树更合适
希望实现比旋转树更直观跳表有优势
需要严格最坏情况保证平衡树更稳

这两个结构的共同点是:它们都不是为了“高级”,而是利用约束换工程收益。

手推:Bloom 的误判率为什么会随填满程度上升

Bloom Filter 的核心动作是:

插入一个 key -> 用 k 个 hash 函数打到 k 个 bit -> 把它们置 1
查询一个 key -> 这些 bit 都是 1,则可能存在;有任意 0,则一定不存在

当插入很少时,bit 数组里 0 很多。一个不存在的 key 查询时,只要有一个位置是 0,就能被挡掉。

当插入越来越多:

越来越多 bit 被置 1
不存在的 key 也更容易碰到全是 1 的位置

极端情况下,如果 bit 数组几乎全是 1:

任何 key 查询都会“可能存在”

这时 Bloom 仍然不会误判“不存在”,但它已经失去过滤价值。它不会给错否定,但会给太多没用的肯定。

所以 Bloom 参数不是装饰:

参数影响
bit 数组大小空间越大,越不容易过快填满
hash 个数太少区分度不够,太多会更快把 bit 置 1
预计插入量超过设计容量后误判率会升高
key 分布hash 质量差会让某些 bit 过热

判断 Bloom 是否还有效,不只看有没有这个结构,还要看:

实际插入量是否超过设计容量?
bit 填充率是否过高?
误判导致的回源比例是否还能接受?

边界条件:普通 Bloom 不能直接删除

普通 Bloom 只知道某个 bit 是否为 1,不知道这个 1 是哪些 key 贡献的。

假设:

key A -> bit 3, bit 8
key B -> bit 3, bit 9

如果删除 A 时直接把 bit 3 和 bit 8 清 0:

bit 3 被清掉
key B 查询时需要 bit 3 和 bit 9 都为 1
=> B 可能被误判为不存在

这破坏了 Bloom 最重要的承诺:

不存在假阴性。

所以普通 Bloom 适合:

代码块JAVA · 2 行收起展开
只增或周期性整体重建;
允许误判存在;

不需要精确删除。

如果确实需要删除,就要换模型,例如计数 Bloom 或其他可删除结构,但代价是更多空间和更新复杂度。

反例:跳表随机层级不是越高越好

新节点层级越高,查找时可跳过的范围可能越大,但它也会增加指针数量和维护成本。

如果大量节点都被提升到高层:

高层不再稀疏
每层要扫描的节点变多
空间开销增加
插入/删除要更新更多指针

如果几乎没有节点被提升:

高层太少
结构退化接近普通链表
查找要在底层走很久

跳表依赖的是概率分布形成的层级梯度:

代码块JAVA · 3 行收起展开
底层完整;
越往上越稀疏;
高层负责快速跨越;

低层负责精确定位。

因此跳表的工程判断不只是“会 O(log n)”,还要问:

最大层数是否合理?
晋升概率是否匹配数据规模?
随机源是否稳定?
范围查询是否真的需要有序结构?

否则它可能既占空间,又没有得到预期的跳跃效率。

11. 学完本章你能解决什么问题

学完这一章,你应该能解决或开始分析这些问题:

  1. 位图如何用 bit 表示集合?
  2. 位图适合什么范围和 key 类型?
  3. 布隆过滤器为什么能节省空间?
  4. 布隆过滤器为什么会误判存在,但不会误判不存在?
  5. 布隆过滤器适合放在哪些昂贵查询之前?
  6. 跳表如何用多级索引加速有序链表?
  7. 跳表和平衡树如何取舍?
  8. 如何在空间、误判率、实现复杂度之间做 trade-off?

这些结构的重点不是“更高级”,而是它们敢于利用问题约束:整数范围、允许误判、随机层级,从而换取更好的工程成本。

延伸阅读