<C++> 位图

文章讲述了位图概念及其在解决实际问题中的应用,如快速判断40亿个整数中是否存在特定数、找出只出现一次的数、处理1GB内存下的交集和限制次数的整数筛选。位图利用哈希和二进制比特位,以节省空间和提高查找速度。
摘要由CSDN通过智能技术生成

目录

一、位图概念

1、使用场景1

2.位图

二、位图的模拟实现

1.基本原理

2.set

3.reset

4.test

三、使用场景2 - 给出 100 亿个不重复的无符号整数(无序),设计算法找到其中只出现一次的数

 四:使用场景3 - 给两个文件,分别有 100 亿个整数(无序),我们只有 1 GB 内存,如何找到两个文件交集?

解决方案一:

解决方案二:

五、使用场景4 - 一个文件有 100 亿个 int,1 GB 内存,设计算法找到出现次数不超过 2 次的所有整数

六、总结:


一、位图概念

1、使用场景1

给出 40 亿个不重复的无符号整数(无序),再给出一个无符号整数,判断此数是否存在于 40 亿个无符号整数中

这是一道来自【腾讯】的面试题,题目要求很简单:判断给出的数是否存在?

可以采用如下方法:

  1. 放进set或unordered_set中,再用find进行查找
  2. 排序,再使用二分法查找,排序的时间复杂度是O(N*logN),二分查找的时间复杂度是O(logN)

但是我们首先得先有一个足够大的数组存储这些数据,计算一下40亿个无符号整数会占多大内存呢?

 但是,16GB的大小,这会很消耗内存,我们不可能把16GB的数据全部加载到内存中。既然是腾讯的题,那其中肯定有坑,常规思路是无法很好地解决问题的,此时就需要借助我们今天的主角 位图 了

2.位图

        位图 是 哈希思想 的一种应用,哈希表 映射数据时使用的是 vector,而 位图 映射数据时使用的是 比特位,没错,就是只能表示 0 和 1 的比特位(使用直接定址法,只能判断整型),适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的

为什么 位图 能解决这种海量数据问题?
因为位图是哈希的应用,查找速度非常快,并且因为位图使用的是最小的单元:比特,空间利用率极高,而这就是【腾讯】这道面试题的最优解。

解题思路:首先 40 亿个无符号的整数,重点在 无符号,这就意味着借助下标可以映射所有的数,无符号整型的最大值为 UINT_MAX4294967295),这 40 亿个数据的范围 [0UINT_MAX]。

 题目不过是 验证某数是否存在,因此我们可以直接创建一个大小为 UINT_MAX 的 位图 结构,将 40 亿个数统统存进去(重复数据不影响),存储完毕后,直接利用 位图 的特性:极速查找(哈希映射),就可以在 O(1) 时间内解决问题。

至于内存占用,UINT_MAX 大约相当于 512 mb,就这点内存占用,随便给。

      数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。比如:

二、位图的模拟实现

注:模拟实现时,只是简单实现,旨在理解位图的原理,与库中的 bitset 存在较大差异

1.基本原理

位图 的原理其实十分简单,本质上就是 开辟了一个大小为 N,类型为 Type 的数组

获取值位于哪一个下标中:Val / TypeSize
获取值位于哪一个具体比特位:Val % TypeSize

注:Val 是待 设置/重置/判断 的值,TypeSize 是类型 Type 所占比特位数

我们模拟实现的 位图 本质上就是一个 vector<char> 的数组,不过此时使用的是 比特位

// 非类型模板参数,我们需要多少个数据,即多少个比特位
template<size_t N>
class bitset
{
public:
	bitset()
	{
		_bits.resize(N / 8 + 1, 0);
		// 除 8 是因为此时基本类型为 char
		// 加 1 是为了避免不能被整除时,造成比特位丢失,宁可多开,也不能缺失
		// 向上取整,如果需要65个比特位,则需9个字节,两个char
	}
private:
	vector<char> _bits;// 以char类型实现bitset
};

为什么要选 char ?
在 C语言 阶段,我们学习过一个知识点:大小端字节序,对于多字节的数据类型,诸如 int 存在大小端问题,比如 int a = 1
大端机器中为:00 00 00 01
而在小端机器中为:01 00 00 00
不同机器中的二进制位排列方式略有差异(后续位运算依赖于二进制的排列方式),为了确保兼容性,我们可以直接使用 char,因为它就 1 字节(8 比特),不存在大小端字节序问题;并且 8 比特位足够少,便于学习理解位图结构。

2.set

首先来看看 如何添加数据

位图 中没有直接插入数据的概念,取而代之的是将数据对应的比特位置为 1

假设现在 位图 Bit 的大小为 32 bit,待设置的数据为 28

首先获取具体的下标:i = 28 / 8 = 3
其次获取具体的比特位:j = 28 % 8 = 4

现在只需要把 Bit[3] 元素的第五个比特位(下标为 4)置为 1 即可成功设置 数据 28

这里不考虑冗余的情况,即使目标位置为 1,照样置为 1

