位图 BitMap:用一个 bit 管一个状态,空间直接省到位

引出图位

平时我们写代码,经常会遇到这种问题:

有一批整数,我不关心它们出现了几次,也不关心它们原来的顺序,只想知道某个数字到底有没有出现过。

比如:

给你一堆整数,范围在 0 ~ 1 亿,要求快速判断某个数字是否存在。

很多人第一反应可能是用数组:

cpp 复制代码
bool arr[100000000];

如果 arr[x] == true,就说明 x 出现过。

这个思路当然没问题,而且很直观。但问题在于,bool 通常也是按字节存的,一个 bool 大多占 1 个字节,也就是 8 个 bit。

可我们真正需要的只是两个状态:

txt 复制代码
出现    -> 1
没出现  -> 0

换句话说,一个数字其实只需要一个 bit 就够了。

bool 数组存 1 亿个状态,大约需要 100MB;

用位图存 1 亿个状态,大约只需要 12.5MB。

这就是位图 BitMap 最吸引人的地方:

把"是否存在"这种状态压缩到一个 bit 里。

它不是什么复杂的数据结构,但在合适的场景下非常好用,尤其是处理大量整数的存在性判断、去重、状态标记时,位图的空间优势很明显。


一、位图的基本思想

位图可以看成是一个"压缩版的 bool 数组"。

普通 bool 数组里,一个位置至少占一个字节;而位图里,一个位置只占一个 bit。

比如一个 uint64_t 有 64 个 bit,那么它就可以表示 64 个数字的状态。

txt 复制代码
一个 uint64_t 可以管理 64 个状态:

bit编号:63 62 61 ... 3 2 1 0
状态值: 0  0  0  ... 0 1 0 1

假设我们要表示数字 x 是否存在,不能真的直接去找"第 x 个 bit",因为计算机里没法单独申请一个 bit。实际实现时,一般会用一个整数数组来承载这些 bit。

所以我们要先算两件事:

cpp 复制代码
size_t index = x / 64;   // x 落在哪一个 uint64_t 里面
size_t offset = x % 64;  // x 落在这个 uint64_t 的第几个 bit 位

也可以写成位运算:

cpp 复制代码
size_t index = x >> 6;   // 等价于 x / 64
size_t offset = x & 63;  // 等价于 x % 64

这里用 >> 6& 63 不是为了炫技。

因为 64 是 2 的 6 次方,所以除以 64 可以换成右移 6 位,取模 64 可以换成 & 63

写成 / 64% 64 当然也没问题,编译器一般也会优化。

举个例子,如果要存数字 130

cpp 复制代码
index = 130 / 64 = 2;
offset = 130 % 64 = 2;

这说明数字 130 在第 2 个 uint64_t 里,也就是数组的第三个元素中,并且对应这个整数的第 2 个 bit 位。

txt 复制代码
数字 130
   |
   |-- index = 2   -> 找到第 2 个 uint64_t
   |
   |-- offset = 2  -> 修改这个 uint64_t 的第 2 个 bit

理解位图最关键的地方就在这里:

一个数字并不是直接存进数组里,而是被映射到了某一个 bit 位上。

这个 bit 是 1,就表示它存在;是 0,就表示它不存在。


二、位图的核心操作

位图常见操作其实就三个:设置、清除、查询。

txt 复制代码
set    :把某个 bit 设置为 1,表示数据存在
reset  :把某个 bit 设置为 0,表示数据不存在
test   :判断某个 bit 当前是不是 1

这三个操作背后都是位运算。

set:标记存在

如果要把数字 x 标记为存在,需要把对应 bit 设置成 1。

cpp 复制代码
bits[index] |= (1ULL << offset);

1ULL << offset 会生成一个掩码。

这个掩码只有目标位置是 1,其他位置都是 0。

然后再和原来的数据做按位或,就能把目标 bit 置为 1,同时不会影响其他 bit。

假设 offset = 3

txt 复制代码
1 << 3 之后:

00001000

如果原来的数据是:

txt 复制代码
原数据:00000001
掩码:  00001000
结果:  00001001

可以看到,第 3 位被设置成了 1,其他位保持不变。

reset:标记不存在

如果要把数字 x 标记为不存在,就需要把对应 bit 清零。

cpp 复制代码
bits[index] &= ~(1ULL << offset);

这里的思路和 set 正好反过来。

先构造一个只有目标位为 1 的掩码,然后取反,让目标位变成 0,其他位变成 1。

最后再和原数据做按位与,这样只有目标 bit 会被清掉。

比如要清除第 3 位:

txt 复制代码
原数据:11111111
掩码:  00001000
取反:  11110111
结果:  11110111

test:判断是否存在

查询就更简单了,只需要看目标 bit 是不是 1。

cpp 复制代码
return (bits[index] & (1ULL << offset)) != 0;

如果按位与的结果不为 0,说明目标 bit 是 1,也就是这个数字出现过。

