前言
现实生活中,有很多场景是需要处理数据量很大的数据的,比如:
 给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等等不能存储映射。
本篇内容到此就结束了,感谢你的阅读!
如果有补充或者纠正的地方,欢迎评论区补充,纠错。如果觉得本篇文章对你有所帮助的话,不妨点个赞支持一下博主,拜托啦,这对我真的很重要。
 



















