文章目录
- 1. 位图的介绍
- 2. 位图的概念
- 3. 位图的实现
- 🍑 构造函数
- 🍑 设置指定位
- 🍑 清除指定位
- 🍑 获取指定位的状态
- 🍑 打印函数
- 4. 总结
1. 位图的介绍
在介绍位图之前先来看一道面试题吧
给 40 亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这 40 亿个数中。
对于判断一个数是否在某一堆数中,主要有以下方法:
- 将这一堆数插入到 unordered_set/set 容器中,然后调用
find
函数判断该数是否在这一堆数中。 - 将这一堆数进行外排序,然后通过二分查找的方法判断该数是否在这一堆数中。
对于上面两种方法,第一种的时间复杂度是 O ( N l o g N ) O(NlogN) O(NlogN),第二种的时间复杂度是 O ( N ) O(N) O(N)。
但是有一个问题,题目给的是 40 亿个无符号的整数,如果把这些数全部加载到内存当中,那么将会占用 16G 的空间,空间消耗是很大的。因此,上面这两种方法都是不可行的。
那么怎么来解决呢?很简单,我们可以用位图(bitset)来解决!
对于数据是否在给定的整形数据中,只需要判断在或者不在,刚好是两种状态,那么可以使用一个 二进制比特位 来代表数据是否存在的信息,如果二进制比特位为 1,代表存在;如果比特位为 0,代表不存在。
如下图所示,对于 arr 集合里面的数据,我们只需要用 3 个字节(24 个 bit 位)的大小即可表示:
那么对于 40 亿个无符号的整型数据来说,无符号整数总共有 2 32 2^{32} 232 个,因此记录这些数字就需要 2 32 − 1 2^{32-1} 232−1 个比特位(比特位是从 0 开始的,所以要减 1),也就是大约 500M 的内存空间,内存消耗大大减少。
2. 位图的概念
所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。
3. 位图的实现
位图的定义需要一段连续的物理空间,所以可以拿 vector 来存储,另外,对于位图的存储,我们是不能控制 bit 位的,但是我们可以控制 字节,那么就可以把 char 或者 int 存在数组里面去。
假设数组里面存储的是 char,那么 1 个 char 就代表 8 个 bit 位。
位图的类如下:
// N个比特位的位图
template<size_t N>
class BitSet
{
public:
// 构造函数
BitSet();
// 设置指定位(将x映射的位标记成1)
void set(size_t x);
// 清空指定位(将x映射的位标记成0)
void reset(size_t x);
// 获取指定位的状态
bool test(size_t x);
// 容纳的比特位的个数
size_t size()
//打印函数
void Print()
private:
vector<char> _bits; // vector数组里面存的是一个char类型
};
🍑 构造函数
在构造位图时,我们需要根据所给位数 N,创建一个 N 位的位图,并且将该位图中的所有位都初始化为 0。
但是一个 char
有 8 个比特位,因此 N 个位的位图就需要用到 N / 8
个 char
类型,但是实际我们所需的 char
个数是 N/8+1
,为什么呢?因为所给非类型模板参数 N 的值可能并不是 8 的整数倍。
举个例子,当 N 为 10 的时候,那么 10 / 8 = 1
相当于只开了 1 个 char
类型,那如果我去访问第 9 个 和 第 10 个 bit 位的话,不就越界了吗?
所以要 N/8+1
,我知道有人肯定会担心浪费空间,我想说的是,当 N 是 8 的倍数时,可以被整除,那么此时也就才浪费 1 个 char
也就是 8 个 bit 位而已,所以无伤大雅。
代码示例
// 构造函数
BitSet()
{
// +1保证了足够的比特位,最多浪费8个
_bits.resize(N / 8 + 1, 0);
}
🍑 设置指定位
set
接口就是用来把 x
映射的位标记成 1,并且不能影响其它比特位,那么我怎么知道 x
映射的比特位在哪儿呢?
很简单,方法如下:
- 先用
x / 8 = i
计算x
应该存储在数组的第i
个 char 对象中 - 然后再用
x % 8 = j
计算x
应该存储在第i
个 char 中的第j
个比特位上 - 最后将第
i
个 char 对象的第j
个比特位设置为 1 即可
前两步很简单,对于第三步,如何设置呢?
很简单,先将数字 1 左移 j
位后,再和第 i
个 char 对象也就是 _bits[i]
进行 或运算 即可
举个例子,我们现在要把数字 19 映射在位图上。
第一步,先用 19 / 8 = 2
得出要存在数组的第 2 个 char 对象中。
第二步,再用 19 % 8 = 3
得出要存在第 3 个比特位上
第三步,先将数字 1 左移 3 位得到:1 << 3 = 00001000 = 8
,然后再和 bits[2]
进行 或运算 得出结果:
代码示例
// 设置指定位(将x映射的位标记成1)
void set(size_t x)
{
// x映射的比特位在第几个char对象
size_t i = x / 8;
// x在char第几个比特位
size_t j = x % 8;
// 先左移,再进行或运算
_bits[i] |= (1 << j);
}
🍑 清除指定位
reset
接口就是用来把 x
映射的位标记成 0,并且不能影响其它比特位。
很简单,方法如下:
- 先用
x / 8 = i
计算x
应该存储在数组的第i
个 char 对象中 - 然后再用
x % 8 = j
计算x
应该存储在第i
个 char 中的第j
个比特位上 - 最后将第
i
个 char 对象的第j
个比特位设置为 0 即可
前两步很简单,对于第三步,如何设置呢?
很简单,先将数字 1 左移 j
位后,然后再对其进行 按位取反,最后再和第 i
个 char 对象也就是 _bits[i]
进行 与运算。
我们还是举个例子,我们现在要把数字 19 从映射的位置上清除掉。
第一步,先用 19 / 8 = 2
得出要存在数组的第 2 个 char 对象中。
第二步,再用 19 % 8 = 3
得出要存在第 3 个比特位上
第三步,先将数字 1 左移 3 位得到:1 << 3 = 00001000 = 8
,然后再把数字 8 进行按位取反得到:~8 = 11110111
,然后再和 bits[2]
进行 与运算 得出结果即可。
注意:因为在设置指定位的时候,数字 19 已经被存储在 bits[2]
上了,所以 bits[2]
的比特位不是全为 0 哦!
代码示例
// 清空指定位(将x映射的位标记成0)
void reset(size_t x)
{
// x映射的比特位在第几个char对象
size_t i = x / 8;
// x在char第几个比特位
size_t j = x % 8;
// 先左移,再按位取反,最后进行与运算
_bits[i] &= (~(1 << j));
}
🍑 获取指定位的状态
test
用来获取位图中指定的位的状态,要么是 1,要么是 0。
很简单,方法如下:
- 先用
x / 8 = i
计算x
应该存储在数组的第i
个 char 对象中 - 然后再用
x % 8 = j
计算x
应该存储在第i
个 char 中的第j
个比特位上 - 最后判断第
i
个 char 对象的第j
个比特位的状态- 如果是 0,说明该比特位没有被设置
- 如果是非 0,说明该比特位被设置
前两步很简单,对于第三步,如何判断呢?
很简单,先将数字 1 左移 j
位后,再和第 i
个 char 对象也就是 _bits[i]
进行 与运算。
我们还是举个例子,假设数字 19 已经被映射到位图上去了,我们现在要判断数字 19 在不在上面。
第一步,先用 19 / 8 = 2
得出要存在数组的第 2 个 char 对象中。
第二步,再用 19 % 8 = 3
得出要存在第 3 个比特位上
第三步,先将数字 1 左移 3 位得到:1 << 3 = 00001000 = 8
,然后再和 bits[2]
进行 与运算 得出结果即可。
代码示例
// 获取指定位的状态
bool test(size_t x)
{
// x映射的比特位在第几个char对象
size_t i = x / 8;
// x在char第几个比特位
size_t j = x % 8;
// 先左移,再进行与运算,最后直接返回与运算的结果即可
return _bits[i] & (1 << j);
}
🍑 打印函数
最后可以实现一个打印函数 Print
,用来检查我们上述代码的正确性。
对于 Print
函数的实现,也很简单,只需要遍历位图所包含的比特位进行打印即可。在打印位图的过程中可以顺便统计位图中位的个数 count,然后将 count 与我们传入的非类型模板参数 N 进行比较,可以判断位图大小是否是符合我们的预期。
代码示例
// 容纳的比特位的个数
size_t size()
{
return N;
}
//打印函数
void Print()
{
int count = 0;
size_t n = _bits.size();
// 先打印前n-1个数
for (size_t i = 0; i < n - 1; i++)
{
for (size_t j = 0; j < 8; j++)
{
if (_bits[i] & (1 << j)) //该位被设置
cout << "1";
else //该位未被设置
cout << "0";
count++;
}
}
// 再打印最后一个数的前(N%8)位
for (size_t j = 0; j < N % 8; j++)
{
if (_bits[n - 1] & (1 << j)) //该位被设置
cout << "1";
else //该位未被设置
cout << "0";
count++;
}
cout << "\n" << count << endl; //打印总共打印的位的个数
}
然后我们测试一组数据
测试第二组数据
4. 总结
其实对于位图在 C++ 库里面是已经实现好了的:位图文档,它是 STL 的一个模板类,它的参数是整形的数值,使用位的方式和数组区别不大,相当于只能存一个位的数组。
以及各种常用接口
主要的功能如下:
最后,说几个位图的应用场景:
- 快速查找某个数据是否在一个集合中
- 排序 + 去重
- 求两个集合的交集、并集等
- 操作系统中磁盘块标记