一:位图
1.1:位图的概念
1.2:位图的实现
1.3:位图的应用与优缺点
二:布隆过滤器
2.1:布隆过滤器的概念
2.2:布隆过滤器的实现
2.3:布隆过滤器的应用与优缺点
三:海量数据处理(哈希切割实现)
四;总结
//////
一:位图
问题:给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。
答:根据我们现有的知识,要寻找一个数,可以将其先排成有序再进行二分查找,或者利用哈希思想进行映射或者全部添加到红黑树中;
即:排序+二分查找
哈希表
红黑树
我们再算一算40亿个不重复的无符号整形占据多大的内存空间:1G=1024MB=1024*1024KB=1024*1024*1024B=10亿字节,即1G空间是10亿字节,那40亿整数也就是40*4=160亿字节,160亿/10亿=16G,就是约占16G内存!
那么此时排序+二分查找就没有用了,因为数据占了16G内存,想要排序只能往文件里面放了,然后只能归并排序,同时在文件里面我们不能使用二分查找(无法通过下标访问数据),所以这样方法不可行!
而哈希表与红黑树通过我们之前的学习,我们知道,红黑树里面有三叉链的指针,哈希表里面有指向下一个的指针,本身数据已经占16G了,再加上这些指针,会让数据变的越来越大,得不偿失!这两种方法也不可行!
所以我们该采用什么样的方法解决这个问题呢?
1.1:位图的概念:
既然上面的方法都不可行,我们直接通过哈希表的直接定址法将数据直接定址,毕竟整数的最大值2^32即42亿多,足够包含住这40亿个数,但是42亿个整形占用内存还是太大,既然只是表示某个数存不存在,那么要么存在,要么不存在,所以我们可以使用一个比特位的方式来定义一个数是否存在--> 0(不存在)/1(存在);
那么通过一个比特位来表示一个数,也就是以前表示一个数(4个字节-->32个比特位),现在可以表示32个数存不存在了,直接将空间占用大大缩小,此时40亿个整数只需要:2^32个比特位=2^29个字节=2^19KB=2^9MB=512MB;那么此时我们只需要512MB的空间就可以解决这个问题,比16GB大大优化了!!!
所以那么位图是什么呢:所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的;
////
1.2:位图的实现:
我们知道。位图是通过比特位来表示,但是我们语言层面上并没有表示位的数组或者数据结构,
所以我们2^32个比特位要么通过2^27个int类型的整形数组来表示,要么通过2^29个char类型的字符数组来表示,然后通过位运算来操作!但是使用整形数组还是字符数组呢?下面给一个示例体会一下:
这是以char为类型的字符数组来实现的位图,因为char类型在内存中占1个字节(8个比特位),所以我们在使用时是以一个字节为单位申请的,用多少申请多少,
在数据比较集中的情况下,最多浪费8个以内的比特位;
同时,比特位的位置是左高右低,符合我们平常对二进制的认识,比如3的二进制是11,6的二进制是110等等!这样的话,我们只有三个char类型,24个比特位就表示了数组array的所有值!
而如果以int为类型的整形数组来实现位图,一次申请就是4字节(32个比特位),那怕只使用一位比特位,都要申请到4个字节,而且int整形在内存中存储的时候还有大小端的问题,即:
由于大小端的问题,就可能导致原本标识在低地址处的位图情况放到了高地址处,
所以,在位图中,我们采用char类型数组就不用考虑大小端的问题,因为一个char类型就是一个字节,每个char都是从低地址到高地址排列!!!
但是其实位图是一种哈希映射关系,我们大致了解一下内存中位置的分布即可,我们真正应该关心的是映射的位置!!!
下面给出位图的大致结构:
template <class N>//非类型的模版参数,表明要开的比特位的范围
class bitset
{
public:
bitset()//构造函数
{
_bits.resize(N / 8 + 1, 0);//为什么是N/8+1?---因为如果是64个数,开64/8=8个char没有问题,但是当是65,66,67,个数呢?/8的时候就会有小数,所以在初始化的时候需要向上取整!
}
private:
vector<char> _bits;//创建一个char类型的数组
};
下面介绍位图一些接口的实现,set(size_t x) (将位图中的某一位置1), reset(size_t x)(将位图中的某一位置0), test(size_t x)(查找要找的哪一位是1还是0);
set(size_t x):
void set(size_t x)
{
size_t i = x / 8;//找到在数组中第几个char里面
size_t j = x % 8;//找到在这个char中第几个比特位上;
_bits[i] |= (1 << j);//先将1向左移(高位)移动j位,然后相或,有1则为1;
}
set接口的作用就在于将指定的x所映射的位置置1;
1:先找到映射的位置; 2:将映射的位置置1, 下面给出一些示例证明一下:
reset(size_t x):
void reset(size_t x)
{
size_t i = x / 8;//找到在数组中第几个char里面
size_t j = x % 8;//找到在这个char中第几个比特位上;
_bits[i] &= ~(1 << j);//下将1向左移(高位)移动j位,然后对其按位取反,此时再相与,有0则为0;
}
reset接口的作用就在于将指定的x所映射的位置置0;
1:先找到映射的位置; 2:将映射的位置置0, 下面给出一些示例证明一下:
test(size_t x):
bool test(size_t x)
{
size_t i = x / 8;//找到在数组中第几个char里面
size_t j = x % 8;//找到在这个char中第几个比特位上;
return _bits[i] & (1 << j);//如果表示x的比特位已经为1,那么1&1=1,如果表示x的比特位为0,那么0&1=0;
}
test接口的作用就在于判断x所映射的位置为1还是为0(存在还是不存在);
1:先找到映射的位置; 2:判断是否已经存在(即这一位比特位为1还是为0), 下面给出一些示例证明一下:
位图的主要接口,经常用的接口就是这几个,如果想了解其他接口,可以去C++库里面看看,有兴趣的可以去C++标准库多了解了解,在此,我就不做过多的实现,稍微了解常用的即可,C/C++库中的位图:bitset - C++ Reference (cplusplus.com)
同时,我们要注意,非类型模板参数即size_t N表示的是0~N的范围,不是个数!!!
哪怕只有两个数,比如1和1000,我们在初始化位图的时候,我们就必须要开100/8+1=126个char类型,那么如果是上千,上万个数的时候,我们应该开多大空间呢?
答:为了保险起见,为了防止这些数中有整数最大值或者接近最大值的出来,我们开位图的时候尽量开到最大,即开42亿多个比特位,覆盖掉全部的整数!!!
那我们如何将位图的空间开到最大?下面给出一个示例:
我们也可以通过任务管理器可以看到位图开满整个整形范围时的内存消耗,具体如下图所示:
通过任务管理器可以看到,我们的进程(可执行程序)7-29exe,占用了512MB的内存,也就是0.5G,和我们上文所分析的一样!
所以综上所述:任何一个整数数据集,使用32个比特位的位图都可以统计的下,也就是最多占用0.5GB的空间。
///
1.3:位图的应用与优缺点:
解题思路:首先100亿个整数肯定有大量重复的数,因为整形的范围才0~42亿多,其次,100亿个整数那就是40G,内存根本放不下,所以我们采取位图的方式来解决,同时一个位图只能表示存在或者不存在,即0或者1,那么有重复的数我们无法表示,所以我们采用两个位图来解决,具体如下所示:
具体实现代码如下所示:
template<size_t N>
class two_bitset
{
public:
void set(size_t x)
{
//如果此时A,B位图都为0,让x映射到A位图的比特位变成1 即从00-->01!
if (_bits1.test(x) == false && _bits2.test(x) == false)
{
_bits2.set(x);
}
//如果此时A位图为1,B位图为0,让x映射到A位图的比特位变成0,B位图的比特位变成1, 即从01-->10!
else if (_bits1.test(x) == false && _bits2.test(x) == true)
{
_bits1.set(x);
_bits2.reset(x);
}
/*else//剩下的情况就是一个数多次出现,
{
}*/
}
void print()
{
for (size_t i = 0; i < N; ++i)
{
//只出现一次,那么就是01,即这个数在A位图映射的比特位为1!
if (_bits2.test(i) == true)
{
cout << i << endl;
}
}
}
private:
//这里_bits1表示B位图,_bits2表示A位图!
bitset<N> _bits1;//调用刚才上面写好的位图;
bitset<N> _bits2;
};
给一个测试用例,看看能不能找到只出现一次的数,具体如下所示:
//
解题思路:
第一种思路:将一个文件先读到位图中去,然后在循环的去取另一位文件中的值,通过位图的test接口看这个值在不在位图里面,在就是交集,打印,不在就继续循环;
第二种思路:将第一个文件读取到A位图中,将第二个文件读取到B位图中,然后循环判断这两个位图的同一个比特位是否都为1,都是1就是交集,不是则继续循环;
我这里就实现第一种思路,具体代码如下所示:
template <size_t N>
class bitset
{
public:
bitset()
{
_bits.resize(N / 8 + 1, 0);
}
void set(size_t x)
{
size_t i = x / 8;//找到在数组中第几个char里面
size_t j = x % 8;//找到在这个char中第几个比特位上;
_bits[i] |= (1 << j);//先将1向左移(高位)移动j位,然后相或,有1则为1;
}
void reset(size_t x)
{
size_t i = x / 8;//找到在数组中第几个char里面
size_t j = x % 8;//找到在这个char中第几个比特位上;
_bits[i] &= ~(1 << j);//下将1向左移(高位)移动j位,然后对其按位取反,此时再相与,有0则为0;
}
bool test(size_t x)
{
size_t i = x / 8;//找到在数组中第几个char里面
size_t j = x % 8;//找到在这个char中第几个比特位上;
return _bits[i] & (1 << j);//如果表示x的比特位已经为1,那么1&1=1,如果表示x的比特位为0,那么0&1=0;
}
private:
vector<char> _bits;
};
其实就是我们上面实现的一个简单位图,下面给出一个测试用例:
/
解题思路:这道题和第一道题差不多,用两个位图就可以搞定,不过在set的时候我们需要多判断一种情况,都是打印的时候也要多判断一下,
具体如下所示:
template<size_t N>
class two_bitset
{
public:
void set(size_t x)
{
//如果此时A,B位图都为0,让x映射到A位图的比特位变成1 即从00-->01!
if (_bits1.test(x) == false && _bits2.test(x) == false)
{
_bits2.set(x);
}
//如果此时A位图为1,B位图为0,让x映射到A位图的比特位变成0,B位图的比特位变成1, 即从01-->10!
else if (_bits1.test(x) == false && _bits2.test(x) == true)
{
_bits1.set(x);
_bits2.reset(x);
}
//如果此时A位图为0,B位图为1,让x映射到A位图的比特位变成1,即从10-->11!
else if (_bits1.test(x) == true && _bits2.test(x) == false)
{
_bits2.set(x);
}
/*else其他的都是两次以上了!
{
}*/
}
void print()
{
for (size_t i = 0; i < N; ++i)
{
//出现不超过两次,那么就是01和10,即A位图对应的比特位为0,B位图对应的比特位为1;
// 或者A位图对应的比特位为1,B位图对应的比特位为0;
if ((_bits2.test(i) == true&&_bits1.test(i)==false)
|| (_bits2.test(i) == false && _bits1.test(i) == true))
{
cout << i << endl;
}
}
}
private:
//这里_bits1表示B位图,_bits2表示A位图!
bitset<N> _bits1;//调用刚才上面写好的位图;
bitset<N> _bits2;
};
给一个测试用例,看看能不能找到出现次数小于两次的数,具体如下所示:
/
位图的优缺点:
1:优点:速度快(是哈希的直接定址法,没有哈希冲突,完全O(1)); 和节省空间!
2:缺点:对较分散的数据有一点点浪费空间,但是位图最大的缺点是只能映射整形,其他类型如string,浮点数不能映射表示!!!
//////
二:布隆过滤器:
上面叙述的都是整形,方便存储到位图中去,那字符串呢,能不能也通过位图映射呢?
答案是可以的,因为之前的哈希表中我们也可以实现字符串的映射,只需要通过一个哈希仿函数将字符串转成整形,然后再进行映射,具体如下所示:
如上图所示,通过一个哈希函数将字符串转成整形再映射,但是这里出现了一个问题:
这个哈希函数能保证字符串转换后不会转出一样的数吗?比如上面那个,假如right与erase经过哈希函数转换后映射到同一个比特位,那这个比特位表示的是right还是erase呢???
所以我们可以通过这得到一个结论:
1:如果一个字符串映射的比特位为1,那么这个字符串之前可能存在,也可能不存在,万一有其他的字符串也映射这个比特位了!
2:如果一个字符映射的比特位为0,那么表示这个字符串之前肯定不存在,
这样我们就有误判的产生,即不存在是准确的,存在是存在误判的,那么我们有什么办法减少误判率,提高准确性呢???
///
2.1:布隆过滤器的概念:
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
具体如图所示,就是通过多个哈希函数将一个字符串映射多个位置来降低误判了,
比如right与erase,之前单个哈希函数映射他俩冲突,当访问到冲突的比特位时不知道是right还是erase,
而现在通过两个哈希函数映射,将字符串映射两个比特位,只有两个映射的比特位都为1时,才能证明这个字符串存在,不单单只看两者冲突的哪一个比特位,此时降低了误判率!当然,如果一个字符串映射的比特位为0,那么还是不存在的,
那如果有大量的字符串需要映射,此时我们该设置多少个哈希函数来降低误判率呢?字符串与哈希函数是否有关系呢???
详解布隆过滤器的原理,使用场景和注意事项 - 知乎一文中详细解释说明了这一问题,并做图来线性的显示哈希函数与诸多元素的关系,描述的很详细,我们可以根本文章中的结论及归纳的公式来推导我们需要的哈希函数个数(下文将使用里面得到的结论公式)!!!
一定要注意:布隆过滤器使用多个哈希函数映射是为了降低冲突,不是彻底解决冲突,也没有办法彻底解决,谁也不能保证不会映射到同一位置!
///
2.2:布隆过滤器的实现:
布隆过滤器的核心是哈希函数,而如何选择哈希函数就是布隆过滤器的主要性能体现,https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html这篇文章中,详细的为我们介绍了很多优秀的哈希函数,我们挑选性能最高的前三种作为我们实现布隆过滤器的哈希函数。具体如下所示:
struct BKDRHash
{
size_t operator()(const string& str)
{
register size_t hash = 0;
for(auto ch:str)
{
hash = hash * 131 + ch;
}
return hash;
}
};
struct APHash
{
size_t operator()(const string& str)
{
size_t hash = 0;
for (long i = 0; i<str.size(); i++)
{
size_t ch = str[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& str)
{
register size_t hash = 5381;
for(auto ch:str)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
//N是数据个数,K默认为string,
template<size_t N,class K=string,
class hashfunc1= BKDRHash,
class hashfunc2= APHash,
class hashfunc3= DJBHash>
class BloomFilter
{
private:
static const count = 4;
bitset<N*count> _bits;//布隆过滤器的总长度
};
其中,构建布隆过滤器我们需要一个位图(我用的是上面自己实现的位图,也可以使用库里面封装好的位图,即std::bitset ),
size_t N 表示我们要存储的数据个数(上面的位图中的N表示的是范围,这里布隆过滤器的N是个数!);
static const count=4;是根据上面如何挑选哈希函数中的公式:K=(M/N)*ln2得到的(K是哈希函数个数,M是布隆过滤器的长度,N是数据个数);
那么M/N=3/ln2-->约等于4,那么就表明平均存储一个数据,就需要开辟大概4个比特位来存储,所以count才为4!!!
同时,布隆过滤器也有set,test,reset,这些接口,我们下面依次实现一下:
set(const K& s): 将数据映射到位图中
void set(const K& s)
{
int len = N * count;//布隆过滤器的总长度
//每次通过哈希函数映射一下,就改变在位图中对应的位置!
int hash1 = hashfunc1()(s) % len;
_bits.set(hash1);
int hash2 = hashfunc2()(s) % len;
_bits.set(hash2);
int hash3 = hashfunc3()(s) % len;
_bits.set(hash3);
//打印一下映射的位置,方便我们观察一下
cout << hash1 << " " << hash2 << " " << hash3 << endl << endl;
}
下面给出一个示例,看看用多个哈希函数是不是可以降低误判率,具体如下所示:
我们可以看到,即使是相同的字符串,调换一下位置,映射的位置都不一样了!
test(const K& s): 查找这个数据在不在位图中:
bool test(const K& s)
{
int len = N * count;
int hash1 = hashfunc1()(s) % len;
//因为在布隆过滤器中,如果不在是确定的,在是有误判的,所以我们这里判断不在
//只有通过哈希函数映射的比特位有一个为0,那么就不在!!
if (_bits.test(hash1) == false)
{
return false;
}
int hash2 = hashfunc2()(s) % len;
if (_bits.test(hash2) == false)
{
return false;
}
int hash3 = hashfunc3()(s) % len;
if (_bits.test(hash3) == false)
{
return false;
}
return true;
}
下面给出一个示例,具体如下所示:
reset(const K& s):
布隆过滤器不直接支持删除工作,因为我们是通过位图映射来表示的,而位图只是0和1,要是有两个数据通过哈希函数映射到同一个比特位,
一个数据删除将比特位置0,将会影响另一个比特位的存在情况!!!
如果非要支持布隆过滤器的删除操作,我们只能改变底层的位图结构,让每一个比特位变成一个计数器,当映射到时,计数器++,删除时,计数器--,但是这样是通过多占用几倍存储空间的代价来增加删除操作的!
其实改成计数器也存在很大的缺陷,使我们无法确定是否真正存在,而且计数过程也可能出现问题!
测试布隆过滤器的误判率:
void BlooFiltertest2()
{
srand((unsigned)time(nullptr));
const size_t N = 100000;//创建一些随机数种子,自己可以调整生成多少个数据
BloomFilter<N> bf2;//定义一个布隆过滤器
vector<string> v1;//定义一个vector,方便我们存储字符串
string s1 = "https://cplusplus.com/reference/";//一个网址,使生成的字符串更随机
for (size_t i = 0; i < N; ++i)
{
v1.push_back(s1 + to_string(i));//将所有的字符串都放入到vector中,且每次+i,保证字符串不一样
}
for (auto& e : v1)
{
bf2.set(e);//全部映射到布隆过滤器
}
vector<string> v2;//创建另一个存储字符串的vector
string s2 = "https://cplusplus.com/reference/";//和上面那个网址一样,利用一样的网址生成相似字符串;
for (size_t i = 0; i < N; ++i)
{
v2.push_back(s2 + to_string(99999 + i));
}
size_t n1 = 0;
for (auto& e : v2)
{
if (bf2.test(e) == true)//判断相似字符串的误判率!
{
++n1;
}
}
cout << "相似字符串的误判率:" << (double)n1 / (double)N << endl;
vector<string> v3;
//找一个和上面不同的网址方便生成不同的字符串
string s3 = "https://blog.csdn.net/KL4180?type=sub&spm=1001.2014.3001.5348";
for (size_t i = 0; i < N; ++i)
{
v3.push_back(s3 + to_string(rand() + i));
}
size_t n2 = 0;
for (auto& e : v3)
{
if (bf2.test(e) == true)//判断不相似字符串的误判率!
{
++n2;
}
}
cout << "不相似字符串的误判率:" << (double)n2 / (double)N << endl;
}
我们可以看到,此时的误判率大概在15%一下,那如果我们调整count的值,即加长布隆过滤器的长度,误判率会呈现什么样的趋势呢?
我们看到,随着布隆过滤器的长度的不断增加,相似字符串与不相似字符串的误判率在不断缩小,虽然我们可以通过增加布隆过滤器的长度来减少误判率,但是增加长度也带来了空间的负担消耗,所以我们要根据具体情况来选择合适的长度!!!
///
2.3:布隆过滤器的应用与优缺点:
应用:比如说平常打游戏/注册账号时都让我们自己起一个昵称,如果已经被别人用过了,它会告诉你重新起一个,如果没有,自己注册成功,非常快,这是怎么做到的?
所以布隆过滤器起到一次过滤的作用,减少IO次数,提高效率!!!
布隆过滤器优点:
1. 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关;
2. 哈希函数相互之间没有关系,方便硬件并行运算;
3. 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势;
4. 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势;
5. 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能;
6. 使用同一组散列函数的布隆过滤器可以进行交、并、差运算;
布隆过滤器缺陷:
1. 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据);
2. 不能获取元素本身;
3. 一般情况下不能从布隆过滤器中删除元素;
4. 如果采用计数方式删除,可能会存在计数回绕问题;
///
///
三:海量数据处理:
解题思路:100亿个字符串就算平均一个字符串长5,也得500亿字节,内存根本放不下,所以我们需要将其分成一小块一小块单独比较,而A文件分成n个小份,B文件也分成多个小份,我们不可能将A文件的每一个小份与B文件的n份都循环找一遍交集吧!
所以此时我们需要哈希切分:将A,B两个文件通过哈希函数分割成一块一块有“辨识度”的小文件,具体如下所示:
但是这种思路有很大的问题:假如我经过哈希映射后转换的整形很集中,那么有可能有的小文件几十G,有的小文件几G,甚至几百MB,那么,此时我们应该考虑这些极端情况!即现在有两种情况:
情况1:某一个小文件中存在大量重复的quary(只有大量重复存在,小文件可能才会大);
情况2:某一个小文件中存在大量的quary(大量的quary映射到同一个小文件中!);
想要解决这两种问题,我们首先得分清楚这两种情况,我怎么知道小文件里面是大量重复的quary,还是大量的quary?
此时,我们可以通过unordered_set/set来进行辨别,
如果读取这个小文件,向unordered_set/set插入时都插入成功,可能是情况二;直接找交集即可!
如果读取这个小文件,向unordered_set/set插入时有大量的插入失败(会抛内存异常),那么可能是情况一,我们需要再重新设置哈希函数,接着拆分,再求交集!!!
大致思路就是这样的,具体实现我就不在这实现了!
解题思路:和上面一样,通过哈希切分切成一个一个的小文件,然后通过unordered_map/map来统计每一个小文件中出现最多次的IP地址,
同时,要注意和上面差不多的两种情况,如果存在大量重复的IP地址,会抛内存异常,我们需要设置哈希函数,继续哈希切割!!!我在这也不具体实现了,明白明白思路就行;
///
///
四:总结:
位图和布隆过滤器都是常见的数据结构,用于快速判断某个元素是否存在。
其中,位图是一种利用一个固定长度的二进制向量表示某些状态的数据结构,常用于判断某些元素是否存在,可有效地节省空间,并且查询时间复杂度为O(1)。
布隆过滤器是一种利用若干个独立的哈希函数和一个二进制向量表示元素是否存在的数据结构,通过对元素进行多次哈希,将每个哈希值对应的位都置为1,从而实现判断元素是否存在。布隆过滤器具有空间效率高、查询速度快等优点,但也存在误判和漏判的问题!!!
因此,在实际应用中需要根据具体情况选择合适的数据结构。