位图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 个桶
- 存储数据时,先按照数据的高
16
位找到 Container(找不到就会新建一个) - 再将低
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添加一个元素,落实到代码层面为:
- 通过二分查找,从有序数组content中找到应该插入的位置
- 插入数据
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的步骤如下:

-
先根据
456789
的高16位0110,定位到第6个桶 -
再根据低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 通过 "分桶 + 动态存储策略",在保证操作效率的同时,极大优化了稀疏场景的空间利用率