想要把某个比特位置为 1 可以使用 | 进行位运算:遇 1 变 1

因此 set 数据 28 时可以这样做:Bit[3] |= (1 << 4),如下图所示:

    void set(size_t x)
	{
		size_t i = x / 8;// 找在第i个char
		size_t j = x % 8;// 找在第j个比特位
		// x==10, 所映射的位置在第0个char的第2个比特位上

		// 比特位置为1,代表存在
		_bits[i] |= (1 << j);

		// 监视窗口,虚拟层,给我们看的,左高右低
		//  7  6  5  4  3  2  1  0   15 14 13 12 11 10 9 8
		// [0  0  0  0  0  0  0  0] [0  0  0  0  0  0  0  0] 
		// 
		// 0000 0000   _bits[1]
		// 0000 0100   1 << 2
		// 0000 0100   _bits[1] |= (1 << 2);
	}

3.reset

有 设置 就要有 重置(取消),也就是 reset

设置 的目的是 将指定的比特位置 1,而 重置 的目的是 把指定的比特位重置 0

至于获取 下标 和 比特位,和 设置 一样,或者说 位图 中的基本操作都离不开这两步

首先获取具体的下标:i = 28 / 8 = 3
其次获取具体的比特位:j = 28 % 8 = 4

将某个比特位置为 0,可以使用 & 进行位运算:遇 0 变 0

下面把之前 设置 的 28 进行 重置:Bit[3] &= ~(1 << 4),如下图所示

	void reset(size_t x)
	{
		size_t i = x / 8;
		size_t j = x % 8;

		// 比特位置为0,代表不存在
		_bits[i] &= ~(1 << j);
	}

4.test

位图 中的必备功能:判断某个数据是否位于位图中(test

这是 位图 的核心功能,毕竟 位图 的主要作用就是 判断某个数在不在。

  • 存在:对应的比特位为 1
  • 不存在:对应的比特位为 0

一样的老套路:获取 下标 和 比特位,这里依旧请出老演员 28

首先获取具体的下标i = 28 / 8 = 3
其次获取具体的比特位:j = 28 % 8 = 4

如何判断在不在?
简答:如果存在的话,对应的比特位肯定为 1,我们只需要把该位置的其他比特位置为 0,再判断该元素是否为 0 即可:Bit[3] & (1 << 4)

	// 在或者不在
	bool test(size_t x)
	{
		size_t i = x / 8;
		size_t j = x % 8;

		// 在,  对应的比特位位1,_bits[i] & (1 << j)一定不为0,就是true
		// 不在,对应的比特位为0,_bits[i] & (1 << j)一定为0,就是false
		return _bits[i] & (1 << j);//返回的是临时变量,真正的元素不会被修改
	}

注意: 此时不能使用 &=,不能改变原来的比特位状态,因为这里只是判断是否存在!

接下来简单验证下存 40 亿个无符号整数只需要 约 512 mb 空间

注:传递 -1 时,因为参数类型为 size_t ,会隐式类型转换为 UINT_MAX;当然,直接传递 UINT_MAX 也是可以的

void testBitSet2()
{
    bitset<-1> Bit;	        //创建可容纳 [0, UINT_MAX]数值 的位图
	bitset<UINT_MAX> Bit;	//创建可容纳 [0, UINT_MAX]数值 的位图

	while (true);	//查看任务管理器中的内存占用情况
}

所以说,用 位图 可以解决 【腾讯】的那一道海量数据面试题,同时也是最优解,查找速度为 O(1)

注意: 此时的测试环境为 x86x64 环境下会报错


三、使用场景2 - 给出 100 亿个不重复的无符号整数(无序),设计算法找到其中只出现一次的数

给出 100 亿个不重复的无符号整数(无序),设计算法找到其中只出现一次的数

数据量变大了一倍多,没事,再多开一点,需要约 1.2 GB 的内存空间,此时内存不是问题的重点,重点在于如何设计 算法。

对于这种在两堆数中找只出现一次的数,避免不了同时遍历两堆数,所以我们需要 2 个 位图,并且大小都为 100 亿,总占用约 2.4 GB 的内存空间。

解题思路:二进制,我们把 位图1 看作高位,位图2 看作低位,第一次出现时,给 位图2 进行设置,后续第二次乃至第N次出现时,重置 位图2,设置 位图1;经过这样操作后,只要是 位图2 为 1,就说明该数仅出现了一次。

设置:

  • 0 0 没有出现
  • 0 1 只出现一次
  • 1 0    出现多次
// 在100亿个整数中找出只出现一次的数字
// 
// 00 - 不在  
// 01 - 出现一次
// 10 - 出现一次以上
template<size_t N>
class twoBitSet
{
public:
	void set(size_t x)
	{
		// 00 -> 01 - 第一次出现
		if (_bs1.test(x) == false && _bs2.test(x) == false)
		{
			_bs2.set(x);
		}
		else if (_bs1.test(x) == false && _bs2.test(x) == true)
		{
			// 01 -> 10 - 第一次变成多次
			_bs1.set(x);
			_bs2.reset(x);
		}
		// 10 - 出现多次就不再就行任何操作
	}

	void Print()
	{
		for (size_t i = 0; i < N; ++i)
		{
			// 只出现一次 - 只需判断位图2里是不是1就行
			if (_bs2.test(i))
				cout << i << endl;
		}
	}
public:
	bitset<N> _bs1;
	bitset<N> _bs2;
};
void test_twobitset()
{
	int a[] = { 3, 45, 53, 32, 32, 43, 3, 2, 5, 2, 32, 55, 5, 53,43,9,8,7,8 };
	twobitset<100> bs;
	for (auto e : a)
	{
		bs.set(e);
	}

	bs.Print();
    // 
}


四、使用场景3 - 给两个文件,分别有 100 亿个整数(无序),我们只有 1 GB 内存,如何找到两个文件交集?

给两个文件,分别有 100 亿个整数(无序),我们只有 1 GB 内存,如何找到两个文件交集?

此时只有 1 GB 的可用空间,意味着我们只有一个 位图100 亿整数中有大量重复的数据,至多有 42 亿多个数,所以 1 GB 空间足够了)

