我们先来看一道题:
给定40亿个不重复的正整数,如何快速判断一个数组是否在这40亿个数中。
以我们目前的思维,我们肯定是只能想到以下几种方法:
一 直接遍历,时间复杂度O(n)
二 二分查找,时间夫复杂度O(nlogn)
三 放入某种数据结构,如红黑树等等,时间复杂度最好O(logn)
但是无论是哪种方法,对空间的需求都是极大的, 40亿个整数 ≈ 14个g的内存 ,像我们平常电脑内存也就是16个 g或者说 32个g的内存,并且还有很多小内存电脑,可能很难应对如此大的空间需求。那么此时就可以引入位图。
实际上,位图的本质也是一种数据结构。
其作用是通过每一个bit位来标识每一种状态(其实也是哈希思想的一种体现),适合存储海量的数据,整数,数据无重复的场景, 一般是用来判断某一个数据是否存在的。
比如上面那个例子,如果用每一个bit位来表示,一个整数原本是四个字节,总共需要4∗4000000000/1024/1024/1024,大约是占用了15G的内存,
而如果使用一个比特位标记这40亿个正整数存在不存在,4000000000*4/32/1024/1024,大约就是480M的内存。这个内存占用直接降低了一个数量级。
一个数据是否存在也就两种状态,在或者不在,通过二进制的0和1就可以表示。位图正是利用了这个特性,因为用一个bit位恰好可以表示这两种状态。
但因为我们在计算机语言中,无法直接拿取一个bit位,因此我们需要经过一些特殊的转换,进而拿到一个bit位。
那么我们可以猜想,实际上位图的底层就是一个数组,但是我们需要通过一些特殊的转换,拿到数组里面的每一个bit位。
这里我们建议底层使用 char 数组,因为int类型会存在某些大小端问题,但是char 数组不会存在。
namespace BitMap {
template
class BitMap {
private:
vector _tables;
};
}
- 使用非类型模板参数,该参数用来指定位图比特位的个数。
- 底层使用的是vector,vector中是char类型变量。
那么,我们该如何从一个char数组里面,准确的取到一个bit位呢?
假设我们要插入一个9 我们要先确定它在数组中哪个char,然后再确定它在char 中哪一个位置。
我们可以发现 ,当9/8 时,我们能得到 9应该存在数组中的哪个位置,当9%8时,我们就可以得到9 在 char数组中的某个位置。
因此,我们得出:
- 求x在数组中的位置 :x/8
- 求x在char中的位置:x%8
我们都知道,位图采用了非类型模板参数,传入的N就代表了位图的大小,那么我们在初始化时,就应该规定好位图的大小。
根据位图的映射思想,求出N在数组中的位置,也就是数组的大小,但我们需要把N+1 ,因为C++的除法思想,除数会小一个,因此,为了保险,需要把结果+1.
namespace BitMap {
template
class BitMap {
public:
BitMap() {
//初始化为0 代表没有这个元素
_tables.resize((N >> 3) + 1, 0);
}
private:
vector _tables;
};
}
位图的set函数及其简单,我们根据位图的映射思想求出插入的数字需要映射到哪个位置,然后根据位运算直接将其映射到位图中。
void set(size_t x)
{
//size_t i = x / 8;//映射到第几个char中
size_t i = x >> 3;
size_t j = x % 8;//映射到char中第几个比特位
//将其映射到位图中
_tables[i] |= (1 << j);
}
示例,当我们插入9时,我们通过i求出它在哪一个 char中,然后通过j求出它在这个char中的哪一个比特位,同时按照由低到高的顺序,利用左移操作符(将一向高位移动),异或一下当前位置的值。
从而把当前位置的0异或为1,我们不考虑重复问题(假设没有重复数字)
位图的reset(删除函数),基本思想上与set函数一致,但注意,这里不可以再使用异或操作符,而是采用在位运算中的清0操作。
//清零
void reset(size_t x)
{
size_t i = x >> 3;
size_t j = x % 8;
//将比特位清0
_tables[i] &= (~(1 << j));
}
查找是否存在,这里和reset函数的思想基本一致。
//查找是否存在
bool test(size_t x)
{
//映射到位图中的位置
size_t i = x >> 3;
size_t j = x % 8;
return _tables[i] & (1 << j);
}
注意:这里的返回值涉及了一个整形提升问题,一个bool 是四个字节,从而产生了整形提升,故没有影响。
template
class BitMap {
public:
BitMap() {
//初始化为0 代表没有这个元素
_tables.resize((N >> 3) + 1, 0);
}
//置一
void set(size_t x)
{
//size_t i = x / 8;//映射到第几个char中
size_t i = x >> 3;
size_t j = x % 8;//映射到char中第几个比特位
//将其映射到位图中
_tables[i] |= (1 << j);
}
//清零
void reset(size_t x)
{
size_t i = x >> 3;
size_t j = x % 8;
//将比特位清0
_tables[i] &= (~(1 << j));
}
//查找是否存在
bool test(size_t x)
{
//映射到位图中的位置
size_t i = x >> 3;
size_t j = x % 8;
return _tables[i] & (1 << j);
}
private:
vector _tables;
};
void test1() {
BitMap<10000> bt;
bt.set(1);
bt.set(7);
bt.set(100);
bt.set(2);
cout << bt.test(1) << endl;
cout << bt.test(7) << endl;
cout << bt.test(100) << endl;
cout << bt.test(2) << endl;
cout << endl << endl;
bt.reset(1);
bt.reset(7);
bt.reset(100);
cout << bt.test(1) << endl;
cout << bt.test(7) << endl;
cout << bt.test(100) << endl;
cout << bt.test(2) << endl;
}
当然,除了我们写的这三个函数之外,位图还有很多函数,具体可以看一下库里面的函数:
https://cplusplus.com/reference/bitset/
- 问题一:给定100亿个整数,设计算法找到只出现一次的整数?
首先这个问题,以往我们学过的数据结构和算法都不行,只能用位图。
但是我们也提到了,单个位图只能用来表示数字存在的问题,那么我们该如何做呢?
两个bit位能表示多少东西呢?0,1,2 显然是三个状态,因此,这个问题我们可以用两个位图来解决。
完整代码::
#pragma once
#include"BitMap.h"
using namespace bitMap;
namespace dBitMap {
template
class DBitMap {
public:
//因为由两个位图,所以我们可以有三种状态
// 不存在这个数时,两个位图中的这个位置都为0
// 存在一次 bit1 为0 bit2 为1 也就是01
// 存在两次 bit1 为1 bit2 为0 10
// 其可以不用管 三次 但我们因为可以表示三种状态 因此 11为 三次
// 三次以上 不用管,和本题无关,如果有要求,底层再加位图
void set(size_t x){
//两个都不存在 变为01
if (!bit1.test(x) && !bit2.test(x)) {
bit2.set(x);
}
//01 变为 10
else if (!bit1.test(x) && bit2.test(x)) {
bit1.set(x);
bit2.reset(x);
}
//10 变成11
else if (bit1.test(x) && !bit2.test(x)) {
bit2.set(x);
}
//其它情况不处理
}
//这里我们设为一次只删除一个
void reset(size_t x){
//这里我们设为一次只删除一个
//01 变为 00
if (!bit1.test(x) && bit2.test(x)) {
bit2.reset(x);
}
//10 变成01
else if (bit1.test(x) && !bit2.test(x)) {
bit2.set(x);
bit1.reset(x);
}
//11 变为10
else {
bit2.reset(x);
}
//想要一次全删除直接
/* bit1.reset(x);
bit2.reset(x);*/
}
//查找是否存在
bool test(size_t x){
//也是分三种情况
if (bit1.test(x) && bit2.test(x)) {
cout << "出现三次" << endl;
}
else if (bit1.test(x) && !bit2.test(x)) {
cout << "出现两次" << endl;
}
else if (!bit1.test(x) && bit2.test(x)) {
cout << "出现一次" << endl;
return true;
}
else {
cout << "未出现" << endl;
}
return false;
}
private:
BitMap bit1;
BitMap bit2;
};
void test1() {
DBitMap<100> DB;
DB.set(1);
DB.set(2);
DB.set(3);
DB.set(4);
DB.set(5);
DB.set(6);
DB.set(1);
DB.test(1);
DB.test(2);
DB.test(3);
DB.test(4);
}
}
代码结果:
优点:节省空间,效率高。
缺点:一般要求数据相对集中,否则会导致空间消耗上升。
位图的一个致命缺点:只能针对整形。
如果要位图存储整形之外的话,那么就必须像哈希函数一样,经过转化,但就拿字符串来说吧,即使经过转化,那么也可能存在不同的值,转化后变为相同的值这种情况,位图用一个bit位来标识这种情况,显然,更容易出现问题。
这时候就需要布隆过滤器了。
布隆过滤器实际上就是位图的一种进阶形式,也是哈希思想的一种体现,其主要作用是检查字符串是否出现过。
在布隆过滤器中,我们将字符串通过哈希函数转化为整形,然后插入到布隆过滤器中。
但是布隆过滤器也无法全部解决字符串误判问题,因为字符串实在是太大太大,太多太多了。
但是有以下两种情况:
位图中存在:不一定真正存在。
因为可能有误判,那么他就不一定真正存在,针对这种情况,我们需要再进行详细的查找啊。位图不存在:必然不存在。
位图中本来就应该插入的位置没有元素,那就也没有其它误判的字符串,自己的字符串也没有,故肯定没有。
所以根据位图判断出的结构,不存在是准确的,存在是不准确的。
有没有办法能提高一下判断的准确率呢?答案是有的,布隆过滤器就可以降低误判率,提高准确率。
布隆过滤器相比于位图有一个很重要的方法,它用多个哈希函数,将一个数据映射到位图结构中。
也就是说,我们不在单纯的用一个bit位标识这个数据是否存在,而是通过多个哈希函数,用多个bit位来标识这个数据是否存在。
只有一个字符串在位图中的几个比特位同时为1才能说明该字符串存在。
借用一下图片。
但是此时只能减少误判,依旧不能避免误判。
对数据不需要太准确的场景,比如注册昵称时的存在判断。
如上图中,一个昵称的数据库是放在服务器中的,这个数据库中昵称的存在情况都放在了布隆过滤器中,当从客户端注册新的昵称时,可以通过布隆过滤器快速判断新昵称是否存在。
这里的话,只要数据库中没有昵称,我们就可以创建,而布隆过滤器中,“没有”的判断场景是绝对正确的,因此,布隆过滤器完美适配这种情况,至于用户有没有取到好名字,那就不是我们用来关心的了。
现在知道布隆过滤器是什么了,但是我们到底该创建多少个比特位的位图(布隆过滤器长度),又应该使用多少个哈希函数来映射同一个字符串呢?
如何选择哈希函数个数和布隆过滤器长度一文中,对这个问题做了详细的研究和论证:
前面不管,后面不管,得出一个关系公式。
- m:表示布隆过滤器长度。
- k:表示哈希函数个数。
- n:表示插入的元素个数。
- 其中:ln2约等于0.69。
首先需要写几个哈希函数来将字符串转换成整形,各种字符串Hash函数一文中,介绍了多种字符串转换成整数的哈希函数,并且根据冲突概率进行了性能比较。
这里我们选择四个哈希函数:
struct BKDRHash
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto ch : key)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
struct APHash
{
size_t operator()(const string& key)
{
unsigned int hash = 0;
int i = 0;
for (auto ch : key)
{
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ (ch) ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ (ch) ^ (hash >> 5)));
}
++i;
}
return hash;
}
};
struct DJBHash
{
size_t operator()(const string& key)
{
unsigned int hash = 5381;
for (auto ch : key)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
struct JSHash
{
size_t operator()(const string& s)
{
size_t hash = 1315423911;
for (auto ch : s)
{
hash ^= ((hash << 5) + ch + (hash >> 2));
}
return hash;
}
};
namespace BloomFliter
{
template
class BloomFilter
{
public:
private:
std::bitset _bs;
};
}
该模板有多个参数,但是大部分都是使用的缺省值,不用必须去传参,底层使用的STL库中的bitset。
size_t N:最多存储的数据个数。
size_t X:平均存储一个值,需要开辟X个位,这个值是根据上面的公式算出来的,此时哈希函数是4个,所以m = 4n/ln2 = 5.8n,取整后X位6,这里先给个缺省值是6。
class K:布隆过滤器处理的数据类型,默认情况下是string,也可以是其他类型。
哈希函数:将字符串或者其他类型转换成整形进行映射,给的缺省值是将字符串转换成整形的仿函数。
void set(const K& key) {
//映射并插入四个位置
size_t hashi1 = HashFunc1()(key) % (N * X);
size_t hashi2 = HashFunc2()(key) % (N * X);
size_t hashi3 = HashFunc3()(key) % (N * X);
size_t hashi4 = HashFunc4()(key) % (N * X);
_bset.set(hashi1);
_bset.set(hashi2);
_bset.set(hashi3);
_bset.set(hashi4);
}
bool test(const K& key) {
//只要有一个位置不存在,就必定不存在,并且是准确的
size_t hashi1 = HashFunc1()(key) % (N * X);
if (!_bset.test(hashi1)){
return false;
}
size_t hashi2 = HashFunc2()(key) % (N * X);
if (!_bset.test(hashi2)) {
return false;
}
size_t hashi3 = HashFunc3()(key) % (N * X);
if (!_bset.test(hashi3)) {
return false;
}
size_t hashi4 = HashFunc4()(key) % (N * X);
if (!_bset.test(hashi4) ){
return false;
}
return true;
}
void TestBF1()
{
BloomFilter<100> bf;
bf.set("猪八戒");
bf.set("沙悟净");
bf.set("孙悟空");
bf.set("二郎神");
cout << bf.test("猪八戒") << endl;
cout << bf.test("沙悟净") << endl;
cout << bf.test("孙悟空") << endl;
cout << bf.test("二郎神") << endl;
cout << bf.test("二郎神1") << endl;
cout << bf.test("二郎神2") << endl;
cout << bf.test("二郎神 ") << endl;
cout << bf.test("太白晶星") << endl;
}
首先,布隆过滤器不能删除元素,因为可能会连带其它元素被删除
缺点:
- 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)。
- 不能获取数据本身。
- 一般情况下不能从布隆过滤器中删除元素。
- 如果采用计数方式删除,可能会存在计数回绕问题。
优点:
增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关。
哈希函数相互之间没有关系,方便硬件并行运算。
布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势。
在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势。
数据量很大时,布隆过滤器可以表示全集,其他数据结构不能。
使用同一组散列函数的布隆过滤器可以进行交、并、差运算。