前言
现实生活中,有很多场景是需要处理数据量很大的数据的,比如:
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。
一看到这样的题,我们可能想到的就是
- 排序+二分查找
- 哈希表 / 红黑树
但是40亿明显是一个很大的数据量,不管哪个方法都不适合。
而这时,位图
产生了。
文章目录
- 前言
- 一. 什么是位图
- 二. 位图的实现
- 1. 基本结构
- 2. 数据的标记
- 3. 数据的清除
- 4. 数据的查找
- 5. 测试
- 三. 位图的应用
- 结束语
一. 什么是位图
所谓位图,就是用每一位比特位
来存放某种状态
,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在
的。因为比特位只有0 / 1两种状态,用来标识存在与否这种原子性的状态,再合适不过了。
第几个比特位其实类似数组的下标
,但是存储的数据只是0 / 1。
比如
每一个方格代表一个比特位,初始为0。如果存储3,则在3位置的比特位写入1,存储7,则在7位置的比特位写入1。
所以前言的题目,就由最开始的,一个整型存储一个数,需要16G:40亿数据大约是4G,而每一个整数需要4个字节,也就是总共16G
但是如果我们使用位图,只需要一个比特位标记一个数是否存在,所以一共需要40亿个比特位,一个字节又有8个比特位,所以总共需要4GB / 8,也就是512MB
。
量级一下子小了不少
二. 位图的实现
虽然位图是以比特位为基本单元的,但是我们并不能直接创建比特位,不过我们可以使用char
类型,其大小为1字节,8个比特位。
如果我们要存储59,那么其实是存储在59 / 8 =7,59 % 8 =3,第7个char的第3个比特位
。
1. 基本结构
因为我们需要随机存储,所以我们底层使用vector,内部存储char类型。同时可以使用非类型模板参数,由需求规定当前位图存储数据量的大小
代码如下:
template<size_t N>
class bitset
{
bitset()
{
_bits.resize(N / 8 + 1, 0);
}
private:
vector<char>_bits;
};
模板参数的N,就是指定该位图存储数据大小的范围
但是因为基本类型是char,一个char有8个比特位,所以vector实际需要的char并不是N,而是N / 8+1。
2. 数据的标记
存储一个数据,我们需要将其对应的比特位置1
正如我们上述的逻辑,我们首先通过 / 8
获取,要在第几个char做修改;再 %8
获取修改其第几个比特位
最后我们使用或运算,就修改成功了。
因为我们要置1。
0和1,与1或运算都是1。
0和1,与0或运算都是其本身
我们只要通过左移
(将1从低地址移到高地址
),使得除修改位置外,其他比特位都是0,就可以只修改特定比特位
代码如下:
//标记
void set(size_t N)
{
size_t piece = N / 8;
size_t bit = N % 8;
_bits[piece] |= (1 << bit);
}
3. 数据的清除
清除一个数据,我们需要将其对应的比特位置0
基本逻辑相同,也是先获取要修改的位置,使用与运算
修改
0 / 1 ,与0与运算都是0
0 / 1, 与1与运算是其本身
所以我们需要将修改的比特位与0与运算
,其他比特位与1与运算,只要将1左移再取反就可以了
代码如下:
//去标记
void reset(size_t N)
{
size_t piece = N / 8;
size_t bit = N % 8;
_bits[piece] &= (~(1 << bit));
}
4. 数据的查找
数据的查找返回bool值,为非0就是true
大致逻辑相同,最后与1与运算
即可
代码如下:
//查找
bool test(size_t N)
{
size_t piece = N / 8;
size_t bit = N % 8;
return _bits[piece] & (1 << bit);
}
5. 测试
我们加一个打印的函数,如果当前位图某比特位为1,那么打印这个数
//打印
void Print()
{
for (size_t i = 0; i < N; i++)
{
if (test(i))
{
cout << i << " ";
}
}
cout << endl;
}
测试代码如下:
void test_bitset1()
{
bitset<100> bs;
bs.set(10);
bs.set(11);
bs.set(15);
bs.set(25);
bs.set(34);
bs.set(7);
bs.Print();
bs.reset(7);
bs.reset(34);
bs.Print();
}
运行结果如下:
回到前言的问题,大致记录方式我们了解了。
但是需要注意的是,不管存储数据个数有多少,对于整型,我们都需要开整型最大值
的空间,因为就算是只记录100个数据,这100个数据的大小,大的可能到42亿,小的可能到个位数。模板参数的N,是记录数据的大小范围,不是存储数据个数
可以如下设置
bitset<-1>bs;
bitset<0xFFFFFFFF>bs;
三. 位图的应用
- 给定100亿个整数,设计算法找到只出现一次的整数
首先,整数的大小范围就是0~42亿,所以100亿个整数,肯定都很多重复数据。
其次,100亿个整数,并不影响我们给位图开的空间,我们依然需要开42亿字节的位图。
但是因为我们要找只出现一次的整数,需要记录一个数出现的次数
一个思路是:我们修改位图的结构,加一个计数的变量
但是这个思路并不合适,因为位图的使用本身就是用来处理海量数据的,因为其节省了很多空间。但是如果给每个比特位添加计数,那么空间使用的成本又上去了。显然两者是相违背的
第二个思路是:我们使用
两个位图
,通过同一位比特位,两个位图的状态标记
,区分一个数出现的次数
没有出现就是00,出现一次是01,出现多次是10。
这样我们只需要在遍历的时候,找到第二个位图是1
的数据,就是只出现一次的数据
实现代码如下:
template<size_t N>
class twoBitset
{
public:
//数据的插入
void set(size_t n)
{
if (_bs1.test(n) == false && _bs2.test(n)== false)
{
//第一次出现
_bs2.set(n);
}
else if (_bs1.test(n) == false && _bs2.test(n) == true)
{
//第二次出现
_bs1.set(n);
_bs2.reset(n);
}
}
//打印
void Print()
{
for (size_t i = 0; i < N; i++)
{
if (_bs2.test(i) == true)
{
cout << i << " ";
}
}
cout << endl;
}
private:
bitset<N>_bs1;
bitset<N>_bs2;
};
- 测试
- 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件的交集
有两种方法
- 第一种方法:
将
其中一个文件的值映射到位图
中,然后读取另一个文件的值,去位图中查找在不在
,在就是交集,不在就不是交集。
但是可能有重复数据
,所以我们可以在查找成功,即找到交集的数值,将其位图的比特位置0
,这样后续再有重复数据也不会查找成功
- 第二种方法:
将
两个文件映射到两个位图
,然后遍历位图。当两个位图的比特位都是1
,代表是交集,反之则不是。
第一种方法适用于文件数据较小
的情况,因为方法一查找的次数是根据数据个数
改变
而第二种方法不管数据个数多少,都需要查找位图范围
,即整型所表示数字范围。
- 1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
这个简单,我们只需要用两个位图就好
00表示出现0次
01表示出现1次
10表示出现2次
11表示出现3次
位图总的应用大致如下
- 快速查找某个数据是否在一个集合中
- 排序 + 去重
- 求两个集合的交集、并集等
- 操作系统中磁盘块标记
结束语
位图的优点:速度快,节省空间
缺点:只能映射整型,其他类型如:浮点数,string等等不能存储映射。
本篇内容到此就结束了,感谢你的阅读!
如果有补充或者纠正的地方,欢迎评论区补充,纠错。如果觉得本篇文章对你有所帮助的话,不妨点个赞支持一下博主,拜托啦,这对我真的很重要。