深入理解 roaring bitmap

位图bitmap

假设要统计一个网站每天的UV,也就是一天内访问网站的不同用户数量,用户ID是整数

常见做法是用HashSet储访问过的用户ID

假设一天有1亿独立用户,每个用户ID是 4 字节的整数(32位),HashSet存储1亿个ID至少需占用约381MB内存(1亿 X 4字节/1024/1024 = 381MB),实际上由于数组预分配空间,hash链表的指针等因素,真实空间占用达到1GB以上

我在本地测试:

go 复制代码
func TestMesUsed(t *testing.T) {
    var m map[int32]struct{}
    m = make(map[int32]struct{}, 100_000_000)  // 预分配 1 亿容量

    for i := 0; i < 100_000_000; i++ {
       m[int32(i)] = struct{}{}
    }

    var mem runtime.MemStats
    runtime.ReadMemStats(&mem)
    fmt.Printf("Allocated memory: %.2f MB\n", float64(mem.Alloc)/1024/1024)
}

结果为1189MB:

go 复制代码
Allocated memory: 1189.37 MB
--- PASS: TestMesUsed (15.92s)

我们看看换成位图来做,需要多少空间

位图的核心思路是用1个二进制位(bit)标识1个用户是否访问过。相比hashset用一个32位的int来标识用户是否访问过,位图节约了32倍的内存,从而极大压缩存储空间

假设网站用户ID最大不超过1亿,则需要1亿个二进制位,也就是12M内存空间(1亿bit = 1亿/8/1024/1024 ≈ 11.9MB)远小于 381MB

如果用hashset的按实际开销1189MB来算,其内存占用只有hashset的1%

位图的更新和查询操作,其时间复杂度和hashset一样都是O(1),但常数时间复杂度非常低,只有一次除法操作+一次取余操作(取余还可以用位运算优化)。相比hashset,少了hash值计算,冲突检测等步骤,效率更高

总结下,位图通过 "用单个二进制位表示状态" 的设计,完美解决了传统集合在 "大规模数据存在性判断" 场景下的内存占用过高的问题,同时进一步提示查询和更新效率

roaring bitmap

bitmap虽然在空间和时间效率上有显著优势,但存在一个核心缺陷:

数据分布稀疏 ,且数据范围比较大 时,空间利用率会急剧下降,甚至可能比传统数据结构更浪费内存

举个例子,假设上文中网站用户ID的范围是1~10亿,则位图需要预先准备10亿位的空间,也就是957MB(10亿字节 / 1024/1024 = 957MB)

假设只存储1个用户,则该位图只有1位是1,其他位全为 0,但仍然占用了 957MB 内存,这意味着大部分的空间都被浪费了

RoaringBitmap是针对稀疏数据场景设计的优化版位图,其核心思路是分桶:将 32 位无符号整数按照高 16 位分桶,即最多可能有 2^16=65536 个桶

  1. 存储数据时,先按照数据的高 16 位找到 Container(找不到就会新建一个)
  2. 再将低 16 位放入 Container中

每个桶根据数据密度选择最适合的存储方式(稠密用位图,稀疏用有序数组),兼顾空间效率和操作性能

本文基于开源库github.com/RoaringBitm... 进行源码走读

落实到代码层面为:

go 复制代码
type roaringArray struct {
    keys            []uint16
    containers      []container 
}
  • keys和containers一一对应
  • keys:存储高16位的值, 唯一标识每个桶
  • containers:存储每个桶的数据,有3种实现:arrayContainer,bitmapContainer,runContainer
  • keys和containers都是惰性初始化,一开始其长度都是0。第一次某个高16位的数据出现时,才将其添加到keys,并初始化对应的容器

接下来介绍3种container

arrayContainer

一开始,每个container都是arrayContainer,其内部数据结构是一个有序的uint16类型数组,最大容量为 4096

go 复制代码
type arrayContainer struct {
    content []uint16
}

