引出图位
平时我们写代码,经常会遇到这种问题:
有一批整数,我不关心它们出现了几次,也不关心它们原来的顺序,只想知道某个数字到底有没有出现过。
比如:
给你一堆整数,范围在
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);
真正理解了这几行代码,位图基本就明白了。
它的优点是省空间、速度快、实现简单;缺点是场景比较挑,通常只适合范围可控的非负整数,而且只能表示"有没有",不能保存更多信息。
最后可以记住一句话:
当一个问题可以抽象成"某个整数是否出现过",并且整数范围不是大到离谱时,位图往往是一个非常值得考虑的方案。
它不复杂,也不炫技,但很多时候就是这种简单的数据结构,最能解决实际问题。