位图
什么是位图
- 位图其实就是哈希的变形,同样通过映射到处理数据,只不过位图本身并不存储数据,而是存储标记
- 通过一个比特位来标记这个数据是否存在,1代表存在,0代表不存在
- 位图通常情况下用在数据量庞大,且数据不重复的情景下判断某个数据是否存在
位图的应用
- 快速查找某个数据是否在一个集合中
- 排序
- 求两个集合的交集、并集等
- 操作系统中磁盘块标记
快速查找某个数据是否在一个集合中
面试题:有1千万个无序不重复的整数,其范围在1到1亿之间。如何快速查找某个整数是否在这1千万个整数中呢?
分析:
- 可以看到数据量比较大,这个时候就需要想内存是不是足够的问题
- 同样的,数据量比较大,所以我们需要快速定位数据所在位置
思路:位图
- 申请一个大小为1亿,数据类型为bool类型的数组。
- 将这1千万个整数作为下标,将对应的数据设置为true。比如,整数5对应下标为5,那么就是arr[5] = true
- 当我们要查询数 K K K是否存在是,只需要查询 a r r [ K ] arr[K] arr[K]是否为true即可,如果为true,表示存在,否则不存在。
但是,很多语言中提供的bool类型,大小是1byte的,并不能节省太多内存空间。实际上,表示true/false,我们只需要1bit就可以了。那么应该如何通过二进制比特位来表示true/false?
- 可以借助位运算:
int main() {
// 3200 bit ---> bit[0~3199]
std::vector<int> arr(100); //int是32位的,100个int就有3200位,可以表示bit[0~3199]
int pos = 453;
//453位置设置为1
arr[pos/32] = arr[453/32] | (1 << (453%32));
//453位置设置为0
arr[pos/32] = arr[453/32] & (0 << (453%32));
// 提取状态
int status = ((arr[453/32] >> (453 % 32)) & 1);
printf("%d\r\n", status);
return 0;
}
- 所以实现为:
template<size_t N>
class bitset
{
public:
bitset()
{
_bits.resize(N / 8 + 1,0);//开辟空间并置为0。对于开辟空间,一个char类型有8个位,所以需要个数/8即为需要开辟的大小,但是整数相除为向下取整,所以需要我们多开一个空间出来
//_bits.resize((N >> 3) + 1,0);
}
bool test(size_t x)
{
size_t i = x / 8;//处于的该数组的第几个空间
size_t j = x % 8;//处于的该空间的第几个比特位
return _bits[i] & (1 << j);
}
void set(size_t x)
{
size_t i = x / 8;//处于的该数组的第几个空间
size_t j = x % 8;//处于的该空间的第几个比特位
_bits[i] |= (1 << j);//该位置置为1
}
void reset(size_t x)
{
size_t i = x / 8;//处于的该数组的第几个空间
size_t j = x % 8;//处于的该空间的第几个比特位
_bits[i] &= (~(1 << j));//该位置置为0
}
private:
vector<char> _bits; //对于底层来说一个位代表一个数的映射,那么我们以char类型来开辟对应需要空间,同时用vector进行管理
};
class bitmap{
public:
bitmap(size_t N){
_bits.resize(N / 32 + 1, 0); // 多开一个整型32bit
_num = 0;
}
// 标记
void set(size_t x){
// 寻找x的标记存放在第几个整型
size_t index = x / 32;
// 寻找x的标记在这个整型的第几个位
size_t pos = x % 32;
//左移是向高位移动
_bits[index] |= (1 << pos);
}
void reset(size_t x){
// 寻找x的标记存放在第几个整型
size_t index = x / 32;
// 寻找x的标记在这个整型的第几个位
size_t pos = x % 32;
// 第pos个位置置为0
_bits[index] &= ~(1 << pos);
}
// 判断x的映射位是否为1
bool test(size_t x){
// 寻找x的标记存放在第几个整型
size_t index = x / 32;
// 寻找x的标记在这个整型的第几个位
size_t pos = x % 32;
return _bits[index] & (1 << pos);
}
private:
std::vector<int> _bits;
size_t _num; // 存储的数据个数
};
-
当然,我们也可以用C++中提供的bitset来操作
-
注:使用成员函数set、reset、flip时,若指定了某一位则操作该位,若未指定位则操作所有位
#include <iostream>
#include <bitset>
using namespace std;
int main()
{
bitset<8> bs;
bs.set(2); //设置第2位
bs.set(4); //设置第4位
cout << bs << endl; //00010100
bs.flip(); //反转所有位
cout << bs << endl; //11101011
cout << bs.count() << endl; //6
cout << bs.test(3) << endl; //1
bs.reset(0); //清空第0位
cout << bs << endl; //11101010
bs.flip(7); //反转第7位
cout << bs << endl; //01101010
bs.reset(); //清空所有位
cout << bs.none() << endl; //1
bs.set(); //设置所有位
cout << bs.all() << endl; //1
return 0;
}
延伸:
- 整形数组最多申请的长度是21亿(INT_MAX),能表示的bit:21亿*32
- 如果想要表示比long类型还要多的bit怎么办?用二维矩阵实现位图
问题:给定100亿个整数,设计算法找到只出现一次的整数。
1G大概是10亿个字节,100亿个整数就是400亿个字节,400亿个字节40G。
思路:
- 这里的数字有三种状态:出现0次的、出现1次的,出现2次以及以上的
- 出现0次的标志为00,出现1次的标志为01,出现2次及以上的标志为10。
- 设置两个位图,位图1和位图2的对应位都提供一个为来标记数据,再遍历两个位图找出所有对应位映射为01的整数。
求两个集合的交集和并集
问题:给两个文件分别有100亿个整数,我们只有1G内存,如何找到两个文件的交集。
思路一:
- 将其中一个文件中数据放到一个位图中,读取另一个文件中的整数,判断在不在位图中,如果在那就是交集,如果不在,那不是交集
思路二:
- 将这两个文件分别映射到不同的位图中,在将这两个位图的对应位按位与,得到的位1就是交集
排序+去重
操作系统中的磁盘块标记
布隆过滤器
引入
问题:
- 场景一:网页爬虫时有可能爬到相同的网页链接,我们应该如何避免这些重复的爬取呢?
- 场景二:我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的?
思路:
- 记录已经爬取的URL
- 在爬取一个网页之前,先拿这个URL在已经爬取的网页链接中搜索
- 如果存在,就不去爬
- 如果不存在,就需要爬取
- 当爬取完URL之后,将这个URL添加到已经爬取的网页链接链表
问题是:该如何记录已经爬取的网页链接呢?也即是用什么数据结构来记录呢?
分析,这个数据结构需要支持两个行为
- 查找URL是否已经存在
- 添加URL
因为这两个操作经常进行而且数据量会很大,所以这两个操作的执行效率应该尽可能高,而且内存占用应该尽量少。
我们知道散列表能支持快速定位数据。现在我们来看看它的内存消耗。
假设我们要爬取10亿个网页,为了判断重复,我们需要存储这10亿个URL,那么,它需要消耗多少数据呢?
- 假设一个URL的平均长度是64字节,那单纯存储这10亿个URL,需要大约60GB的内存空间。因为散列表必须维持较小的装载因子,才能保证不会出现过多散列冲突。而且,用链表法解决总被的散列表,还会存储表指针。所以,如果将这10亿个URL构建成散列表,那需要的内存空间会远大于60GB,有可能超过100GB。
- 当然,对于一个大型的搜索引擎来说,即便是100GB的内存要求,其实也不算太高,我们可以用分治的思想,用多台机器来存储这10亿数据
也就是说,哈希表:浪费存储空间
那能不能用位图?
- 因为这里数据范围是1~10 亿,所以需要 10 亿个二进制位
- (注意,位图没有哈希冲突这一回事。10亿的数字10 亿个二进制位)
能不能在优化一下空间呢?用布隆过滤器(布隆过滤器=哈希表与位图结合)。
- 布隆过滤器对位图进行了一些优化,可以解决数据范围比较大的场景
- 如果是布隆过滤器的话,假设这里用1亿的二进制位来表说10亿的值域,这时候我们就要用哈希函数来讲值域映射到二进制位上去。为了解决哈希冲突问题,布隆过滤器用多个位来表示一个值。
具体操作如下
布隆过滤器
第一步:初始化一个长度为m比特的数组,每个bit位置0
那么n
怎么确定呢?
第二步:准确k个hash函数
第三步:操作插入、查找
(1)插入某个数
- 用K 个哈希函数,对同一个数字进行求哈希值,得到 K 个不同的哈希值,分别记作 X 1 , X 2 , X 3 , … , X n X_1 ,X_2 ,X_3 ,…, X_n X1,X2,X3,…,Xn。
- 把这 K 个数字作为位图中的下标,将对应的 B i t M a p [ X 1 ] , B i t M a p [ X 2 ] , … , B i t M a p [ X n ] BitMap[X_1 ],BitMap[X_2 ],…,BitMap[ X_n] BitMap[X1],BitMap[X2],…,BitMap[Xn]都设置成 true
- 也就是说,我们用 K 个二进制位,来表示一个数字的存在
(2)查询某个数是否存在
- 用K 个哈希函数,对同一个数字进行求哈希值,得到 K 个不同的哈希值,分别记作 Y 1 , Y 2 , Y 3 , … , Y n Y_1 ,Y_2 ,Y_3 ,…, Y_n Y1,Y2,Y3,…,Yn。
- 看这 K 个数字对应位图中的数值是否全部为true:
- 如果全部为true,那么说明这个数字存在
- 如果有任意一个数组不为true,那么说明不存在
对于两个不同的数字来说,经过一个哈希函数处理之后,可能会产生相同的哈希值。但是经过 K 个哈希函数处理之后,K 个哈希值都相同的概率就非常低了。尽管采用 K 个哈希函数之后,两个数字哈希冲突的概率降低了,但是,这种处理方式又带来了新的问题,那就是容易误判。
随着数据的不断插入,位图变红的部分越来越多,几乎全部变红,然后看谁谁都是嫌疑人,但是是嫌疑人的不一定是罪犯呀。也就是说误判有如下特点:只对存在误判。也就是:判定不存在一定不存在,判断存在有可能不存在。
其误判率与哈希函数的个数、位图的大小有关
第四步:是否可以删除集合中的元素
一般情况下不支持删除。上面我们提到,在布隆过滤器算法中会存在一个bit位被多个元素值覆盖的情况,即bit位碰撞,如果我们删除元素时刚好重置该碰撞bit为0,那么其他元素在查找的时候,就会导致判断出错的问题发生。
如何设计一个布隆过滤器
-
先确定允不允许失误率,如果不允许,就不允许用布隆过滤器来做。
-
假设m为向量表的长度,k为哈希函数的个数,n为要插入的元素个数,p为误判概率。
在实际应用中,我们一般是可以给定n、p,然后计算出m、k。
面试中:要求设计一个布隆过滤器
(1)了解现状
- 先问面试官,样本量多少,假设为n
- 然后问失误率多少,假设为p
(2)开始设计
- 根据n和p,确定位图的大小m(向上取整)
- 根据m和n,确定k值的选取(哈希函数的个数)(向上取整)
- 根据上面的m和k,修正出真实的p(只会比给出的小)
(3)k个哈希函数怎么得到
- 其实只要两个哈希函数就可以加工出无限个哈希函数
- 1 ∗ f 1 + f 2 1 * f_1 + f_2 1∗f1+f2
- 2 ∗ f 1 + f 2 2 * f_1 + f_2 2∗f1+f2
- 3 ∗ f 1 + f 2 3 * f_1 + f_2 3∗f1+f2
- …
其他:失误率和m、k之间的关系:
- (图一)随着m的增大,失误率会越来越低;
- (图二)当m固定时,随着k的增大,失误率会先降低,然后再升高,所以,要注意把控k的取值范围;
- 哈希函数的数量过少,会因为提取的特征不够多,影响失误率
- 随着哈希函数过多,变红的位置就会变多,m就会快速的耗尽
实现
template<class K = std::string, class Hash1 = HashStr1, class Hash2 = HashStr2, class Hash3 = HashStr3>
class bloomfilter{
public:
bloomfilter(size_t num){
_bs(-1);
}
void set(const K& key){
// 通过多个哈希函数将数据映射到位图中
size_t index1 = Hash1()(key);
size_t index2 = Hash2()(key);
size_t index3 = Hash3()(key);
_bs.set(index1);
_bs.set(index2);
_bs.set(index3);
}
// 存在误删的问题
void reset(const K& key){
// 不支持删除
}
bool test(const K& key){
// 判断在是不准确的,可能存在误判,判断不在是准确的。
size_t index1 = Hash1()(key);
if (_bs.test(index1) == false)
return false;
size_t index2 = Hash2()(key);
if (_bs.test(index2) == false)
return false;
size_t index3 = Hash3()(key);
if (_bs.test(index3) == false)
return false;
return true;
}
private:
// 底层其实是一个位图
bitmap _bs;
size_t len;
};
优缺点
优点:
- 布隆过滤器允许有误判的情况下才可使用。
- 哈希函数之间没有关系,方便硬件并行保存
- 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
- 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
- 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能使用同一组散列函数的布隆过滤器可以进行交、并、差运算
缺点:
- 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
- 不能获取元素本身
- 一般情况下不能从布隆过滤器中删除元素
- 如果采用计数方式删除,可能会存在计数回绕问题
应用(布隆过滤器允许有误判的情况下才可使用)
优点:占用内存少,插入和查询速度快,时间复杂度都为 O(k),与集合中元素的多少无关。
缺点:存在误判的情况,只能判断一个数据是否一定不存在, 无法判断一个数据是否一定存在。而且随着数据的增加,误判率会增加;另外数据无法删除。
优化:可以通过调整hash函数的个数、向量表长度与要存储数值个数之间的比例,降低误判率。
减少磁盘IO或者网络请求(一个值不存在,可以不用进行后续的查询请求)
比如说,在设计一个索引时
- 因为布隆过滤器的内存占用小,而且判定不存在一定不存在,判断存在有可能不存在
- 所以我们可以针对数据,构建一个布隆过滤器,并存储在内存中。当要查询数据的时候,先通过布隆过滤器,判断是否存在。如果其判断不存在,那么我们就没有必要去读取磁盘中的索引了