由于数组是有序的,存储和查询时都可以通过二分查找快速定位其在数组中的位置

适合稀疏分布的数据

例如:对于整数集合 {100, 200, 300},它们的高16位相同(都是 0),则低16位 [100, 200, 300] 被存入一个 []uint16

往arrayContainer添加一个元素,落实到代码层面为:

  1. 通过二分查找,从有序数组content中找到应该插入的位置
  2. 插入数据
go 复制代码
func (ac *arrayContainer) iaddReturnMinimized(x uint16) container {
    l := len(ac.content)
    /** 性能优化:当满足以下所有条件时直接在数组末尾追加元素
    1.数组长度小于最大容量限制 arrayDefaultMaxSize(即4096)
    2.要添加的元素 x 大于数组中最大的元素(ac.content[l-1] < x)
    */
   if l > 0 && l < arrayDefaultMaxSize && ac.content[l-1] < x {
       ac.content = append(ac.content, x)
       return ac
    }

     // 使用二分查找在已排序的数组中查找元素 x 的位置
     loc := binarySearch(ac.content, x)
     // 元素不存在
     if loc < 0 {
        // 如果当前数组长度已达到4096, 将数组容器转换为位图容器,以节省内存
        if len(ac.content) >= arrayDefaultMaxSize {
          // 下文详细分析
          a := ac.toBitmapContainer()
          a.iadd(x)
          return a
       }
       // 如果数组长度未达到限制,则在正确位置插入元素
       s := ac.content
       i := -loc - 1
       s = append(s, 0)
       copy(s[i+1:], s[i:])
       s[i] = x
       ac.content = s
    }
    return ac
}

判断一个数是否存在于arrayContainer中,就是在arrayContainer.content中进行二分查找,看能否找到

go 复制代码
func (ac *arrayContainer) contains(x uint16) bool {
    return binarySearch(ac.content, x) >= 0
}

bitmapContainer

arrayContainer存储的元素个数大于4096时,就需要升级成bitmapContainer

为啥到4096就需要升级?从内存占用的角度来说,这是一个分水岭

  • 当元素数量等于4096时,arrayContainer占用4096X2字节 = 8KB,bitmapContainer也是8KB,两者占用空间基本相同
  • 当元素数量小于4096时,arrayContainer占用空间更少
  • 当元素数量大于4096时,bitmapContainer占用空间更少

bitmapContainer底层是一个位图:

go 复制代码
type bitmapContainer struct {
    cardinality int
    bitmap      []uint64
}

每个容器需要 65536 个比特来存储数据,每个 uint64有64 位,因此需要65536/64=1024 个uint64提供比特位

因此其初始化时,创建固定1024大小的slice

go 复制代码
func newBitmapContainer() *bitmapContainer {
    p := new(bitmapContainer)
    // size=1024
    size := (1 << 16) / 64
    p.bitmap = make([]uint64, size, size)
    return p
}

Bitmap Container 不像 Array Container 那样需要二分查找定位位置,而是可以直接通过下标直接寻址

例如:对于整数 456789(其二进制表示为 00000000 00000110 11110010 01100101,前16位是 00000000 00000110,后16位是 11110010 01100101),要将其加入Bitmap Container的步骤如下:

  1. 先根据456789的高16位0110,定位到第6个桶

  2. 再根据低16位11110010 01100101(对应十进制62053),将bitmap容器中第62,053位置为1

往bitmap容器添加一个数,落实到代码层面为:

