文章目录
- 前言
- [1. 面试题思考](#1. 面试题思考)
- [2. 位图](#2. 位图)
-
- [2.1 位图的概念](#2.1 位图的概念)
- [2.2 思路讲解及代码实现](#2.2 思路讲解及代码实现)
- [3. 位图的应用](#3. 位图的应用)
- [4. 总结](#4. 总结)
- [5. 源码](#5. 源码)
-
- [5.1 bitset.h](#5.1 bitset.h)
- [5.2 Test.c](#5.2 Test.c)
前言
前面的文章里我们学习了哈希表,并用哈希表模拟实现了STL里面的unordered_map和unordered_set。
那接下来呢我们要再来学习一下哈希的应用------位图和布隆过滤器。
这篇文章先来看第一个------位图
1. 面试题思考
首先我们来看一道腾讯曾经考过的面试题,引出我们今天要讨论的问题
问题是这样的:
给40亿个不重复的无符号整数,没排过序。然后给一个无符号整数,如何快速判断这个数是否在这40亿个数中?
那我们看到这个问题可能会想到这样的思路:
- 遍历,时间复杂度O(N)
- 排序+二分查找
- 利用哈希表或红黑树,就是放到set或unordered_set里面进行查找嘛
那大家思考一下,上面这些方法有没有什么问题?
那这里我们要注意到的是它这里给的是40亿个整数。
那大家先算一下,40亿个整数大概占多少空间?
首先:
1G=1024MB=1024*
1024KB=1024*
1024*
1024byte
就约等于10亿byte(字节)
那40亿个整数(一个整型4字节)就约等于16G。
那大约占16G空间的话我们上面的方法还可行吗?
首先最关键的问题是16G的数据可能都不能一次全部放到到内存中,内存可能都不够用。
要遍历的话就得把它们全部放到内存里面啊
然后如果要排序那就是用归并排序了,分开放到一个个的小文件里面,进行归并。
但是文件里面也没法二分查找啊,都不能下标访问。
那你像放到set或unordered_set里面查找也是一样,内存可能不够,哈希表或红黑树还有额外的消耗,因为还要存一些指针啥的,记录颜色啥的。
当然你可以分开每次处理一小部分,但这样效率就不太行了。
所以上面这些思路都不太合适,而且我们这里只是要判断在不在,其实没必要把它们全部存起来。
那像这样的问题用我们接下来要学的位图来解决就比较好。
2. 位图
2.1 位图的概念
所谓位图,就是用一个个比特位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。
那对于上面的那个问题我们其实就是判断在不在嘛,所以我们只需要存储每个值的存放状态(是否存在)即可,那我们就可以用位图来解决。
怎么做呢?
判断一个数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,比如可以用二进制比特位为1代表存在,为0代表不存在
题目说的是40亿个不重复的无符号整数,所以我们可以这样做:
无符号整数的最大值是2^32-1,即4294967295
所以我们开这样一个数组
下标从0到无符号整型的最大值2^32-1
每个元素的大小呢是1个比特位,因为我们用1个比特位就可以来标识当前位置下标所对应的值存不存在,所以它其实就是一个直接定址法的思想 。
当然没有类型的大小是1个bit,所以我们可以开成char数组(当然不一定非要用char),那每个位置就是8个比特位。
0到7映射到第一个char,8到15映射到第二个char,以此类推。
那大家看现在我们标识40亿个整数存不存在用了多大空间?
前面我们算了40亿个整数大约16G,一个整数4字节=32bit,现在相当于缩到了1bit,原来的1/32,那就是大约0.5G即512MB,这个大小肯定是没问题的。
2.2 思路讲解及代码实现
那接下来我们就来详细讲解一下如何使用位图解决上面那道题,并实现相应的代码。
结构定义
首先我们可以先简单定义一下位图的结构:
那它实际就是一个数组嘛。
构造函数
我们来看一下构造函数应该怎么写?
那我们初始的时候就把vector里面全放成0嘛。
那现在要开N的比特位的空间的话,我们的元素个数应该给多少呢?
🆗,这里给N/8+1是比较合适的
因为我们vector里面放的是char嘛,一个char8个bit,所以N/8,但是可能有余数,所以再+1,多开8个bit,这个确保肯定是够用的。
set和reset接口实现
然后这里我们首先要实现的两个核心操作叫做set和reset:
set就是把x映射的那个位置的比特位设置成1,reset就是把它设置成0。
那首先第一个问题我们如何通过x找到它映射的那个比特位?
🆗,那首先第一步我们要找到x映射到第几个char里面 ,因为我们现在开的是char数组,里面是一个个的char(8bit),所以这里就是去找它映射到第几个8比特位里面。
那要算这个的话我们是不是用x除8就得到了
比如就这个图我们算8,那就是8/10等于1,所以它就在下标为1(实际是第二个)的那个char里面,这没有问题。
那然后我们就要找它映射的是在这个char里面的哪个bit位上?
那用x%8 是不是就行了
那紧接着,我们找到了这个比特位,如何把它设置成1(set)或者设置成0(reset)呢?
先来看set,把x映射的比特位设置成1,怎么做呢?
🆗,我们给这个位置按位或
|
上一个1 是不是就可以了。
因为按位或的话是有1则1,全0才0嘛。
不论它原来是1还是0,按位或1之后都是1。
当然改变这个位置的同时我们不能影响其它位置 。
那我们如何做到呢?
我们让1左移j位(实际是向高位移动,只不过一般情况下都是左边为高位,所以如果我们画的图不同,移动方向可能就变了)然后和x映射的这个char进行按位或就行了
这里char和int进行按位或的话会发生整型提升
因为1的补码只有最低位一个1,其余位置都是0,所以向高位移动j个位置这个唯一的1就和x映射的那个位置对上了。
所以:
那reset呢?把x映射的比特位设置成0
很简单,让这个比特位按位与0就行了
按位与是有0则0,全1才1
那我们只需让x映射的这个char跟1左移j位之后再取反(按位取反~)的结果按位与就行了 ,这样x映射的比特位变成0,其它位置也不受影响(因为其它位置是跟1进行&)。
set和reset测试观察
我们测试测试,并调式观察一下
首先看一下set:
我们开一个大小100bit的,然后把10这个位置置成1,就表示插入10这个值,set之后10就存在了
我们来调试观察一下:
先拿到bs的起始地址
我们看到现在都是0(现在一列是1个字节,不支持按比特位监视)
然后我们执行set
那就是把10这个位置置成1
我们看一下对不对
没问题(我们看到监视窗口其实就对应我们后面画的那个图,他也是为了方便我们观察,符合人的习惯),大家可以自己再换一些值测试测试。
那我们再来测试一下reset:
刚才对10进行了set,现在再对10进行reset,看一下
🆗,没问题。
test接口实现
除了这两个呢,还有一个比较核心的接口------test,他其实就是去判断某个值存不存在(它映射的位置是否被设置成了1):
那其实也很简单,让x映射的这个比特位和1进行按位与,如果结果是0,就表明这个比特位是0,如果结果是1,就表明这个比特位是1。
所以让x映射的这个char跟1左移j位之后的结果进行按位与就行了
结果为0就是假,非0就是真。
test接口测试
来测试一下test:
没问题。
那现在的话我们就可以很好的解决最开始的那个面试题了。
思考
那大家来思考一下:对于上面那个问题40亿个无符号整数,我们的位图应该给多大?
就开40亿个可以吗?
不可以的。
要注意我们不能按个数去开,而是要按照范围去开 。
就算现在变成10亿个无符号整数,我们也应该开4294967295(即2^32-1,无符号整型最大值)个,因为我们不知道这10亿个整数的取值范围,它可能就包含了最大值,所以我们要确保不论它多大,就可以映射到位图中一个确定的位置上。
那我们如何让位图开这么大空间呢?4294967295这个值也不好记啊?
🆗,我们给一个-1是不是就好了啊。
bitset<-1> bs;
因为我们的N是无符号整数啊,那-1变成无符号整数不就是整型最大值嘛,就跟那个npos是一样的玩法。
那除了用-1,其实我们还可以这样写:
字面值是可以这样写的。
那我们可以看一下,给这个值他是不是按我们上面分析的一样开了512MB空间:
大家看现在是0.4MB
然后我们继续执行一步
🆗,就变成512.4MB了
最后想告诉大家的是:
我们上面讲的位图其实C++库里面也是提供的有现成的
我们上面实现的命名风格其实就是跟着库里面走的
比较核心的接口我们都带大家实现了
其它的接口大家用的的时候可以自己查阅文档
3. 位图的应用
下面我们再来一起看几个位图相关的练习题
习题1
- 给定100亿个整数,设计算法找到只出现一次的整数?
大家思考一下,可以怎么解决?
首先这里是100亿个整数欸,我们还开
0xFFFFFFFF
这么多空间够吗?
当然是够的,虽然有100亿个,但它的范围还是不变的,肯定不会超过整型最大值,只能说明有很多重复值。
那我们肯定还是用位图来解决嘛,这道题我们可以这样做:
这道题让我们找出只出现一次的整数,其实通过两个比特位就可以判断。
我们只看两位,00就是0次,01就是一次,10就是1次以上,不一定就是两次,因为我们set的时候如果是10就可以不进行操作了,反正set到10的时候就已经超过1次了
所以我们可以给上面实现的位图改造一下,改造成每个位置占两个比特位的位图。
当然也可以不改造,我们还是用上面的位图,我们开两个位图,如果一个整数第一次出现就在第一个位图中把它映射的位置置成1,第二次出现就把它在第二个位图中映射的位置置成1。
所以我们可以封装一个twobitset:
那对于它的set,就应该是这样的:
看当前值在两个位图中映射位置的值,如果是00,就变成01,如果是01,就变成10,如果是10,已经超过1次了,后续就可以不进行任何操作了
那然后我们可以写一个Print打印所有出现一次的值:
那我们如何找出twoset里面所有只出现一次的值呢?
很简单,我们去遍历找哪个值在第二个位图里面的映射位置是1(那一定是01,因为我们只有00、01、10三种状态),那它就是只出现一次的
那我们来搞一组数据测试一下:
测试的话我们数据量就不高那么大了
我们看一下打印的是不是只出现一次的
🆗,没有问题,大家可以自己再测试。
那我们再来看下一个问题:
习题2
- 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
首先第一种思路:
我们可以先读取一个文件的值放到内存中,然后再读取第二个文件,依次判断第二个文件里面的值在不在第一个位图里面,在的就是交集。
当然如果这样做的话有一个问题就是我们求出来的交集可能会有重复值出现,而求交集一般是要去重的。
当然我们可以解决一下:
我们读取第二个文件判断在不在位图里面的时候,如果在的话第一次就把它映射的这个位置置成0,这样后续再遇到就不再把它添到交集里面,就达到去重的效果了。
然后另外一种思路是这样的:
把两个文件的数据分别映射到两个位图里面。
然后遍历其中一个文件依次取值,判断如果某个值在两个位图里面映射的位置
都是1,那说明它在两个文件里都存在,就是交集
或者我们可以直接对两个位图进行按位与,结果中为1的位置对应的下标就是交集
那这两种方法的话:
如果数据量比较小用第一种其实比较好,因为第一种是遍历文件里面的数据去判断,而第二种我们遍历的数据的范围(跟数据量无关),所以如果数据量比较大可以用第二种。
所以要看场景选择合适的方法。
习题3
- 位图应用变形:1个文件有100亿个int,我们只有1G内存,设计算法找到出现次数不超过2次的所有整数
这个其实跟习题1有点类似嘛,就是第一题的一个变形:
还是用两个位图,这里我们可以分为4种状态
那还是两个比特位就够用了
我们最终要找到就是出现1次和2次的
那这个把第一题的代码稍微修改一下就行了
测试一下:
没有问题
4. 总结
最后总结一下:
那它的缺陷有没有办法解决呢?
有的就是我们下一篇文章要学的------布隆过滤器。
那我们下一篇文章在详细介绍
5. 源码
5.1 bitset.h
cpp
#pragma once
#include <vector>
template <size_t N>
class bitset
{
public:
bitset()
{
_bits.resize(N / 8 + 1, 0);
}
void set(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] |= (1 << j);
}
void reset(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] &= ~(1 << j);
}
bool test(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
return _bits[i] & (1 << j);
}
private:
vector<char> _bits;
};
void test1()
{
bitset<100> bs;
bs.set(10);
cout << bs.test(10) << endl;
cout << bs.test(15) << endl;
bs.reset(10);
cout << bs.test(10) << endl;
cout << bs.test(15) << endl;
}
void test2()
{
//bitset<-1> bs;
bitset<0xFFFFFFFF> bs;
}
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);
// }
// //01->10
// else if (_bs1.test(x) == false
// && _bs2.test(x) == true)
// {
// _bs2.reset(x);
// _bs1.set(x);
// }
// //10不用管
//}
/*void Print()
{
for (size_t i = 0; i < N; i++)
{
if (_bs2.test(i) == true)
{
cout << i << " ";
}
}
cout << endl;
}*/
//找到出现次数不超过2次的所有整数
void set(size_t x)
{
//00->01
if (_bs1.test(x) == false
&& _bs2.test(x) == false)
{
_bs2.set(x);
}
//01->10
else if (_bs1.test(x) == false
&& _bs2.test(x) == true)
{
_bs2.reset(x);
_bs1.set(x);
}
//10->11
else if (_bs1.test(x) == true
&& _bs2.test(x) == false)
{
_bs2.set(x);
}
//11不用管
}
void Print()
{
for (size_t i = 0; i < N; i++)
{
if ((_bs1.test(i) == true && _bs2.test(i) == false)
|| (_bs1.test(i) == false && _bs2.test(i) == true))
{
cout << i << " ";
}
}
cout << endl;
}
private:
bitset<N> _bs1;
bitset<N> _bs2;
};
void test_twobitset()
{
int a[] = { 1,1,1,2,3,3,3,3,4,4,5,6,9,12,33,45,45,45,6 };
twobitset<100> bs;
for (auto e : a)
{
bs.set(e);
}
bs.Print();
}
5.2 Test.c
cpp
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;
#include "bitset.h"
int main()
{
test_twobitset();
return 0;
}