位图和布隆过滤器
位图的概念
位图是一种顺序数据结构,其编号表示对应元素,编号对应内容表示该编号的状态,由于标号对应的内容只有一个bit位的大小,因此常用二进制数0、1来表示该编号对应的状态;
比如:
通过位图结构来存储一堆元素的状态,我们就能在海量数据中快速的判断某一个元素在不在;
时间复杂度位O(1),是一种非常高效的数据结构;同时由于位图的每一个编号对应的内容只有一个bit位的大小,因此在空间复杂度上也是比较可观的!
比如:我们要在40亿个少量重复无符号元素中判断一个元素在不在,如果使用普通的数据结构的话,我们以char类型的顺序表为例,40亿个无符号数据,如果按照直接映射的话,那么我们至少需要40亿 x 1byte约等于4G,内存资源消耗极大,虽说4G内存我们现代计算机还是吃的消的,可是万一我们的数据范围大了起来呢?那么岂不是需要更到的内存空间,但是如果我们使用位图的话,每一个比特位表示一个元素的状态,那么40多亿个数据,就需要40亿个比特位,那么算下来的总的内存空间也就大概:
477M内存空间,相比于之前缩小了8倍多,内存空间得到了极大的节约;
位图的简单模拟实现
实际上位图的数据结构十分简单,我们只需要根据我们需要存储的数据范围,计算出总共有多少种元素即可,然后根据计算出的元素数量开辟相应大小的比特位空间;
但是由于我们C/C++允许申请的最小单位就是字节,不能直接开辟比特位,为此,我们实际需要开辟的是一个连续的字符数组!
具体操作如下:
当我们传递给bitset(位图)一个准确的位图大小N过后,我们需要根据这个N的大小计算出我们需要开多少个字节:
如果N%8不为0的话,那么还有剩余比特位没有开辟空间,我们需要给这些剩余的比特位单独开一个字节,因此实际开辟的的字节大小为N/8+1;
如果N%8等于0的话,那么说明没有比特位剩余,我们只需要开辟N/8个字节即可:
为此位图的结构设计如下:
template<size_t N>
class bitset
{
public:
bitset() {
//申请空间
_bitArr = new char[(N % 8 == 0) ? N / 8 : (N / 8 + 1)];
//将空间全部置0
memset(_bitArr, 0, (N % 8 == 0) ? N / 8 : (N / 8 + 1));
}
private:
char* _bitArr;
};
位图set
位图的set操作就是将特定位置的比特位内容置1;
在此之前,我们需要先形成一个共识:
我们规定每一个字节都是左边是低位,右边是高位,比如:
当然,我们也可以规定每一个字节右边为高比特位,左边为低比特位:
这样的话,在直觉上有点不适,但是这符合我们平常的用法,我们平常的左移右移都是依据这个共识所来的,实际上左移并不是向左边移动,而是向高比特位移动;右移也不是向右边移动,而是向低比特位移动!
理解清楚了这些,那么我们的·set操作实现起来就比较简单了,为了方便观看,我们采用第一种共识,当然我们采用第二种也没有错误:
位图reset
set是将特定位置置1,那么reset就是与set相反的,reset就是将特定位置置0;
位图test
test的功能是判断特定位置是否有效的,不用修改位图里面的内容:
位图总的代码和实现
#ifndef ___BITSET___
#define ___BITSET___
#include<iostream>
#include<string.h>
#include<assert.h>
namespace MySpace
{
template<size_t N>
class bitset
{
public:
bitset() {
_bitArr = new char[(N % 8 == 0) ? N / 8 : (N / 8 + 1)];
memset(_bitArr, 0, (N % 8 == 0) ? N / 8 : (N / 8 + 1));
}
//将pos比特位置1
bitset<N>& set(size_t pos)
{
assert(pos<N);
//定位第几个字节
size_t i = pos / 8;
//第几个比特位(低->高)
size_t j = pos % 8;
//将1添加上去
_bitArr[i] |= (1 << j);
return *this;
}
//将pos比特位置0
bitset<N>& reset(size_t pos)
{
assert(pos < N);
//定位第几个字节
size_t i = pos / 8;
//第几个比特位(低->高)
size_t j = pos % 8;
_bitArr[i] &= ~(1 << j);
return *this;
}
//pos比特位的数据是否存在
bool test(size_t pos)const
{
assert(pos < N);
//定位第几个字节
size_t i = pos / 8;
//第几个比特位(低->高)
size_t j = pos % 8;
return _bitArr[i] & (1 << j);
}
bool operator[](size_t pos)const
{
return test(pos);
}
private:
char* _bitArr;
};
}
#endif // !___bitset___
位图的应用
1、快速某个数据是否存在于一个集合中;
2、排序+去重;
3、求两个集合的交集、并集等;
4、操作系统中磁盘标记:比如inode bitmap、block bitmap等;
布隆过滤器
首先,通过上面对于位图的实现我们可以发现,在位图中判断一个数存不存在效率是真的很高,可是我们似乎只能用位图来判断整型数据的存不存在问题,对于字符串以及其他自定义类型,我们根本无法直接使用位图结构!
自定义类型无法直接使用位图的主要原因就是:自定义类型与位图编号之间无法建立起直接映射,可是要是我们借用一下哈希函数的思想,利用一个哈希函数将我们的自定义类型转换成整数,然后再根据哈希函数转换出来的整数来与位图建立间接映射呢?是不是自定义类型就能使用位图结构了?
理论上是可以的,但是既然我们都用哈希函数了,那么必然会存在哈希冲突啊,也就是说一定会有多个自定义类型对应同一个哈希地址,我们该如何减哈希冲突?
1、设计一个极其强悍的哈希函数,使其产生哈希冲突的概率极小;但是这往往都需要数学造诣颇高的大佬;
2、给一个自定义类型用多组哈希函数进行映射,那么一个自定义类型就会得到多组哈希地址!同时将这组哈希地址对应的比特位内容置1;
刚才我们不是说一个哈希函数所产生的哈希冲突比较高吗,那主要是因为一个哈希函数产生哈希冲突的成本太低,只需要一个哈希地址相等就会产生哈希冲突;那么我们就提升哈希冲突产生的成本呗!我们用多组哈希函数,来进行映射,就会得到多组哈希地址,那么此时想要产生哈希冲突就必须:两个不同的自定义类型经过多组哈希函数映射过后仍然存在相同的哈希地址!也就是说与原来只需要一个哈希地址相同相比,现在需要这一组中哈希地中的每一个哈希地址都要相同才会产生哈希冲突,这样的话产生哈希冲突的成本就上去了,哈希冲突产生的概率也就低下来了,这也就是布隆过滤器的原理了;
布隆过滤器的简单实现
相关操作讨论
插入操作:根据上面布隆过滤器的原理我们可以知道,布隆过滤器的插入就是,将一个自定义类型通过多个哈希函数映射,得到多组哈希地址,然后将这一组哈希地址对应的比特位置1:
查找操作: 对于布隆过滤器来说我们我们是怎么插入的就怎么查找就行了;
1、先根据多组哈希函数,映射出多组哈希地址;
2、判断这一组哈希地址是否全部置1;
3、如果是:则说明查找的元素存在,返货true;如果不是,则说明该组哈希地址还没有被自定义类型使用过,返回false;
但是,布隆过滤器的查找操作存在一定的误判率:
布隆过滤器告诉你:你要查找的值存在,那么请不要相信!
布隆过滤器告诉你:你要查找的值不存在,那么请务必相信!
接下来我们解释一下原因:前面我们说了,为什么布隆过滤器为什么要配多组哈希函数,其目的就是为了减小哈希冲突的产生!注意我说的是减少,并不是杜绝、避免等一系列肯定语气的词,换句话说就是尽管我们使用了多组哈希函数来进行映射,但是还是无法避免哈希冲突的产生!还是存在着,两个相同类型的不同对象,经过同一组哈希函数映射过后,依然存在着两组完全一摸一样的哈希地址!
举个简单例子:
这也就意味着,哈希地址为2、4、7的比特位内容分别会被置两次1,可是对于位图来说,无论我们将任意位置置多少次1,位图都只会记录一次,因此,当我们连续插入bilibili、Alibaba两个字符串时,2、4、7号比特位都会被置1,然后我们接着再进行查找Ailibaba字符串,嗯,找到了,得到了与预期相符合的条件!
可是如果,我们先插入bilibili字符串,然后再去查找Ailibaba字符串呢?
首先插入布隆过滤器的插入原则,再插入bilibili的时候2、4、7号比特位被置为了1,接着再根据布隆过滤器的查找原则,计算出Ailibaba字符串的哈希地址2、4、7,然后发现这3个比特位的内容都是1,嗯这说明要查找的值是存在的,于是布隆过滤器会给你返回true,实际上Alibaba字符串我们还并未插入进去,因此我们期望得到的结果是false,因此对于这种情况布隆过滤器是存在误判的!为此我们才说布隆过滤器告诉我们某一个值存在,那么一定要验证一下,避免被布隆过滤器误判!
接着我们来解释一下,为什么布隆过滤器说不存在,那么就一定不存在呢?
首先根据插入的原则,是将多组哈希函数映射出来多组哈希地址全部置1,注意是全部置1!因此一组被使用过的哈希地址其对应的内容一定全部是1,那么相反的,如果一组哈西地址中存在至少一个哈希地址不为1,那么说明还没有自定义对象,映射到过这一组哈希地址,也就说明到目前为止从来没有对象插入到过该组哈希地址,因此如果本次查找的对象刚好映射到这样一组哈希地址上,那么我们可以大胆的返回false!而布隆过滤器判断一个元素不存在的思想就是这样实现的,是符合事实的,为此布隆过滤器告诉我们一个元素不存在,那么我们一定要相信!删除操作: 布隆过滤器是不存在删除操作的!
为什么呢?其实与布隆过滤器的查早操作一样存在着误删的概率!
布隆过滤器的删除逻辑就是,先判断要删除的元素在不在?存在,才删除,而删除的具体做法就是将哈希地址对应的比特位全部置0;不存在,则删除失败!
可是由于有着哈希冲突的原因,这也就导致了我删除了一个对象,那么另一个与之拥有相同哈希地址的对象也会被删除,举个例子:bilibili、Alibaba字符串是两个拥有相同哈希地址的不同对象,现在我们删除Alibaba字符串,那么根据哈希函数算出,Alibaba字符串哈希地址为2、4、7,然后将2、4、7的内容全部置0:
可是这时候,我们再去查找bilibili字符串时,就会发现此次布隆过滤器告诉我们的结果是false,这显然不符合逻辑,明明我们只删除的Alibaba,bilibili字符串我们东都没动,怎么就找不到了?不应该返回true吗?这就是布隆过滤器不提供删除操作的原因,会存在误删啊!布隆过滤器的删除你以为是只删除你指定的哪一个对象,实际上是会将所有与你指定的对象拥有相同哈希地址的对象们都删除!也就是删除一片!
那么有没有什么办法让布隆过滤器支持删除操作呢?
答案是有的,就是不在让哈希地址对应的比特位的内容为0、1状态了,而是表示为一个引用计数,专门记录该哈希地址有多少个对象使用!
因此当我们插入一个元素时,我们只需要将该元素对应的哈希地址的引用计数+1即可;
我们想要查找一个元素时,就判断对应哈希地址处的引用计数是不是全部都是大于0即可,是,则说明存在;不存,则不存在!仍然存在误判!
要删除一个对象时:将其对应的哈希地址处的引用计数-1即可,可是任然存在着误判,比如上图的bilibili字符串,我们再删除bilibili字符串过后,2、4、7对应的引用计数就变为了:3\2\4,这时我们再去查找bilibili字符串的时候,会发现布隆过滤器告诉我们的结果是true的,这显然是不符合逻辑的,因为我们明明已经删除了bilibili字符串,理论上来说应该返回false的;为此,就算我们使用引用计数来删除操作,可是只要引用计数不为0,着删除了和没删除没什么区别!这个删除操作并没有什么作用!
布隆过滤器的删除操作大致是按照上面的思想来实现的,可是布隆过滤器,毕竟底层是用的位图啊,怎么能用来存计数呢?一个位图不行,那么我们就多搞几张位图嘛,每个位图都用来模拟不同的二进制位:
比如我现在有三张位图:
那么这3张位图组成的二进制范围不就是:000 ~ 111,这样的话,我们任意一个哈希地址所能表示的引用计数都是000 ~ 111;
比如:哈希地址为4的引用计数为3的表示方式就是:
如果我们想要扩大每个哈希地址所能引用的计数范围,我们只需要多开几张位图即可!
可是无论我们开多少张位图,只要位图的数目固定下来,那么每个哈希地址所能引用的范围也就确定了下来;
因此我们面对着一个问题,要是引用计数溢出了怎么办?
为此,针对这些问题,布隆过滤器是不提供删除操作的!
布隆过滤器的结构设计
上面我们讨论了布隆过滤器的插入、查找、删除操作,可是我们还是面临着两个问题:
1、哈希函数给多少个合适?
2、位图开多大合适?
针对这些问题,大佬们给出了险关的函数图和计算公式:
如何确定哈希函数的个数和位图的大小的计算公式:
这里我们可以选择3和哈希函数来实现布隆过滤器(当然具体写多少个,与具体应用场景有关),那么k=3,那么布隆过滤器的长度m就为:m=3×n÷ln2≈4×n;
为此我们布隆过滤器的设计结构如下:
template<size_t N,class K=std::string,class Hash1=BKDRHash,class Hash2=DJBHash,class Hash3=APHash>
class BloomFilter
{
public:
private:
bitset<4 * N> _bt;//
};
对于自定义类型如果是字符串的话,那么则默认用户可以使用我们自己提供的哈希函数来将字符串转换为整数,不用自己提供,为此,我们需要了解一些比较厉害的关于字符串的哈希函数:
字符串哈希函数:
这里我们采用了3个哈希冲突产生比较小的3个字符串哈希函数:
布隆过滤器插入
布隆过滤器查找
布隆过滤器总代码
#include"bitset.hpp"
using std::string;
namespace MySpace
{
struct BKDRHash
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 31;
}
return hash;
}
};
struct APHash
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (long i = 0; i < s.size(); i++)
{
size_t ch = s[i];
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ ch ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ ch ^ (hash >> 5)));
}
}
return hash;
}
};
struct DJBHash
{
size_t operator()(const string& s)
{
size_t hash = 5381;
for (auto ch : s)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
template<size_t N,class K=std::string,class Hash1=BKDRHash,class Hash2=DJBHash,class Hash3=APHash>
class BloomFilter
{
public:
BloomFilter& set(const K& key)
{
Hash1 hash1;
Hash2 hash2;
Hash3 hash3;
size_t Hashi1 = hash1(key) % (4 * N);
size_t Hashi2 = hash2(key) % (4 * N);
size_t Hashi3 = hash3(key) % (4 * N);
_bt.set(Hashi1);
_bt.set(Hashi2);
_bt.set(Hashi3);
}
bool test(const K& key)
{
Hash1 hash1;
Hash2 hash2;
Hash3 hash3;
size_t Hashi1 = hash1(key) % (4 * N);
size_t Hashi2 = hash2(key) % (4 * N);
size_t Hashi3 = hash3(key) % (4 * N);
return _bt.test(Hash1) && _bt.test(Hashi2) && _bt.test(Hashi3);
}
private:
bitset<4 * N> _bt;//λͼ
};
}
布隆过滤器优点和缺陷
布隆过滤器优点:
- 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关
- 哈希函数相互之间没有关系,方便硬件并行运算
- 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
- 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
- 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
- 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
布隆过滤器的缺陷:
- 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再
建立一个白名单,存储可能会误判的数据)- 不能获取元素本身
- 一般情况下不能从布隆过滤器中删除元素
- 如果采用计数方式删除,可能会存在计数溢出问题
海量数据面试题
哈希切割
给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?
与上题条件相同,如何找到top K的IP?如果数据量比较小的话,那么我们直接一股脑加载进内存处理就好了;可是显然100G的数据就是限制我们全部加载进内存处理的,我们需要将着100个G的数据进行分批处理,分成能够进入内存的单元进行处理,比如我们可以将100G个文件,分成均等的1024个小文件,那么每个小文件也就100M了,我们不能对100G的大文件处理,那么对于100M的小文件总归能加载进内存处理了吧!我们只需要求出每个小文件中的出现次数最多的IP地址,然后再在这1024个最大值中求出最终出现次数最多的IP地址,这个我们可以利用map/unordered_map来实现;可是这样求出来的最大值一定准确吗?
显然上述方法的思路是可行的,但是存在着一点小bug,那就是由于我们的文件是均等分的,那么也就意味着,每一个文件中都存在各种各样的字符串,重复的字符串可能并没有被分配到同一个文件中,因此每个文件中求出来IP地址的次数是不准确的!这样求出来的IP地址次数是只算了本小文件中出现次数最多的,至于其他文件中的重复的我们并没有计算进来,因此这就会导致我们每个小文件计算出来的最大IP地址有bug,为此我们需要优化我们切割文件的方法,我们需要把重复的IP地址都放到同一个文件中!为此我们可以借用哈希的思想,利用哈希函数的思想:相同的Key值经过相同的哈希函数拥有相同的哈希地址,我们把这个哈希地址当作文件名,那么这样的话,重复的Key值就会被分配到同一个文件中了:
这样的话,我们再对每个小文件求出的IP最大值就是这个IP地址的所有次数,那么我们再把这1024个最大值再取一次最大值,那么我们就可以得出这个文件中出现次数最多IP地址了;
这样的切割方式,叫做哈希切割!
通过对于文件中出现次数最多的k个IP地址,我们也可以对每个小文件先求topk,然后得到1024组topk,我们再对这1024组topk再求一次topk,我们就可以得到整个文件中的topkIP地址了;
上诉的切割方法固然哈啊,可是面临着一个极端情况:
要是哈希冲突十分严重呢?
这也就会导致某一个文件中的数据特别多!这样一来我们似乎又回到了起点!
当然,针对某个文件中数据特别多这种情况,我们又可以分为两个小情况:
①虽然某个文件中仍然存在大量的数据,但是都是一些重复的数据;
那么我们可以直接插入map/unordered_map,对于重复数据map/unordered_map是不会插入成功的!不会占用内存空间,我们可以放心大胆插!
②虽然某个文件中仍然存在大量的数据,但是都是一些不重复的数据;
那么这个如果再直接插入的话,势必会插入失败,也就是map再new节点的时候会new失败,new失败是会抛出异常的,我们可以捕捉这个异常;
综上所述,对于某个文件有很多大量的重复元素的情况,我们先直接插入,如果都插完了,都没有抛出异常,那么说明是情况①;如果插到一半,抛出异常那么说明是情况②,我们需要调整哈希函数,然后重新切割文件!2.给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出
精确算法和近似算法;
首先,我们可以对着两个文件都进行哈希切割:
那么我们只需要在A0与B0、A1与B1、……An与Bn文件中分别寻找交集即可,因为Ai与Bi文件中都放的是哈希地址一样的Key值,那么这两个文件中可能存在相同的Key;而对于Ai与Bj(i!=j)这两个文件,由于哈希地址不一样,那么肯定Key也就不一样,因此一定不存在交集,我们不必考虑这些文件!然后我们只需要将每个小文件的交集放入set/unordered_set中去,最后set/unordered_set中放的就是我们想要的交集!
位图应用
1.给定100亿个整数,设计算法找到只出现一次的整数?
这个与布隆过滤器的删除操作优点类似,可以用位图来模拟一个对应编号的引用计数:
为此我们需要两个位图,这两个位图所能表示的引用计数范围是:00~11;
因此,当我们插入一个元素,其引用计数为00时,我们许需要将低位图的对应比特位置即可,即引用计数最后变为01;
当我们插入一个元素,其引用计数为01,我们需要将引用计数变为10
当我们插入一个元素,其引用计数为10,我们不必做任何处理;
最后我们只需要遍历位图,找到引用计数为01的对应编号即可!2.位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整
数思路与上面一摸一样,只需要多增加一个状态即可,当插入一个元素的时候,其引用计数为10时,需要变为11;
当插入一个元素时,其引用计数为11时,不用变化;
最后我们在遍历位图的时候,只需要打印引用计数不为11的编号即可!3.给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
这里我们有两种方法:①将一个文件中的数据,先加载进一个位图中,这样这个位图就记录下了这个文件中存在哪些数据;接着我们再从另一个文件中读取数据去位图中寻找是否存在,如果存在则说明是交集,我们就可以记录下来了,同时将该比特位置0,避免最后得到的交集中存在重复元素!;如果不存在,则说明该值不是交集,继续向后读取;
②将两个文件中的数据分别进入两个不同的位图,这样的话,位图A就记录着A文件中哪些元素存在,位图B就记录着B文件中那些元素存在,交集嘛,就是在A位图中有,在B位图中有,那么就是A位图和B位图按位与的结果,我们需要将A位图与B位图按位与的结果存于位图C中,最后遍历位图C,其内容为有效的编号既是A文件与B文件的交集!
布隆过滤器
如何扩展BloomFilter使得它支持删除元素的操作;
上面已经讲过了,不在重复啦!