go 复制代码
func (bc *bitmapContainer) iadd(i uint16) bool {
     x := int(i)
     // 位图容器使用一个 uint64 数组来存储位信息,通过 x/64 计算出元素 x 对应的数组索引
     previous := bc.bitmap[x/64]
     // 通过 uint(x) % 64 计算出元素 x 在64位中的位偏移量,然后将数字1左移这个偏移量,创建一个掩码        mask。这个掩码中只有对应位为1,其他位都为0
     mask := uint64(1) << (uint(x) % 64)
     // 使用按位或操作将掩码应用到之前的值上,得到新的值 newb。这样就将对应位设置为1(如果原来已经是1,则保持不变) 
     newb := previous | mask
     bc.bitmap[x/64] = newb
     // 更新基数
     // previous ^ newb:只把有变化的那位保留下来
     bc.cardinality += int((previous ^ newb) >> (uint(x) % 64))
     // 返回是否新添加
     return newb != previous
}

判断一个数是否存在于bitmapContainer中,就是看对应bit位是否是1

go 复制代码
func (bc *bitmapContainer) contains(i uint16) bool {  // testbit
     x := uint(i)
     // 通过右移6位(等同于除以64)计算元素 x 在位图数组中的索引
     w := bc.bitmap[x>>6]
     mask := uint64(1) << (x & 63)
     return (w & mask) != 0
}

再来看看从arrayContainer升级成bitmapContainer的代码:

go 复制代码
func (ac *arrayContainer) iaddReturnMinimized(x uint16) container {
     // ...
     // 使用二分查找在已排序的数组中查找元素 x 的位置
     loc := binarySearch(ac.content, x)
     // 元素不存在
     if loc < 0 {
        // 如果当前数组长度已达到4096, 将数组容器转换为位图容器,以节省内存
        if len(ac.content) >= arrayDefaultMaxSize {
          a := ac.toBitmapContainer()
          a.iadd(x)
          return a
       }
        // 如果数组长度未达到限制,则在正确位置插入元素
        // ...
    }
    return ac
}
go 复制代码
func (ac *arrayContainer) toBitmapContainer() *bitmapContainer {
    bc := newBitmapContainer()
    bc.loadData(ac)
    return bc
}
go 复制代码
func (bc *bitmapContainer) loadData(arrayContainer *arrayContainer) {
     // 获取基数,底层是len(ac.content) 
    bc.cardinality = arrayContainer.getCardinality()
    c := arrayContainer.getCardinality()
    for k := 0; k < c; k++ {
        // 拿到数组容器中每个元素
        x := arrayContainer.content[k]
        // 计算在位图的索引
        i := int(x) / 64
        // 设置对应位
        bc.bitmap[i] |= (uint64(1) << uint(x%64))
    }
}

runContainer

runContainer 中的 Run 指的是行程长度压缩算法(Run Length Encoding),对连续数据有比较好的压缩效果。它的原理是:对于连续出现的数字,只记录初始数字和后续数量

go 复制代码
type runContainer16 struct {
    // 存储所有连续的部分,按start有序存储
    iv []interval16
}

type interval16 struct {
    // 从哪开始
    start  uint16
    // 连续的数量
    length uint16
}

例如:[100, 101, 102, ..., 200] 存储为 (start=100, length=101),仅需 4 字节(两个 uint16) 对于一个完整的 0~65535 区间, bitmap container 需要固定的8KB。而runContainer只需 仅需 4 字节(两个 uint16)

当bitmapContainer都装满时,会转换为run container:

go 复制代码
func (bc *bitmapContainer) iaddReturnMinimized(i uint16) container {
     bc.iadd(i)
     // 如果满了,将bitmapContainer转换成RunContainer
     if bc.isFull() {
       return newRunContainer16Range(0, MaxUint16)
    }
    return bc
}

func newRunContainer16Range(rangestart uint16, rangelast uint16) *runContainer16 {
    rc := &runContainer16{}
    // 用一个interval16代表0~65535都满的情况
    rc.iv = append(rc.iv, newInterval16Range(rangestart, rangelast))
    return rc
}

func newInterval16Range(start, last uint16) interval16 {
    return interval16{
       start,
       last - start,
    }
}

判断一个数是否存在于runContainer中,就是二分查找runContainer16.iv,看参数key落在哪个区间内