解决方案一:

先读取其中一个文件,将数据设置入 位图 中;然后再读取另一个文件,此时是判断第二个文件中的数据是否存在于 位图 中,如存在,就说明是交集。

这种方案面临一个问题:存在重复的值,比如 文件1{1, 2}文件2{1, 3, 1, 2},此时得出的交集为 {1, 1, 2},交集中是没有重复值的,想要解决这个问题有两个方法:

  1. 初步得到交集后进行去重,就能得到最终的交集
  2. 判断该数是否为交集,如果是,记录交集值后,把位图中的交集值给 reset(置0),这样即使后续有重复的值,也不会被纳入交集了。

解决方案二:

(无内存空间限制的情况下)直接搞两个位图,把两个文件都读进去,然后同时遍历,通过 & 位运算求出交集就行了

这种方案很暴力,对空间要求较高,且每次遍历的时间都是恒定的(42 亿次)

抛开题目中的内存空间限制,解决方案一、二各有自己的使用场景

  • 数据量过大时,比如 42 亿或更多,适合使用方案二(个数相关),因为 不管值再大,整数不过 42 亿多个,方案二在进行遍历时,也只需要遍历 42 亿次,是比较合适的。
  • 当数据量较小时,比如 1 亿,就可以考虑方案一了(值相关),原因很简单:节省空间的同时不至于遍历太多次,方案一遍历时,遍历的是数据量,只需要遍历 1 亿次。
     

可惜本题有内存空间限制,还是老老实实的使用方案一吧


五、使用场景4 - 一个文件有 100 亿个 int1 GB 内存,设计算法找到出现次数不超过 2 次的所有整数

一个文件有 100 亿个 int1 GB 内存,设计算法找到出现次数不超过 2 次的所有整数

这道题是 问题二 的变形,只需要推广 设置 即可,照样使用两个 位图

设置:

  • 一次都没有出现:0 0
  • 只出现一次:0 1
  • 出现两次:1 0
  • 出现三次或更多:1 1

 把代码稍微修改下,可得出代码

template<size_t N>
class twoBitSet
{
public:
	void set(size_t val)
	{
		// 00 -> 01 - 第一次出现
		if (_bs1.test(x) == false && _bs2.test(x) == false)
		{
			_bs2.set(x);
		}
		else if (_bs1.test(x) == false && _bs2.test(x) == true)
		{
			// 01 -> 10 - 第一次变成两次
			_bs1.set(x);
			_bs2.reset(x);
		}
		else if (_bs1.test(val) == true && _bs2.test(val) == false)
		{
			//10 -> 11 - 两次变多次
			_bs2.set(val);
		}
	}

	void Print()
	{
		//输出不超过 2 次的数字
		for (size_t i = 0; i <= N; ++i)
		{
			if ((!_bs1.test(i) && _bs2.test(i)) || (_bs1.test(i) && !_bs2.test(i)))
				cout << i << endl;
		}
	}

private:
	bitset<N> _bs1;
	bitset<N> _bs2;
};
void testTwoBitSet2()
{
	int a[] = { 1, 1, 2, 2, 2, 3, 3, 3, 3, 3, 5, 6, 7, 8, 8 };
	twoBitSet<100> Bits;	//最大的值不过 100,所以 100 足够了

	for (auto e : a)
	{
		Bits.sSet(e);
	}

	Bits.Print();
}


六、总结:

位图 是一种十分特殊的数据结构,其主要依靠 0 和 1 表征状态,结合 哈希 的映射思想,即保证了 速度,又保证了 空间

位图 的优点如下:

  • 速度极快 O(1)
  • 节省空间 使用粒度最细的比特位

位图 的缺点如下:

  • 只能映射整型
  • 对于浮点符、字符串等数据无法做到很好的映射

位图 的应用场景:

  • 快速查找某个数据是否在一个集合中
  • 排序 + 去重
  • 求两个集合的交集、并集等
  • 操作系统中磁盘块标记
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值