目录
1. 位图
1.1 位图的概念
1.2 位图的实现
1.3 位图解决海量数据面试题
完整BitSet.h和two_bitset:
1.4 位图的优缺点
2. 布隆过滤器
2.1 布隆过滤器的概念
2.2 布隆过滤器的实现
完整 BloomFilter.h 和测试
2.3 布隆过滤器的优缺点和应用
3. 哈希切割(哈希切分)
4. 笔试选择题
答案及解析
本章完。
1. 位图
腾讯面试题:给40亿个不重复的无符号整数,没排过序。给一个无符号整数,
如何快速判断一个数是否在这40亿个数中。
根据我们现有的知识,该如何处理上诉问题呢?
1. 遍历,时间复杂度O(N)
2. 排序(O(NlogN)),利用二分查找: logN
3. 红黑树 / 哈希表。
还有很多其他的方式,但是这些方式都行不通,
先来口算一下40亿的无符号整数占用多大的内存空间:
- 10亿个字节 ≈ 1GB。
- 40亿个字节 ≈ 4GB。
- 40亿个无符号整数 ≈ 16GB。
而一般的内存根本放不下这么多的数据,无论是上面的哪种方法,都需要存放数据本身,即使是用数组来存放都需要16GB,如果用红黑树(有三叉链,颜色)需要大的内存,哈希表虽然少一点,但是仍然有next指针,还是存放不下。
- 问题中只要求判断一个数是否在这40亿个数据中,所以可以不存放数据本。
可以采用下面的位图的方式来处理这个问题。
1.1 位图的概念
位图:就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。
通常是用来判断某个数据存不存在的。
位图就是哈希结构,这里我们用直接定址法,1表示在,0表示不在,就能很好处理这个面试题。
对于40亿个数据,至少需要40亿个比特位才能标识它们的状态,
对于这种情况一般选择2^32个比特位:
2^32 = 42亿9千多万,40亿个数据完全可以表示的下,
此时相当于一个数组,有2^32个元素,每个元素是一个比特位。
使用位图方式占用的内存就小多了:
1个字节等于8个比特位
- 2^32个比特位 = 2^29个字节 = 2^19KB = 2^9MB = 512MB = 0.5GB
- 从最开始需要16GB内存空间直接下降到了需要0.5GB的空间。
但是在语言层面上并没有比特位的数组。
- 2^32个比特位可以用2^27个int类型的数组来表示。
- 也可以用2^29个char类型的数组来表示。
随便例举一些数字,如上图所示,这里采用char类型为数组的基本单位。
- 数据范围是1到22,所以需要3个char类型的变量。
- 下标为1的比特位表示数字1的存在情况,下标为18的比特位表示数字18是否存在。
上图中,存在3个char类的变量,一共24个比特位,整体标号的话是0~23。
- 0~7使用第一个char类型的变量。
- 8~15使用第二个char类型变量。
- 16~23使用第三个char类型变量。
这3个char类型的变量是用一个数组实现的,即char [3]。
这3个char类型变量的地址从左到右依次升高。
- 每个char类型中比特位却是:低的比特位在右,高的比特位在左。
这是由我们的使用习惯决定的,比如3用二进制表示就是11,6用二进制表示就是100,
低比特位在右,高比特位在左。
不使用int类型数组的原因:(用int也可以)
我们知道,数据在内存中的存储是有大小端的,如果使用int类型的数组,上图就变成:
一个int就是4个字节,8个比特位只需要一个int类型的数据就够了,并且还多出8个比特位。假设上图中是小端存储方式,并且是处理完的位图,此时将这份代码换到了大端存储方式的机器上:
此时位图结构就变成了下图中所示,原本表示数字0~7的8个比特位放在了高地址处,变成了表示24 ~31的8个比特位。
原本在小端机上的程序在大端机上极有可能出现BUG。
而采用char类型数组就不用考虑大小端的问题,因为一个char类型就是一个字节,每个char都是从低地址到高地址排列。
上面是在内存中存储的真实样子,我们在使用的时候无需知道位图在内存中样子。
这种方式就是一种哈希思想,将数据直接映射到位图上。
如何确定一个数据映射在位图的哪个比特位呢?以整数18为例说明:
18映射在位图的下标为2的八个比特位中的某一个,也就是第三个char类型变量。
具体映射在下标为2的char类型变量中下标为2的比特位上,也就是在这个char类型中第三个比特位上。
- 确定映射到char类型变量的下标:18 / 8 = 2。
- 确定映射到比特位的下标:18 % 8 = 2。
可以根据上面的图确定一下,发现和我们算出来的结果是一样的。求其他数据的映射位置时,只需要将18换成对应数据即可。
1.2 位图的实现
BitSet.h:
#pragma once
#include <iostream>
#include <vector>
using namespace std;
namespace rtx
{
template<size_t N>
class bitset
{
public:
bitset()
{
//_bits.resize(N / 8 + 1, 0);
_bits.resize((N >> 3) + 1, 0); // 即上面注释的,效率快一点点
}
protected:
vector<char> _bits;
};
}
- 使用非类型模板参数,该参数用来指定位图比特位的个数。
- 底层使用的是vector,vector中是char类型变量。
在构造函数中需要指定vector的大小,否则vector的大小是0,一个比特位也没有。
- 非类型模板参数N指定是比特位的个数,而构造函数开辟的是char类型变量的个数,所以需要N / 8。
- 由于N / 8的结果不是整数时会取整而抛弃小数部分,所以需要在N /8 后再加1,也就是再增加 8 个比特位来确保位图够用。
CPU在计算除法的时候,其实是很复杂的,而进行移位运算就很简单。N / 8相当于N右移3位。所以我们使用移位运算来代替除法来提高效率,需要注意的是,加法的优先级比移位运算高,所以必须给(N>>3)加括号,否则就是成了 N>>4了。
下面来写bitset的接口函数:
set(); 该接口的作用是将x映射在位图中的比特位置1,表示该数据存在。
- 首先将x映射在位图中的位置计算出来。
- 然后将映射的比特位置1。
怎么将对应的比特位置1?这就要我们以前C语言学的知识:
如上图所示,要将一个char类型中的8个比特位的某一个位置一而不影响其他位,就需要或等一个只有那个位是1其他位都是0的char类型,这样一个char类型可以通过1左移固定位数得到。
void set(size_t x)
{
size_t i = x >> 3; // 将x映射在位图中的位置计算出来。
size_t j = x % 8; // //映射到char中第几个比特位
_bits[i] |= (1 << j); //将映射的比特位置1。
}
现在来实现reset();该接口的作用是将x映射在位图中的比特位置0,表示该数据不存在。
和set的思路一样同样先计算处x所在位图中的位置。 然后再进行置0。
怎么将对应比特位置0?上面是或等,这里就要与等一个数。
这里与等一个只有那个位是0其他位都是1的char类型变量,这样一个char类型可以通过1左移固定位数(就是set或等的那个数),然后按位取反得到。
void reset(size_t x)
{
size_t i = x >> 3; // 将x映射在位图中的位置计算出来。
size_t j = x % 8; // //映射到char中第几个比特位
_bits[i] &= ~(1 << j); //将映射的比特位置0,这里~是按位取反,不要用到!逻辑取反
}
现在来实现test(); 该接口的作用是在位图中查找数据x是否存在。
- 首先计算出x映射在位图中的位置。
- 然后看该比特位是0还是1。
如上图所示,判断某个比特位是1还是0,需要与一个只有这个位是1其他位都是0的char类型变量,如果这个bit是0,那么与以后的结果就是0,对应的bool值flase,如果这个bit是1,那么与以后的结果就不是0,对应的bool值是true。
- bool值本质上是4个字节的整形,所以这里涉及到了整形提升,但是并没有影响。
- 如果与以后的结果是0,整形提升后的结果仍然是0,bool值就是false。
- 如果与以后的结果非0,即使符号位是1,整形提升和的结果仍然非0,bool的值就是true。
bool test(size_t x)
{
size_t i = x >> 3; // 将x映射在位图中的位置计算出来。
size_t j = x % 8; // //映射到char中第几个比特位
return _bits[i] & (1 << j); //与上除了对应比特位是1,其它位都是0的数,得到对应比特位bool值
}
位图主要的接口就是这三个,下面来测试一下:
#include "BitSet.h"
void test_bitset()
{
rtx::bitset<100> bs; //上面面试题开范围可以这样开:bitset<-1> bs1;
bs.set(8);
bs.set(9);
bs.set(20);
cout << bs.test(8) << endl;
cout << bs.test(9) << endl;
cout << bs.test(20) << endl;
cout << bs.test(30) << endl << endl;
bs.reset(8);
bs.reset(20);
cout << bs.test(8) << endl;
cout << bs.test(9) << endl;
cout << bs.test(20) << endl;
}
int main()
{
test_bitset();
return 0;
}
STL中的位图:
在STL库中,是存在位图的,但是用的比较少。
上面实现的这3个操作也是有的,当然它还提供了其他的接口,这里就不介绍了。
1.3 位图解决海量数据面试题
下面是一些海量数据面试题:
1. 给定100亿个整数,如何设计算法找到只出现一次的整数?
2. 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
3. 位图应用变形:1个文件有100亿个int,1G内存,如何找到出现次数不超过两次的所有整数?
这三道题我们一题一题来看:
- 问题一:给定100亿个整数,如何设计算法找到只出现一次的整数?
首先这100亿个数据在内存中肯定是放不下的,所以之前学习的存放数据本身的数据结构都用不了,只能用位图。位图的一个比特位只有两种状态来表示数据的有无,这里是要统计次数,所以就要让位图不仅仅只有两种状态。这里可以用KV模型,但是想想还有没有更好的方法?
位图在STL库里有,虽然只是K模型的,但是我们用两个位图就能很好的解决这个问题:
创建两个2^32比特位的位图结构,如上图所示。
- 两个位图相同下标的两个比特位来表示一个数据的状态。
- 00表示0次,01表示1次,10表示一次1以上。
完整BitSet.h和two_bitset:
#pragma once
#include <iostream>
#include <vector>
#include <bitset>
using namespace std;
namespace rtx
{
template<size_t N>
class bitset
{
public:
bitset()
{
//_bits.resize(N / 8 + 1, 0);
_bits.resize((N >> 3) + 1, 0); // 即上面注释的,效率快一点点
}
void set(size_t x)
{
size_t i = x >> 3; // 将x映射在位图中的位置计算出来。
size_t j = x % 8; // //映射到char中第几个比特位
_bits[i] |= (1 << j); //将映射的比特位置1。
}
void reset(size_t x)
{
size_t i = x >> 3; // 将x映射在位图中的位置计算出来。
size_t j = x % 8; // //映射到char中第几个比特位
_bits[i] &= ~(1 << j); //将映射的比特位置0,这里~是按位取反,不要用到!逻辑取反
}
bool test(size_t x)
{
size_t i = x >> 3; // 将x映射在位图中的位置计算出来。
size_t j = x % 8; // //映射到char中第几个比特位
return _bits[i] & (1 << j); //与上除了对应比特位是1,其它位都是0的数,得到对应比特位bool值
}
protected:
vector<char> _bits;
};
template<size_t N>
class two_bitset
{
public:
void set(size_t x)
{
bool inset1 = _bs1.test(x); // 测试当前状态
bool inset2 = _bs2.test(x);
if (inset1 == false && inset2 == false)
{
_bs2.set(x); // 00 -> 01
}
else if (inset1 == false && inset2 == true)
{
_bs1.set(x); // 01 -> 10
_bs2.reset(x);
} // 10 是出现两次或两次以上,不用变
}
void print_once_num()
{
for (size_t i = 0; i < N; ++i)
{
if (_bs1.test(i) == false && _bs2.test(i) == true)
{
cout << i << endl; // 打印只出现一次的整数
}
}
}
protected:
bitset<N> _bs1;
bitset<N> _bs2;
//std::bitset<N> _bs1;
//std::bitset<N> _bs2;
};
}
Test.cpp:
#include "BitSet.h"
void test_bitset()
{
rtx::bitset<100> bs; //上面面试题开范围可以这样开:bitset<-1> bs1;
bs.set(8);
bs.set(9);
bs.set(20);
cout << bs.test(8) << endl;
cout << bs.test(9) << endl;
cout << bs.test(20) << endl;
cout << bs.test(30) << endl << endl;
bs.reset(8);
bs.reset(20);
cout << bs.test(8) << endl;
cout << bs.test(9) << endl;
cout << bs.test(20) << endl;
}
void test_two_bitset()
{
int arr[] = { 3, 4, 5, 2, 3, 4, 4, 4, 4, 12, 77, 65, 44, 4, 44, 99, 33, 33, 33, 6, 5, 34, 12 };
rtx::two_bitset<100> bs;
for (const auto& e : arr)
{
bs.set(e);
}
bs.print_once_num();
}
int main()
{
//test_bitset();
test_two_bitset();
return 0;
}
- 问题二:给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
- 两个文件都有100一个整数,必然放不进内存中,所以同样采用位图结构。
- 每个文件使用一个2^32个比特位的位图,两个文件就是两个位图,占用的内存也就是1GB,符合要求。
- 两个文件都放进位图,这样就可以去重了,然后将两个位图进行按位与运算,得到的结果中,比特位是1的就是交集。
这里具体的实现就不再写了,要注意体会位图的应用,也就是哈希应用的思想。
- 问题三:个文件有100亿个int,1G内存,如何找到出现次数不超过两次的所有整数?
- 采用的方法是两个位图结构,和问题1一样。
- 只是这里还需要两个位是11的情况,用来表示3次及以上。
只需要在前面代码增加一种情况的处理即可:
1.4 位图的优缺点
上面就是一些位图的应用,有下面这些时应该想到位图:
1. 快速查找某个数据是否在一个集合中
2. 排序 + 去重
3. 求两个集合的交集、并集等
4. 操作系统中磁盘块标记
但是位图有优点也是有缺点的:
优点:节省空间,效率高。(直接定制法,直接开到整形的最大范围就不存在冲突)
缺点:一般要求数据相对集中,否则会导致空间消耗上升。
位图的一个致命缺点:只能针对整形。
2. 布隆过滤器
果我就要使用位图来存放字符串呢?当然也是可以的,只是需要和哈希表一样,将字符串转换成整数。
如上图所示,将不同的字符串通过hashfunc函数转换成不同的整数,然后将这些整数映射到位图中,从而表示字符串的存在情况。
但是无论是哪种方式,字符串转换成整数,都有可能让两个不同的字符串转换的整数相同。
这就会产生误判的情况,那是判断存在有误判,还是判断不存在有误判,还是都有误判呢?:
位图中存在:不一定真正存在。
如上图中“find”和“insert”转换成的整数都是1234,所以位图中第1234个比特位是1,就可以说“find”和“insert”都存在,但实际上是“insert”存在,而“find”不存在,于是就产生了误判。位图不存在:必然不存在。
还使用上面的例子,如果位图的第1234个比特位是0,说明“find”和“insert”都不存在。
所以根据位图判断出的结构,不存在是准确的,存在是不准确的。
有没有办法能提高一下判断的准确率呢?答案是有的,布隆过滤器就可以降低误判率,提高准确率。
2.1 布隆过滤器的概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
- 布隆过滤器:用多个哈希函数,将一个数据映射到位图结构中。
使用两个哈希函数,将同一个字符串转换成两个整数,并且都映射在位图中,如上图所示。
只有一个字符串在位图中的两个比特位同时为1才能说明该字符串存在。
"find"经过哈希函数处理后的两个整数,只有一个是被“insert”映射的,另一个是0,说明“find”不存在。而“insert”经过哈希函数处理后的两个整数,在位图中都有映射,可以说明“insert”存在。
此时降低了误判率:
位图存在:字符串存在的准确率提高,但是仍有不存在的可能。
字符串“find”经过两个哈希函数处理后得到两个整数,与字符串“insert”得到的两个整数相同的概率,比之前各自有一个整数相同的概率低的多。
但是仍然有可能“find”的两个整数和“insert”的两个整数相同,此时就会又出现误判。位图不存在:必然不存在。
布隆过滤器对于不存在的判断是准确的,并且可以降低存在时的误判率。
布隆过滤器的应用场景:不需要一定准确的场景,比如注册昵称时的存在判断。
如上图中,一个昵称的数据库是放在服务器中的,这个数据库中昵称的存在情况都放在了布隆过滤器中,当从客户端注册新的昵称时,可以通过布隆过滤器快速判断新昵称是否存在。
- 这里对存在的准确率要去就没有太高,布隆过滤器显示存在(不准确),就换一个昵称,显示不存在(准确),就注册这个昵称,并放入数据库中。
- 通过布隆过滤器查找可以提高效率,如果之前去数据库中查找的话,效率就会大大降低。
哈希函数个数和布隆过滤器长度的关系:
现在知道布隆过滤器是什么了,但是我们到底该创建多少个比特位的位图(布隆过滤器长度),又应该使用多少个哈希函数来映射同一个字符串呢?
布隆过滤器长度长度开得短了误判率就高,开得长了就存在空间浪费的情况,优点就不明显了。
如何选择哈希函数个数和布隆过滤器长度一文中,对这个问题做了详细的研究和论证:
- 哈希函数个数和布隆过滤器长度以及误判率三者之间的关系曲线。
最后得出一个公式:
- m:表示布隆过滤器长度。k:表示哈希函数个数。n:表示插入的元素个数。n2约等于0.69。
2.2 布隆过滤器的实现
首先需要写几个哈希函数来将字符串转换成整形,各种字符串Hash函数一文中,介绍了多种字符串转换成整数的哈希函数,并且根据冲突概率进行了性能比较,有兴趣的小伙伴可以自行研究一下。这里选择分数较高的3个哈希函数:
struct HashBKDR
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val *= 131;
val += ch;
}
return val;
}
};
struct HashAP
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (size_t i = 0; i < key.size(); i++)
{
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ key[i] ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ key[i] ^ (hash >> 5)));
}
}
return hash;
}
};
struct HashDJB
{
size_t operator()(const string& key)
{
size_t hash = 5381;
for (auto ch : key)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
template<size_t N, class K = string, class Hash1 = HashBKDR, class Hash2 = HashAP, class Hash3 = HashDJB>
class BloomFilter // N表示准备要映射N个值
{
public:
protected:
const static size_t _ratio = 5; // 根公式算出来,此时哈希函数是3个,所以m = 3n/ln2 约等于4.2 取5
std::bitset<_ratio* N>* _bits = new std::bitset<_ratio * N>;
// 库里的bit是放在栈上的,容易栈溢出,所以自己放到堆上(很挫)用自己写的就是放在堆上的
};
size_t N:最多存储的数据个数。
class K:布隆过滤器处理的数据类型,默认情况下是string,也可以是其他类型。
哈希函数:将字符串或者其他类型转换成整形进行映射,缺省值是将字符串转换成整形的仿函数。
set(): 将数据经过3个哈希函数的处理得到3个整数,
然后将这3个整数都映射到位图中来表示这个数据存在。
void Set(const K& key)
{
size_t hash1 = Hash1()(key) % (_ratio * N); // 注意优先级问题,在最后加括号
size_t hash2 = Hash2()(key) % (_ratio * N);
size_t hash3 = Hash3()(key) % (_ratio * N);
_bits->set(hash1);
_bits->set(hash2);
_bits->set(hash3);
}
test(): 对每一个哈希函数得到的整数所映射的位置进行判断,如果某个位置不存在直接返回false,说明这个字符串不存在,当所有整数所映射的位置都存在,说明这个字符串存在。
- 判断每个比特位时,判断它不存在,不要判断它存在,因为不存在是准确的,存在是不准确的。
bool Test(const K& key)
{
size_t hash1 = Hash1()(key) % (_ratio * N);
if (!_bits->test(hash1))
{
return false; // 准确的
}
size_t hash2 = Hash2()(key) % (_ratio * N);
if (!_bits->test(hash2))
{
return false; // 准确的
}
size_t hash3 = Hash3()(key) % (_ratio * N);
if (!_bits->test(hash3))
{
return false; // 准确的
}
return true; // 可能存在误判
}
- 布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。
“baidu”和“tencent”映射的比特位都有第4个比特位。删除上图中"tencent"元素,如果直接将该元素所对应的二进制比特位置0,“baidu”元素也被删除了,因为这两个元素在多个哈希函数计算出的比特位上刚好有重叠。
面试题: 如何扩展BloomFilter使得它支持删除元素的操作。
一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作。
但是也存在缺陷,无法确认元素是否真正在布隆过滤器中,甚至会有计数回绕。
总的来说,布隆过滤器最好不要支持删除操作。
完整 BloomFilter.h 和测试
#pragma once
#include <iostream>
#include <vector>
#include <bitset>
#include <string> // to_string
using namespace std;
struct HashBKDR
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val *= 131;
val += ch;
}
return val;
}
};
struct HashAP
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (size_t i = 0; i < key.size(); i++)
{
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ key[i] ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ key[i] ^ (hash >> 5)));
}
}
return hash;
}
};
struct HashDJB
{
size_t operator()(const string& key)
{
size_t hash = 5381;
for (auto ch : key)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
template<size_t N, class K = string, class Hash1 = HashBKDR, class Hash2 = HashAP, class Hash3 = HashDJB>
class BloomFilter // N表示准备要映射N个值
{
public:
void Set(const K& key)
{
size_t hash1 = Hash1()(key) % (_ratio * N); // 注意优先级问题,在最后加括号
size_t hash2 = Hash2()(key) % (_ratio * N);
size_t hash3 = Hash3()(key) % (_ratio * N);
_bits->set(hash1);
_bits->set(hash2);
_bits->set(hash3);
}
bool Test(const K& key)
{
size_t hash1 = Hash1()(key) % (_ratio * N);
if (!_bits->test(hash1))
{
return false; // 准确的
}
size_t hash2 = Hash2()(key) % (_ratio * N);
if (!_bits->test(hash2))
{
return false; // 准确的
}
size_t hash3 = Hash3()(key) % (_ratio * N);
if (!_bits->test(hash3))
{
return false; // 准确的
}
return true; // 可能存在误判
}
// 一般不支持删除,因为可能会影响其它值(引用计数可以解决,但空间消耗更多了)
//void Reset(const K& key);
protected:
const static size_t _ratio = 5; // 根公式算出来,此时哈希函数是3个,所以m = 3n/ln2 约等于4.2 取5
std::bitset<_ratio* N>* _bits = new std::bitset<_ratio * N>;
// 库里的bit是放在栈上的,容易栈溢出,所以自己放到堆上(很挫)用自己写的就是放在堆上的
};
Test.cpp:
#include "BloomFilter.h"
void TestBloomFilter1()
{
BloomFilter<10> bf;
string arr1[] = { "苹果", "西瓜", "阿里", "美团", "苹果", "字节", "西瓜", "苹果", "香蕉", "苹果", "腾讯" };
for (auto& str : arr1)
{
bf.Set(str);
}
for (auto& str : arr1)
{
cout << bf.Test(str) << " ";
}
cout << endl;
string arr2[] = { "苹果111", "西瓜", "阿里2222", "美团", "苹果dadcaddxadx", "字节", "西瓜sSSSX", "苹果 ", "香蕉", "苹果$", "腾讯" };
for (auto& str : arr2) // 测试相似字符串在不在
{
cout << str << ":" << bf.Test(str) << endl;
}
}
void TestBloomFilter2() // 网上找的测试误判率的测试
{
srand(time(0));
const size_t N = 100000;
BloomFilter<N> bf;
cout << sizeof(bf) << endl;
std::vector<std::string> v1;
std::string url = "https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html";
for (size_t i = 0; i < N; ++i)
{
v1.push_back(url + std::to_string(1234 + i));
}
for (auto& str : v1)
{
bf.Set(str); // 将十万个不同的字符串映射到位图中
}
std::vector<std::string> v2;
for (size_t i = 0; i < N; ++i) // 获取十万个和前面相似的字符串用于下面测试
{
std::string url = "http://www.cnblogs.com/-clq/archive/2023/05/31/2528153.html";
url += std::to_string(rand() + i);
v2.push_back(url);
}
size_t n2 = 0;
for (auto& str : v2)
{
if (bf.Test(str))
{
++n2;
}
}
cout << "相似字符串误判率:" << (double)n2 / (double)N << endl;
std::vector<std::string> v3; // 获取十万个和前面不相似的字符串用于下面测试
for (size_t i = 0; i < N; ++i)
{
string url = "zhihu.com";
url += std::to_string(rand() + i);
v3.push_back(url);
}
size_t n3 = 0;
for (auto& str : v3)
{
if (bf.Test(str))
{
++n3;
}
}
cout << "不相似字符串误判率:" << (double)n3 / (double)N << endl;
}
int main()
{
TestBloomFilter1();
TestBloomFilter2();
return 0;
}
可以看到,相似字符串的误判率在百分之十左右。
可以试试改X值,X值越大,也就是一个字符串所需要的映射比特位越多,布隆过滤器的误判率越小。但是空间消耗也增加了。
- 哈希函数的个数越多,误判率也会越小,但是对于的空间消耗也会增加。
布隆过滤器只能提高存在判断的准确率,并不能让它完全准确。
2.3 布隆过滤器的优缺点和应用
优点:
- 1. 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关。
- 2. 哈希函数相互之间没有关系,方便硬件并行运算。
- 3. 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势。
- 4. 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势。
- 5. 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能。
- 6. 使用同一组散列函数的布隆过滤器可以进行交、并、差运算。
缺点:
- 1. 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)。
- 2. 不能获取数据本身。
- 3. 一般情况下不能从布隆过滤器中删除元素。
- 4. 如果采用计数方式删除,可能会存在计数回绕问题。
海量数据面试题: 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出近似算法和精确算法。
分析:和位图应用一样,数据量太大,无法放入内存中,由于是字符串,近似算法可以使用布隆过滤器来处理。创建两个布隆过滤器,每个是232大小,占用空间0.5GB,两个就是1GB。将两个文件中的字符串各自映射到布隆过滤器中,然后两个布隆过滤器进行按位与操作,最后是1的位置就是交集。具体代码这里就不写了,这里主要体会布隆过滤器是使用的思想。
精确算法就不能用布隆过滤器处理了,要用到下面的哈希切割:
3. 哈希切割(哈希切分)
先看一道哈希切割的海量数据面试题:给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?设计算法找到top k的IP地址呢?
分析:
- 100GB大小的文件,无法放入内存。
- 找到出现次数最多的IP,需要准确统计,无法使用位图或者布隆过滤器,因为它两的存在是不准确的。
- 统计次数,还是需要用到map或者是unordered_map。
- 将100GB的文件拆分成100个1GB大小的小文件,每个小文件进行统计。
- 一个个来统计次数,依次读取每个小文件,依次统计次数。
- 统计完一个,将出现最多次数的IP及次数保存,并且clear掉map,再统计下一个小文件。
如果将这100GB的文件均分为100给1GB的小文件,统计会出现问题。
- 假设A0中出现次数最多的IP是“IP1”,出现最少次数的IP是“IP2",那么这个小文件最终得到是”IP1“出现最多。
- A1小文件中,出现最多的是”IP2“,出现最少的是”IP1“,那么这个小文件最终得到是”IP2“出现最多。
- 最终是A0中统计出来”IP1“的次数和A1中统计出来”IP2“的次数在比较。
这样最终比较时的数据具有片面性,因为在统计每个小文件时,会舍弃很多的数据,这些舍弃的数据再最终比较时并没有被考虑到。
- 如果在分小文件的时候,让相同的IP分到一个小文件中,这样统计出来的次数就不片面了。
此时就需要用到哈希切分的方法。
- 哈希切分:通过哈希函数,将相同或者相近的数据切分到一组。
如上图所示,通过哈希函数,将100GB文件中的所有IP都转换成整数,然后模100,得到多少就进入标号为多少的小文件中。
- 哈希切分时:相同的IP经过哈希函数处理得到的整数必然是相同的,所以也必然会被分到同一个小文件中。
- 虽然会有哈希碰撞的情况,产生碰撞的IP都会在一个小文件中,而不会被分到其他小文件。
经过哈希切分后,每个小文件中统计出现次数最多的IP就是这100GB文件中该IP出现的总次数。最后再从每个小文件中出现次数最多的IP中比较出最终出现次数最多的IP。
但是此时又存在问题,哈希切分并不是均分,也就意味着每个小文件中的IP个数不一样,有的多有的少。如果某个小文件的大小超出1GB怎么办?有两种超出1GB的情况:
- 这个小文件中冲突的IP很多,都是不同的IP,大多数是不重复的,此时无法使用map来统计——需要换一个哈希函数递归切分这个小文件。
- 这个小文件中冲突的IP很多,都是相同的IP,大多数是重复的,此时仍然可以用map来统计——直接统计。
无论是哪种情况,我们先都直接用map去统计,如果是第二种情况,内存就够用,map可以进行统计,而且不会报错。
如果是第一种情况,map就会因为内存不够而插入失败,相当于new节点失败,就会抛异常,此时我们只需要捕获这个异常,然后换一个哈希函数递归切分这个小文件即可。
再看这道海量数据面试题: 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?给出精确算法。(近似算法前面讲了这里就忽略)
- 这个问题和布隆过滤器应用中的问题一样,只是需要给出精确的算法,所以肯定不能使用布隆过滤器,还是需要map来统计。
- 1GB的内存,无法存放下100亿个字符串,所以需要哈希切分。
假设平均每个字符串的大小是50B,那么100亿个字符串就是500GB,所以需要将这500GB哈希切分成1000份,每个小文件才能在内存中进行准确的次数统计。
- 将文件A和文件B各自进行哈希切分为1000个小文件,每个小文件平均大小是0.5GB。
- 然后Ai和Bi去找交集,找1000次就找到了两个文件中的所有交集。
- 如果某个小文件太大,仍然使用上个问题的方法去处理。
找交集的方法有很多,这里就不再详细讲解了,但是需要注意的是,每个小文件Ai和Bi都需要各自去重以后再找交集。
4. 笔试选择题
1. 下面关于位图说法错误的是()
A .位图就是用比特比特位表示一个数据的状态信息
B .通过位图可以求两个集合的交集
C .位图实际是哈希变形思想的一种应用
D .位图可以很方便的进行字符串的映射以及查找
2. 现有容量为10GB的磁盘分区,磁盘空间以簇(cluster)为单位进行分配,簇的大小为4KB,若采用位图法管理该分区的空闲空间,即用一位(bit)标识一个簇是否被分配,则存放该位图所需簇的个数为 ()
A .80
B .320
C .80K
D .320K
3. 下面关于布隆过滤器优缺点说法错误的是()
A .布隆过滤器没有直接存储数据,可以对数据起到保护作用
B .布隆过滤器查找的结果不准确,并不能使用
C .布隆过滤器采用位图的思想表示数据装填,可以节省空间
D .布隆过滤器可能会存在误判,告知数据存在可能是不准确的
4. 下面关于布隆过滤器说法不正确的是()
A .布隆过滤器是一种高效的用来查找的数据结构
B .布隆过滤器弥补了位图不能存储字符串等类型得缺陷
C .可以使用布隆过滤器可以准确的告知数据是否存在
D .布隆过滤器不存储数据本身,是一种紧促的数据结构
答案及解析
1. D
A:正确,位图概念
B:正确,将两个序列分别映射到两个位图上,对两个位图的每个字节进行按位与操作,结果为1 的比特位对应的数据 的就是两个序列的交集
C:正确,位图就是将数据与数据在位图中对应的比特位进行了一一对应,是哈希的一种变形
D:错误,采用位图标记字符串时,必须先将字符串转化为整形的数字,找到位图中对应的比特 位,但是在字符串转 整形的过程中,可能会出现不同字符串转化为同一个整形数字,即冲 突,因此一般不会直接用位图处理字符串。
2. A
10GB = 10*1024*1024K 一个簇大小为4K,
那10GB总共有 10*1024*1024/4 = 10*1024*256个簇
用位图来进行存储时:一个簇占用一个比特位,总共需要10*1024*256个比特位,
10*1024*256 bit = 10*1024*256/8字节 = 320K
一个簇大小为4K,故总共需要320K/4k=80个簇进行存储
10GB/4KB=2.5M,共有2.5M个可分配的簇, 2.5M/8=320KB,
需要320K的字节来标记可分配的簇, 320KB/4KB=80个,
这320KB同样是按4KB一簇在硬盘上存储,所以需要除4K,得80个簇
3. B
A:正确,布隆过滤器底层使用的是位图,没有直接存储数据本身
B:错误,如果可以接受误差,是可以用的
C:正确
D:正确,因为多个元素的比特位上可能有重叠
4. C
A:正确,因为其底层使用的是位图,而位图优势哈希的一种变形
B:正确,布隆过滤器可以映射存储任意类型,只是存在误判的问题
C:错误,布隆过滤器找到数据不存在,则该数据一定不存在,如果说存在,那可能存在, 不存在 一定是准确的,存在时可能会误判
D:正确,因为其底层使用位图,用比特位代表数据存在与否的状态信息,
是一种紧促的数据结构
本章完。
位图和布隆过滤器都是针对数据量很大的情况下使用的数据结构,并且它们不能存放数据本身,只能表示数据存在或者不存在,位图只针对整形,并且不存在误判的情况,布隆过滤器主要针对字符串,但是也可以是其他自定义类型,但是存在误判,可以通过增加哈希函数或者映射一个数据所需要的比特位来降低误判率,但是会消耗更多的空间。
本章主要是介绍哈希思想的应用,位图以及布隆过滤器归根到底还是哈希思想的体现。
下一部分就开始进入C++11的系统学习了。
下一篇:从C语言到C++_33(C++11新特性)initializer_list+右值引用+完美转发+移动构造/赋值