go 复制代码
func (rc *runContainer16) contains(key uint16) bool {
    _, in, _ := rc.search(int(key))
    return in
}

func (rc *runContainer16) search(key int) (whichInterval16 int, alreadyPresent bool, numCompares int) {
    return rc.searchRange(key, 0, 0)
}

func (rc *runContainer16) searchRange(key int, startIndex int, endxIndex int) (whichInterval16 int, alreadyPresent bool, numCompares int) {
    endxIndex = n

     // 使用二分查找算法找到第一个起始值大于key的区间索引
    i, j := startIndex, endxIndex
    for i < j {
       h := i + (j-i)/2 
       numCompares++
       if !(key < int(rc.iv[h].start)) {
          i = h + 1
       } else {
          j = h
       }
    }
    below := i

    // ...

    // 检查key是否在特定区间内
    if key >= int(rc.iv[below-1].start) && key < int(rc.iv[below-1].last())+1 {

    alreadyPresent = true
       return
    }

    
    return
}

runContainer的压缩效果和数据的连续性关系极为密切

考虑极端情况,如果所有数据都是连续的,那么最终只需要 4 字节。如果所有数据都不连续(比如全是奇数或全是偶数),那么不仅不会压缩,还会膨胀成原始数据的两倍大

对比

在增删改查的时间复杂度方面,bitmapContainer 只涉及到位运算,显然为O(1)。而arrayContainer 和run Container都需要用二分查找在有序数组中定位元素,故为 O(logN)

最后对这3种container进行对比

假设我们有以下整数集合:

go 复制代码
{1, 2, 3, 4, 5, 100, 200, 300}
  • ✅Array Container:存储为 [1,3,5,100,200,300],占用 16 字节
  • Bitmap Container:需要 固定8KB,浪费严重
  • Run Container:可编码为 (1,1)(3,1)(5,1)(100,1), (200,1), (300,1)

但如果集合是:

go 复制代码
{1000, 1001, 1002, ..., 2000}
  • Array Container:需 2×1001 = 2002 字节
  • Bitmap Container:固定 8KB
  • ✅Run Container:仅需 4 字节(1000, 1001),是最优选择
特性 Array Container Bitmap Container Run Container
适用数据 稀疏、少量元素 密集、大量元素 连续/区间数据
内存占用 ~2n 字节 固定 8KB ~4r 字节(r为区间数)
插入/删除 O(n) O(1) O(r)
查找 O(log n) O(1) O(log r)
自动切换 初始化时 >4096 → bitmap bitmap满时

总结

传统位图的缺陷是空间占用与数据范围强绑定,稀疏数据下浪费严重 。RoaringBitmap 通过 "分桶 + 动态存储策略",在保证操作效率的同时,极大优化了稀疏场景的空间利用率

相关推荐
XiangCoder7 分钟前
🔥Java核心难点:对象引用为什么让90%的初学者栽跟头?
后端
二闹18 分钟前
LambdaQueryWrapper VS QueryWrapper:安全之选与灵活之刃
后端
得物技术18 分钟前
Rust 性能提升“最后一公里”:详解 Profiling 瓶颈定位与优化|得物技术
后端·rust
XiangCoder23 分钟前
Java编程案例:从数字翻转到成绩统计的实用技巧
后端
aiopencode24 分钟前
iOS 文件管理全流程实战,从开发调试到数据迁移
后端
Lemon程序馆1 小时前
Kafka | 集群部署和项目接入
后端·kafka
集成显卡1 小时前
Rust 实战五 | 配置 Tauri 应用图标及解决 exe 被识别为威胁的问题
后端·rust
阑梦清川1 小时前
派聪明知识库项目---关于IK分词插件的解决方案
后端
jack_yin1 小时前
飞书机器人实战:用MuseBot解锁AI聊天与多媒体能力
后端
阑梦清川1 小时前
派聪明知识库项目--关于elasticsearch重置密码的解决方案
后端