目录
🌇前言:布隆过滤器提出
我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那些已经存在的记录。 如何快速查找呢?
- 用哈希表存储用户记录,缺点:浪费空间
- 用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理了。
- 将哈希与位图结合,即布隆过滤器
1.布隆过滤器概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
2. 实现原理
HashMap 的问题
讲述布隆过滤器的原理之前,我们先思考一下,通常你判断某个元素是否存在用的是什么?应该蛮多人回答 HashMap 吧,确实可以将值映射到 HashMap 的 Key,然后可以在 O(1) 的时间复杂度内返回结果,效率奇高。但是 HashMap 的实现也有缺点,例如存储容量占比高,考虑到负载因子的存在,通常空间是不能被用满的,而一旦你的值很多例如上亿的时候,那 HashMap 占据的内存大小就变得很可观了。
还比如说你的数据集存储在远程服务器上,本地服务接受输入,而数据集非常大不可能一次性读进内存构建 HashMap 的时候,也会存在问题。
布隆过滤器数据结构
布隆过滤器是一个 bit 向量或者说 bit 数组,长这样:
如果我们要映射一个值到布隆过滤器中,我们需要使用多个不同的哈希函数生成多个哈希值,并对每个生成的哈希值指向的 bit 位置 1,例如针对值 “baidu” 和三个不同的哈希函数分别生成了哈希值 1、4、7,则上图转变为:
Ok,我们现在再存一个值 “tencent”,如果哈希函数返回 3、4、8 的话,图继续变为:
值得注意的是,4 这个 bit 位由于两个值的哈希函数都返回了这个 bit 位,因此它被覆盖了。现在我们如果想查询 “dianping” 这个值是否存在,哈希函数返回了 1、5、8三个值,结果我们发现 5 这个 bit 位上的值为 0,说明没有任何一个值映射到这个 bit 位上,因此我们可以很确定地说 “dianping” 这个值不存在。而当我们需要查询 “baidu” 这个值是否存在的话,那么哈希函数必然会返回 1、4、7,然后我们检查发现这三个 bit 位上的值均为 1,那么我们可以说 “baidu” 存在了么?答案是不可以,只能是 “baidu” 这个值可能存在。
这是为什么呢?答案跟简单,因为随着增加的值越来越多,被置为 1 的 bit 位也会越来越多,这样某个值 “taobao” 即使没有被存储过,但是万一哈希函数返回的三个 bit 位都被其他值置位了 1 ,那么程序还是会判断 “taobao” 这个值存在。
总而言之:判断存在可能是假的,不存在一定是真的。
3、布隆过滤器的实现
3.1、基本结构
布隆过滤器 离不开 位图,此时可以搬出之前实现过的 位图结构
既然需要增加 哈希函数,我们可以在模板中添加三个 哈希函数 的模板参数以及待存储的数据类型 K,(K一会传缺省string)
namespace Yohifo
{
template<size_t N,
class K,//(K=std::string)
class Hash1,
class Hash2,
class Hash3>
class BloomFilter
{
public:
//……
private:
Yohifo::bitset<N> _bits; //位图结构
};
}
这三个 哈希函数 的选择是十分重要的,我们在这里提供三种较为优秀的 哈希函数(字符串哈希算法),分别是 BKDRHash
、APHash
以及 DJBHash
struct BKDRHash
{
size_t operator()(const std::string& str)
{
size_t hash = 0;
for (auto e : str)
{
hash = hash * 131 + (size_t)e;
}
return hash;
}
};
struct APHash
{
size_t operator()(const std::string& str)
{
size_t hash = 0;
for (auto e : str)
{
if (((size_t)e & 1) == 0)
{
hash ^= ((hash << 7) ^ (size_t)e ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ (size_t)e ^ (hash >> 5)));
}
}
return hash;
}
};
struct DJBHash
{
size_t operator()(const std::string& str)
{
if (str.empty())
return 0;
size_t hash = 5381;
for (auto e : str)
{
hash += (hash << 5) + (size_t)e;
}
return hash;
}
};
3.2、插入
插入 无非就是利用三个 哈希函数 计算出三个不同的 哈希值,然后利用 位图 分别进行 设置 就好了
void set(K& key)
{
size_t HashI1 = Hash1()(key) % N; //% N 是为了避免计算出的哈希值过大
_bits.set(HashI1);
size_t HashI2 = Hash2()(key) % N;
_bits.set(HashI2);
size_t HashI3 = Hash3()(key) % N;
_bits.set(HashI3);
}
3.3、查找
查找 某个字符串时,需要判断它的每个 哈希值 是否都存在,如果有一个不存在,那么这个字符串必然是不存在的
bool test(const K& key)
{
//过滤不存在的情况,至于是否存在,还得进一步判断
size_t HashI1 = Hash1()(key) % N;
if (_bits.test(HashI1) == false)
return false;
size_t HashI2 = Hash2()(key) % N;
if (_bits.test(HashI2) == false)
return false;
size_t HashI3 = Hash3()(key) % N;
if (_bits.test(HashI3) == false)
return false;
//经过层层过滤后,判断字符串可能存在
return true;
}
4.布隆过滤器的查找
布隆过滤器的思想是将一个元素用多个哈希函数映射到一个位图中,因此被映射到的位置的比特位一定为1。所以可以按照以下方式进行查找:分别计算每个哈希值对应的比特位置存储的是否为零,只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中。
注意:布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可能存在,因为有些哈希函数存在一定的误判。
比如:存储了 “腾讯”,“阿里”,“百度”,并没有存储“字节”,但是如果检查会发现“字节”也存在,出现误判。
5.布隆过滤器删除
布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。
比如:删除上图中"腾讯"元素,如果直接将该元素所对应的二进制比特位置0,“百度”,”阿里“,元素也被删除了,因为这两个元素在多个哈希函数计算出的比特位上刚好有重叠。
一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作。
缺陷:
- 1. 无法确认元素是否真正在布隆过滤器中
- 2. 存在计数回绕
6.如何选择哈希函数个数和布隆过滤器长度
很显然,过小的布隆过滤器很快所有的 bit 位均为 1,那么查询任何值都会返回“可能存在”,起不到过滤的目的了。布隆过滤器的长度会直接影响误报率,布隆过滤器越长其误报率越小。
另外,哈希函数的个数也需要权衡,个数越多则布隆过滤器 bit 位置位 1 的速度越快,且布隆过滤器的效率越低;但是如果太少的话,那我们的误报率会变高。
k 为哈希函数个数,m 为布隆过滤器长度,n 为插入的元素个数,p 为误报率
如何选择适合业务的 k 和 m 值呢,这里直接贴一个公式:
如何推导这个公式这里只是提一句,因为对于使用来说并没有太大的意义,你让一个高中生来推会推得很快。k 次哈希函数某一 bit 位未被置为 1 的概率为:
7、测试
接下来测试一下 布隆过滤器 是否有用
void TestBloomFilter1()
{
BloomFilter<100> bf; //最大值为 100 的布隆过滤器
bf.set("aaaaa");
bf.set("bbbbb");
bf.set("ccccc");
bf.set("ddddd");
bf.set("eeeee");
std::cout << "bbbbb: " << bf.test("bbbbb") << std::endl;
std::cout << "ddddd: " << bf.test("ddddd") << std::endl;
std::cout << "============" << std::endl;
std::cout << "aaaa: " << bf.test("aaaa") << std::endl; //相似字符串
std::cout << "CCCCC: " << bf.test("CCCCC") << std::endl;
std::cout << "zzzzz: " << bf.test("zzzzz") << std::endl; //不相似字符串
std::cout << "wwwww: " << bf.test("wwwww") << std::endl;
}
测试方法:插入约 10 w
个字符串(原生),对原字符串进行微调后插入(近似),最后插入等量的完全不相同的字符串(不同),分别看看 原生
与 近似
,原生
与 不同
字符串之间的误判率
template<size_t N,
class K = std::string,
class Hash1 = BKDRHash,
class Hash2 = APHash,
class Hash3 = DJBHash>
class BloomFilter
{
static const int _len = 6; //布隆过滤器的长度
static const int _size = N * _len; //位图的大小
public:
void set(const K& key)
{
size_t HashI1 = Hash1()(key) % _size; //% N 是为了避免计算出的哈希值过大
_bits.set(HashI1);
size_t HashI2 = Hash2()(key) % _size;
_bits.set(HashI2);
size_t HashI3 = Hash3()(key) % _size;
_bits.set(HashI3);
}
bool test(const K& key)
{
//过滤不存在的情况,至于是否存在,还得进一步判断
size_t HashI1 = Hash1()(key) % _size;
if (_bits.test(HashI1) == false)
return false;
size_t HashI2 = Hash2()(key) % _size;
if (_bits.test(HashI2) == false)
return false;
size_t HashI3 = Hash3()(key) % _size;
if (_bits.test(HashI3) == false)
return false;
//经过层层过滤后,判断字符串可能存在
return true;
}
private:
Yohifo::bitset<_size> _bits; //位图结构
};
误判率降至 5%
左右
8.布隆过滤器小结
布隆过滤器优点
- 1. 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无
- 关
- 2. 哈希函数相互之间没有关系,方便硬件并行运算
- 3. 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
- 4. 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
- 5. 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
- 6. 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
布隆过滤器缺陷
- 1. 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再
- 建立一个白名单,存储可能会误判的数据)
- 2. 不能获取元素本身
- 3. 一般情况下不能从布隆过滤器中删除元素
- 4. 如果采用计数方式删除,可能会存在计数回绕问题
8、海量数据面试题(哈希切割)
1、题目一
给两个文件,分别有 100 亿个 query,我们只有 1 GB 内存,如何找到两个文件交集?分别给出
精确算法和近似算法
query 指 查询语句,比如 网络请求、SQL 语句等,假设一个 query 语句占 50 Byte,单个文件中的 100 亿个 query 占 500 GB 的空间,两个文件就是 1000 GB
下面来看看解法
近似解法:借助布隆过滤器,先存储其中一个文件的 query 语句,这里给每个 query 语句分配 4 比特位,100 亿个就占约 1 GB 的内存,可以存下,存储完毕后,再从另一个文件读取 query 语句,判断是否在 布隆过滤器 中,“在” 的就是交集。因为 布隆过滤器 判断 “在” 不准确,符合题目要求的 近似算法
精确解法:对于这种海量数据,需要用到哈希分割,我们这里把单个文件(500 GB 数据)分割成 1000 个小文件,平均每个文件大小为 512 Mb,再将小文件读取到内存中;另一个文件也是如此,读取两个大文件中的小文件后,可以进行交集查找,再将所有小文件中的交集统计起来,就是题目所求的交集了
此时存在一个问题:如果我们是直接平均等分成 1000 个小文件的话,我们也不知道小文件中相似的 query 语句位置,是能把每个小文件都进行匹配对比,这样未免为太慢了
所以不能直接平均等分,需要使用 哈希分割 进行切分
i = HashFunc(query) % 1000
不同的 query 会得到不同的下标 i,这个下标 i 决定着这条 query 语句会被存入哪个小文件中,显然,一样的 query 语句计算出一样的下标,也就意味着它们会进入下标相同的小文件中,经过 哈希切割 后,只需要将 大文件 A 中的小文件 0 与 大文件 B 中的小文件 0 进行求 交集 的操作就行了,这样能大大提高效率
但是,此时存在一个 问题:如果因哈希值一致,而导致单个小文件很大呢?
此时如果小文件变成了 1GB、2GB、3GB 甚至更大,就无法被加载至内存中(算法还有消耗)
解决方法很简单:借助不同的哈希函数再分割
即使在同一个小文件中,不同的 query 语句经过不同的 哈希函数 计算后,仍可错开,怕的是 存在大量重复的 query,此时 哈希函数 就无法 分割 了,因为计算出的 哈希值 始终一致
所以面对小文件过大的问题,目前有两条路可选:
- 大多都是相同、重复的 query,无法分割,只能按照大小,放到其他小文件中
- 大多都是不相同的 query,可以使用 哈希函数 再分割
这两条路都很好走,关键在于如何选择?
小文件中实际的情况我们是无法感知的,但可以通过特殊手段得知:探测
对于大于 512 Mb 的小文件,我们可以对其进行读取,判断属于情况1、还是情况2
- 首先准备一个 unorder_set,目的很简单:去重
- 读取文件中的 query 语句,存入 unordered_set 中
- 如果小文件读取结束后,没有发生异常情况,说明属于情况1:大多都是相同、重复的 query 语句,把这些重复率高的数据打散,放置其他 512 Mb 的小文件中
- 如果小文件读取过程中,出现了一个异常,捕获结果为 bad_alloc,说明读取到的大多都是不重复的 query 语句,因为我们内存只有 1 GB,抛出的异常是 内存爆了,异常的抛出意味着这个小文件属于情况2,可以使用其他的 哈希函数 对其进行再分割,分成 512 Mb 的小文件
如此一来,这个文件就被解决了,核心在于:利用哈希切割将数据分为有特性的小文件、利用抛异常得知小文件的实际情况