如果结果为 0,说明目标 bit 是 0,这个数字没有出现过。

这三行代码基本就是位图的灵魂:

cpp 复制代码
// 设置
bits[index] |= (1ULL << offset);

// 清除
bits[index] &= ~(1ULL << offset);

// 查询
bits[index] & (1ULL << offset);

看起来很短,但只要把这三句理解透,位图的实现就没什么神秘的了。


三、手写一个简单的 BitMap

下面写一个简单版的 BitMap

为了让代码更容易看懂,这里只处理非负整数,并且初始化的时候指定最大值范围。

cpp 复制代码
#include <iostream>
#include <vector>
#include <cstdint>
#include <stdexcept>

class BitMap
{
public:
    // 支持表示 [0, maxValue] 范围内的整数
    explicit BitMap(size_t maxValue)
        : _maxValue(maxValue),
          _bits((maxValue >> 6) + 1, 0)
    {}

    // 把 x 标记为存在
    void set(size_t x)
    {
        checkRange(x);

        size_t index = x >> 6;   // x / 64
        size_t offset = x & 63;  // x % 64

        _bits[index] |= (1ULL << offset);
    }

    // 把 x 标记为不存在
    void reset(size_t x)
    {
        checkRange(x);

        size_t index = x >> 6;
        size_t offset = x & 63;

        _bits[index] &= ~(1ULL << offset);
    }

    // 判断 x 是否存在
    bool test(size_t x) const
    {
        checkRange(x);

        size_t index = x >> 6;
        size_t offset = x & 63;

        return (_bits[index] & (1ULL << offset)) != 0;
    }

private:
    void checkRange(size_t x) const
    {
        if (x > _maxValue)
        {
            throw std::out_of_range("value is out of bitmap range");
        }
    }

private:
    size_t _maxValue;
    std::vector<uint64_t> _bits;
};

简单测试一下:

cpp 复制代码
int main()
{
    BitMap bm(1000);

    bm.set(10);
    bm.set(99);
    bm.set(130);

    std::cout << bm.test(10) << std::endl;
    std::cout << bm.test(11) << std::endl;
    std::cout << bm.test(99) << std::endl;

    bm.reset(99);

    std::cout << bm.test(99) << std::endl;

    return 0;
}

运行结果:

txt 复制代码
1
0
1
0

这段代码没有什么花活。

每次操作一个数字,都先算出它落在哪个整数里,再算出它对应这个整数中的哪个 bit。

找到位置以后,剩下的事情就是位运算。

这里有个细节可以注意一下:

cpp 复制代码
_bits((maxValue >> 6) + 1, 0)

为什么要 + 1

因为下标是从 0 开始的。

比如 maxValue = 64,数字 64 本身已经落到第二个 uint64_t 里了。

如果不加 1,就会出现空间不够的问题。

实际写工程代码时,还可以继续封装一些接口,比如统计 bit 个数、批量设置、支持序列化等。但从理解位图本身来说,上面这份代码已经够用了。


四、位图适合解决哪些问题

位图不是万能的,但它适合的场景非常明确:

数据是整数,范围比较清楚,并且只关心是否出现过。

比如有一千万个整数,范围在 0 ~ 1 亿,现在想判断某个数字是否存在,就可以这么写:

cpp 复制代码
BitMap bm(100000000);

for (auto x : nums)
{
    bm.set(x);
}

if (bm.test(target))
{
    std::cout << "target exists" << std::endl;
}

如果用哈希表,当然也能做,而且查询平均也是 O(1)

但哈希表本身要维护桶、节点、负载因子等结构,空间开销通常会比位图高不少。

所以在"整数范围可控 + 只判断是否存在"这种场景下,位图往往比哈希表更省内存。

但是如果数据范围特别大,而实际数据很少,位图就不一定划算了。

比如只有下面几个数字:

txt 复制代码
1, 1000000000, 9999999999

这时候如果为了这几个数字开一个巨大位图,那就有点离谱了。

所以位图适合什么,不适合什么,一定要分清楚:

txt 复制代码
适合:
- 非负整数
- 范围明确
- 只关心是否存在
- 数据量较大
- 对内存比较敏感

不适合:
- 字符串、结构体这类复杂数据
- 整数范围极大但数据很稀疏
- 需要记录出现次数
- 需要保存原始数据本身

位图去重

位图很适合做整数去重。

假设有这样一组数据:

txt 复制代码
3 5 7 3 2 5 8

我们可以边遍历边判断,如果这个数字之前没出现过,就输出并标记。

cpp 复制代码
std::vector<int> nums = {3, 5, 7, 3, 2, 5, 8};

BitMap bm(100);

for (int x : nums)
{
    if (!bm.test(x))
    {
        std::cout << x << " ";
        bm.set(x);
    }
}

输出结果:

txt 复制代码
3 5 7 2 8

这个过程其实很好理解:

第一次看到 3,位图里没有,就输出并标记;

