所谓位图就是用每一位来存放某种状态,适用于海量的数据,数据无重复的场景。通常用来判断某个数据在或者不在的情况。
给40亿个不重复的无符号整数,没有排过序。给一个无符号整数,如何快速判断这一个整数是否在这40个亿之中?
如果数据量很小,比如不是40亿个而是40个,就可以采用直接遍历全部数据,进行查找的方式;也可以排序然后使用二分查找;或者用unordered_set容器进行查找。
但问题在于,40亿个无符号整数的总大小为40 * 4=160亿字节,而1G的空间是1024 * 1024 * 1024=1073741824字节≈10亿字节,也就是说160亿字节完全存储需要16G。在一般的电脑上面,是行不通的,因此我们可以用到位图的思想。
位图解决:
用40亿个比特位来标记所有的数字,1表示存在,0表示不存在。
无符号整数总共有232个,因此记录这些数字就需要232个比特位,也就是 232位 = 4G/8= 512M的内存空间,相比之下大大的节省了空间的消耗
优点:速度快,并且节省空间
缺点:只可以映射整形(如果有负数则开两个图,取反,映射到另外一个图)
在定义位图时,需要传入位图的位的个数,也就是能表示的最大数据,比如传入16,则表示该位图有16位,可以表示数字0-15是否在位图中。
方式一: 构造一个16位的位图,所有位都初始化为0。
bitset<16> bs1; //0000000000000000
方式二: 构造一个16位的位图,根据所给值初始化位图的二进制位。
bitset<16> bs2(177); //构造一个16位的bitset对象,将177转换为二进制,拷贝到内存空间
方式三: 构造一个16位的位图,根据字符串中的0/1序列初始化位图的前n位。
bitset<16> bs3("1110011"); //将二进制字符串初始化到对象中
在定义时需要注意以下几点
成员函数 | 功能 |
---|---|
set | 设置指定位为1,如果不传参数则将所有位设为1 |
reset | 清空指定位或所有位 |
flip | 反转指定位或所有位 |
test | 测试某一位是否被置为1 |
count | 统计bitset里面1的位数 |
size | 获取bitset总的位数 |
any | 测试是否至少有1位被置为1(至少一个1,则返回true,否则返回false) |
none | 测试是否没有一个被置为1(都是0,则返回true,否则false) |
all | 测试是否都是1(都是1,返回ture,否则返回false) |
to_string() | 以二进制字符串形式输出,将所有二进制位输出 |
to_ulong() | 转换为unsigned long整数,然后输出 |
bitset容器对>>、<<运算符进行了重载,我们可以直接使用>>、<<运算符对biset容器定义出来的对象进行输入输出操作。
同时赋值运算符:=,关系运算符:==、!=,复合赋值运算符:&=、|=、^=、<<=、>>=,单目运算符:~,位运算符&、|、 ^ 都进行了重载,可以对位图进行操作,其用法和操作整形的二进制位相同。
[ ]运算符也进行了重载,可以直接使用[ ]对指定位进行访问或修改。
namespace hjl
{
template<size_t N>//非类型模板参数
class bitset
{
public:
bitset()
{
//要多开一个整形,否则会不够,因为N/32是向下取整
_bits.resize(N/32+1, 0);
}
//标记
void set(size_t x)
{
assert(x < N);
size_t index = x /32;//算出x映射的位在第几个整形
size_t pos = x % 32;//算出x在这个整形中第几个位;
_bits[index] |= (1 << pos);//将第index个整形中第pos个位置成1
}
//取消标记
void reset(size_t x)
{
assert(x < N);
size_t index = x / 32;//算出x映射的位在第几个整形
size_t pos = x % 32;//算出x在这个整形中第几个位;
_bits[index] &= ~(1 << pos);//将第index个整形中第pos个位置置成0
}
//反转位
void flip(size_t x)
{
assert(x < N);
//算出pos映射的位在第i个整数的第j个位
int index = x / 32;
int pos = x % 32;
_bits[index] ^= (1 << pos); //将该进行反转(不影响其他位)
}
//查找在不在
bool test(size_t x)
{
assert(x < N);
size_t index = x / 32;
size_t pos = x % 32;
return _bits[index] & (1 << pos);
}
//获取被设置位的个数
size_t count()
{
size_t count = 0;
//将每个整数中1的个数累加起来
for (auto e : _bits)
{
int num = e;
//计算整数num中1的个数
while (num)
{
num = num & (num - 1);
count++;
}
}
return count; //位图中1的个数,即被设置位的个数
}
//测试是否至少有1位被置为1(至少一个1,则返回true,否则返回false)
bool any()
{
//遍历每个整数
for (auto e : _bits)
{
if (e != 0) //该整数中有位被设置
return true;
}
return false; //全部整数都是0,则没有位被设置过
}
//判断是否没有一个被置为1(都是0,则返回true,否则false)
bool none()
{
return !any();
}
//测试是否都是1(都是1,返回ture,否则返回false)
bool all()
{
size_t n = _bits.size();
//先检查前n-1个整数
for (size_t i = 0; i < n - 1; i++)
{
if (~_bits[i] != 0) //取反后不为全0,说明取反前不为全1
return false;
}
//再检查最后一个整数的前N%32位
for (size_t j = 0; j < N % 32; j++)
{
if ((_bits[n - 1] & (1 << j)) == 0) //该位未被设置
return false;
}
return true;
}
private:
vector<int> _bits;
};
}
我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那些已经存在的记录。 如何快速查找呢?
布隆过滤器是由布隆在1970年提出来的,它的特点是比较高效的告诉你,某样东西一定不存在或者可能存在,它采用的方法是,用多个哈希函数,将一个数据映射到位图之中,这种方式不仅可以提高查询效率,还可以节省大量的空间。
布隆过滤器中一个值通过多个哈希函数,在位图中有多个映射位置,即使一个位置发生冲突了,还有另外的映射的值,降低了冲突的概率。由于映射多个位置,因此可能不同的值,处于同一个位置,虽然不能保证这个值一定存在,但是可以保证一个值一定不存在,因为只要有一个映射的位置为0,就说明该值不存在。
布隆过滤器通常应用在允许误判的场景之中
黑名单校验
发现存在黑名单中的,就执行特定操作。比如:识别垃圾邮件,只要是邮箱在黑名单中的邮件,就识别为垃圾邮件。假设黑名单的数量是数以亿计的,存放起来就是非常耗费存储空间的,布隆过滤器则是一个较好的解决方案。把所有黑名单都放在布隆过滤器中,再收到邮件时,判断邮件地址是否在布隆过滤器中即可。
身份验证:
大门口的身份验证,如果不是小区里面的人,直接就拒绝进入(不在是确定的),如果通过了布隆过滤器的判断,再去数据库中对比一次,这样通过一层布隆过滤器可以提高这个查找系统的效率
2.插入
将每个哈希函数映射的位置都置为1
3.查找
所有的哈希函数映射的位置之中,只要有一个映射的位置为0,即当前值不存在,因为在插入的时候,所有的位置都设置为了1(所以不存在是准确的),否则表示存在(不准确,可能发生哈希冲突,是其它值映射的)
4.删除
布隆过滤器不支持删除工作, 因为不确定当前位置,是自己的,还是发生了哈希冲突其它的值映射过来的。
一种支持删除的方法:
将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作。
但是这种方法也不好,因为计数器的大小不易确定,如果给小了,发生冲突会导致溢出(计数回绕,最大值-> 最小值)。如果给大了,浪费空间,脱离了布隆过滤器的本质思想。 所以一般的布隆过滤器不支持删除操作。
优点:
缺点:
与位图不同,是库函数中是没有布隆过滤器的,需要手动实现。
namespace hjl
{
class bitset
{
public:
bitset(size_t N)
{
//要多开一个整形,否则会不够,因为N/32是向下取整
_bits.resize(N / 32 + 1, 0);
}
//标记
void set(size_t x)
{
size_t index = x / 32;//算出x映射的位在第几个整形
size_t pos = x % 32;//算出x在这个整形中第几个位;
_bits[index] |= (1 << pos);//将第index个整形中第pos个位置成1
}
//取消标记
void reset(size_t x)
{
size_t index = x / 32;//算出x映射的位在第几个整形
size_t pos = x % 32;//算出x在这个整形中第几个位;
_bits[index] &= ~(1 << pos);//将第index个整形中第pos个位置置成0
}
//查找在不在
bool test(size_t x)
{
size_t index = x / 32;
size_t pos = x % 32;
return _bits[index] & (1 << pos);
}
private:
vector<int> _bits;
};
struct HashStr1
{
size_t operator()(const std::string& str)
{
size_t num = 0;
for (auto& e : str)
{
num = num * 131 + e;
}
return num;
}
};
struct HashStr2
{
size_t operator()(const string& str)
{
size_t num = 0;
for (auto& e : str)
{
num = num * 65699 + e;
}
return num;
}
};
struct HashStr3
{
size_t operator()(const std::string& str)
{
size_t num = 0;
for (auto& e : str)
{
num = num * 7642 + e;
}
return num;
}
};
template<class K, class Hash1= HashStr1, class Hash2= HashStr2, class Hash3=HashStr3 >//给定三个哈希函数
class BloomFilter
{
public:
BloomFilter(size_t num)
//m(开的比特位数量) = k(哈希函数个数)*n(数据量)/ln2(0.7)
:_count(5 * num)
, _bitset(_count)
{}
void set(const K& key)
{
//获得哈希地址
size_t pos1 = Hash1()(key);
size_t pos2 = Hash2()(key);
size_t pos3 = Hash3()(key);
//将三个位置都设置为1
_bitset.set(pos1 % _count);
_bitset.set(pos2 % _count);
_bitset.set(pos3 % _count);
}
bool test(const K& key)
{
//有一个为0,就是不存在的
size_t pos1 = Hash1()(key);
if (!_bitset.test(pos1 % _count))
return false;
size_t pos2 = Hash2()(key);
if (!_bitset.test(pos2 % _count))
return false;
size_t pos3 = Hash3()(key);
if (!_bitset.test(pos3 % _count))
return false;
//布隆过滤器判断在时不准确的,可能会存在误判
// 判断不在是准确的
//都为1,则true,不一定正确
return true;
}
private:
size_t _count;
bitset _bitset;
};
}
分析:如果数据量很小,要统计次数,一般用kv模型的map就能解决。但是这里的问题是有100G数据,数据量太大放不进内存中。
此时采用哈希切割的方法,先创建1000个小文件A0-A999,读取IP计算出i=hashstr(IP)%1000,i是多少,IP就进入对应编号的Ai小文件。这样相同的IP一定进入了同一个小文件。然后使用map
读取Ai中的IP统计出次数,一个文件读完,clear清空map,然后再读另一个文件,使用pair
记录出现次数最多的IP即可。
判断一个值在不在,只需要两种状态,所以只使用一个位就可以。但是这里要找出值出现一次的数,其中有出现0次,出现2次及以上,此时有三种状态,说每个值使用两个位来表示就可以,出现0次用00表示,出现一次用01表示,出现两次及以上用10表示。然后遍历找出所有值为01的整数。
方法一:将其中一个文件1的整数映射到一个位图中,读取另外一个文件2中的整数,判断在不在位图中,在就是交集。此时消耗的内存是整数的范围232,也就是512M。
方法二:将文件1的整数映射到位图1中,将文件2的整数映射到位图2中,然后将两个位图中的数按位与,按位与之后为1的位就是交集,消耗内存1G。
与第二题类似,用两个位图进行存储,(0,0)表示出现0次,(1,0)表示出现1次,(0,1)表示出现两次,(1,1)表示出现多次
假设平均一个query是30-60字节,100亿个query大约占用300G-600G。
近似算法:将文件1中的query映射到一个布隆过滤器中,读取文件2中的query,判断在不在布隆过滤器中,在就是交集。
该方案存在缺陷:因为布隆过滤器判断不在是准确的,判断在是不准确的,可能存在误判。所以不会存在交集漏掉的情况,但是会导致交集中有不准确的数据。
分析思路:
这两个文件都非常大,也没有合适的数据结构能直接精确的找出交集,文件很大不能都放到内存中,那么我们可以把文件切分成多个小文件,将小文件的数据加载到内存中。该文件有300G-600G,此时切1000份,一个小文件为300M-600M,1G的内存可以搞定。
如果是平均切分,那么A0可以放到内存中存储到一个set中,然后用B0-B999与A0进行比较,接着A1放到内存中存储到set中,以此类推。可以看到这里的优势就是比较的过程放到内存中,且不是暴力比较,因为小文件Ax的数据是在set中,比较效率会高一些。但是这里需要不断的互相比较。
精确算法:哈希切割,不再平均切分,而是i=hashstr(query)%1000,i是多少,query就进入第Ai/Bi的小文件中,那么只需要比较Ai和Bi两个文件即可。因为两个文件的交集经过哈希算法以后,得到的i一定是相同,这样它们都会进入Ai和Bi。
但是哈希切割会出现一种情况,就是某个文件太大超过1G,这种情况可以考虑换个哈希算法,再切分一次。
每个位标记成计数器,那么到底用几个为来表示计数器呢?给的位如果少了,那么多个值映射一个位置就会导致计数器溢出,比如1字节最多计数到256,假设有260个值都映射到一个位置,就会出问题。但是如果使用更多的位映射一个位置,那么空间消耗就大了。不符合布隆过滤器节省空间的特点。
上面的一致性哈西解决了数据迁移问题,问题是哪台服务器映射哪些范围,如何保证他们映射的范围是均分的呢?
32位无符号整数的范围是0~4294967295(43亿),现在正好有一个包含40亿个无符号整数的文件。找到一个没出现过的数。
如果不限制内存或者内存限制为1G,只需要使用位图就能够解决(表示43亿需要232/8的字节,小于1G)。
但是这里限制内存为3KB。
使用分段统计的思想。
3KB最多能够申请长度为512的整形数组arr。我们可以把232分成512份,每份的长度为8388608,用数组统计落在该范围的长度,比如arr[0]表示0~8388608
的数出现的次数,arr[1]表示8388609~8388609+8388608
出现的次数。遍历40亿个数并统计,但是我们现在只有40亿个数,这就会导致arr中某个下标对应的数不够8388608,这就说明,这个区间有没有出现的数,假设为arr[1],然后我们就只需要将8388609~8388609+8388608
这个小范围分成512分,重复上面的统计。
不断重复上述操作,就能够找出一个没出现过的数。
条件和上个题类似。
此时可以采用二分策略,用L为0,R为232-1,mid为(L+R)/2,遍历40亿个数,然后我们统计L~mid
和mid~R
范围数出现的次数。必然有一侧次数是少于231的,然后我们继续采用二分的方式寻找这一侧。
32位无符号整数的范围是0~4294967295(43亿)。
和第9题类似,仍然使用分段统计的思想。继续分成512份。
一共40亿个数,我们需要找第20亿个数,假设arr[0]的值为1亿,arr[0]的值为5亿,arr[1]的值为15亿,因此中位数一定在arr[1]所对应的区间,所以我们只需要找arr[1]所对应的区间中,第14亿个数。接着再把arr[1]对应的范围分成512分,找第14一个数。
不断重复上述的操作。
我们不需要5G空间,假设空间只够存3条记录(数+对应出现的次数),采用大根堆的策略,先遍历一遍文件,找到最小的3个数以及它们出现的次数并写回文件,找到最大值,假设这3个数中最大的值为11,然后清空记录,继续采用大根堆的方式,遍历文件,找大于11并且最小的三个数写回文件,找到最大值,不断重复上述操作。
和第一个题类似,采用分文件的思想,找出每个文件中字符串出现次数最多的前100名,然后把每个文件的前100名排序,找排序后的前100名即可。
也可以将每个文件中出现次数最多的前100名组成大根堆h1,h2,h3…hn,再把每个文件的大根堆的堆顶组成大根堆H,然后弹出H的堆顶,假设堆顶的数据来自h3,将h3堆顶的数据弹出,重新调整堆之后,将h3的堆顶的数据插入回H。
不断重复上述操作,知道凑够前100名。
资料参考:
哈希的应用
哈希(Hash)与加密(Encrypt)的基本原理、区别及工程应用