第二次再看到 3,发现已经标记过了,就直接跳过。

不过普通位图只能判断"有没有出现过",不能记录"出现了几次"。

如果题目要求保留重复次数,那就不能直接用普通位图了,可以考虑计数数组或者哈希表。

位图排序

如果数据都是非负整数,并且范围不大,位图也可以拿来做一种特殊排序。

cpp 复制代码
std::vector<int> nums = {7, 3, 5, 1, 3, 9};

BitMap bm(100);

for (int x : nums)
{
    bm.set(x);
}

for (int i = 0; i <= 100; ++i)
{
    if (bm.test(i))
    {
        std::cout << i << " ";
    }
}

输出:

txt 复制代码
1 3 5 7 9

它的思路不是比较大小,而是先把出现过的数字标记起来,然后从小到大扫描整个位图。

扫到哪个位置是 1,就说明哪个数字出现过。

这种方式在某些范围可控的整数场景下挺好用,但问题也很明显:

重复数字会丢失。

比如原数据里有两个 3,最后也只会输出一个 3


五、位图和哈希表、布隆过滤器的关系

位图和哈希表都能做存在性判断,但两者的出发点不太一样。

对比点 位图 哈希表
适合数据 非负整数,范围明确 各种类型
查询效率 O(1) 平均 O(1)
内存占用 通常更低 通常更高
是否保存原始数据 不保存,只保存状态 可以保存原始数据
是否适合稀疏大范围数据 不适合 更适合

简单来说,位图不是哈希表的替代品。

它只是刚好在某些场景下,可以用更低的空间成本完成同样的存在性判断。

如果数据是字符串,比如用户名、URL、邮箱地址,那位图就没法直接用了。

这时候一般要用哈希表,或者先把数据哈希成整数,再做进一步处理。

说到这里,就能顺手理解布隆过滤器了。

普通位图是:

txt 复制代码
数字 x -> 第 x 个 bit

布隆过滤器是:

txt 复制代码
数据 key -> 多个哈希函数 -> 多个 bit

也就是说,布隆过滤器的底层也离不开位图,只不过它不是直接拿数据本身当下标,而是先通过多个哈希函数算出多个位置。

txt 复制代码
普通位图:
一个整数直接对应一个 bit

布隆过滤器:
一个数据经过多个 hash 计算后,对应多个 bit

这也是布隆过滤器可能误判的原因。

因为不同的数据经过哈希以后,可能刚好把同一批 bit 都设置成了 1。

所以布隆过滤器可以判断"某个数据一定不存在",但不能百分百判断"某个数据一定存在"。

这里不展开讲布隆过滤器,知道它和位图的关系就够了:

位图是基础组件,布隆过滤器是在位图上叠了一层哈希映射。


总结

位图 BitMap 的核心并不复杂:

用一个 bit 表示一个整数是否存在。

实现时主要就是两步定位:

cpp 复制代码
size_t index = x / 64;
size_t offset = x % 64;

然后通过位运算完成设置、清除和查询:

cpp 复制代码
// 设置
bits[index] |= (1ULL << offset);

// 清除
bits[index] &= ~(1ULL << offset);

// 查询
bits[index] & (1ULL << offset);

真正理解了这几行代码,位图基本就明白了。

它的优点是省空间、速度快、实现简单;缺点是场景比较挑,通常只适合范围可控的非负整数,而且只能表示"有没有",不能保存更多信息。

最后可以记住一句话:

当一个问题可以抽象成"某个整数是否出现过",并且整数范围不是大到离谱时,位图往往是一个非常值得考虑的方案。

它不复杂,也不炫技,但很多时候就是这种简单的数据结构,最能解决实际问题。

相关推荐
四代水门1 小时前
LeetCode刷算法题(C++)
c++·算法·leetcode
一头老黄牛@2 小时前
飞书 × OpenClaw 接入指南:不用服务器,用长连接把机器人跑起来
数据结构·人工智能·程序人生·算法·决策树·自动化·推荐算法
Passionate.Z2 小时前
基于FPGA的CLAHE自适应限制对比度直方图均衡算法硬件verilog实现
图像处理·嵌入式硬件·算法·fpga开发·fpga
菜鸡爱玩6 小时前
线性代数矩阵相乘
线性代数·算法·矩阵
devilnumber9 小时前
Java 递归算法 详解 + 核心要点 + 实战运用 + 避坑指南
java·开发语言·算法
unicrom_深圳市由你创科技10 小时前
哪些控制逻辑应该放在 PLC,哪些放在上位机?
c++
‎ദ്ദിᵔ.˛.ᵔ₎11 小时前
双指针、滑动窗口、前缀和、二分查找 算法
算法
顾北顾11 小时前
多头注意力机制
人工智能·深度学习·算法
H1785350909611 小时前
SolidWorks_基于草图的实体特征20_特征错误排查
算法·3d建模·solidworks