Bootstrap

C++第28课-布隆过滤器的介绍

目录

1.布隆过滤器概念

2. 实现原理

3、布隆过滤器的实现

3.1、基本结构

 3.2、插入

3.3、查找

 4.布隆过滤器的查找

5.布隆过滤器删除

6.如何选择哈希函数个数和布隆过滤器长度

7、测试

8.布隆过滤器小结

布隆过滤器优点

布隆过滤器缺陷

8、海量数据面试题(哈希切割)

1、题目一


🌇前言:布隆过滤器提出

我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那些已经存在的记录。 如何快速查找呢?

  1. 哈希表存储用户记录,缺点:浪费空间
  2.  用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理了。
  3. 将哈希与位图结合,即布隆过滤器

 

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;	//位图结构
	};
}

这三个 哈希函数 的选择是十分重要的,我们在这里提供三种较为优秀的 哈希函数(字符串哈希算法,分别是 BKDRHashAPHash 以及 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. 1. 无法确认元素是否真正在布隆过滤器中
  2. 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,此时 哈希函数 就无法 分割 了,因为计算出的 哈希值 始终一致

所以面对小文件过大的问题,目前有两条路可选:

  1. 大多都是相同、重复的 query,无法分割,只能按照大小,放到其他小文件中
  2. 大多都是不相同的 query,可以使用 哈希函数 再分割

这两条路都很好走,关键在于如何选择?
小文件中实际的情况我们是无法感知的,但可以通过特殊手段得知:探测

对于大于 512 Mb 的小文件,我们可以对其进行读取,判断属于情况1、还是情况2

  1. 首先准备一个 unorder_set,目的很简单:去重
  2. 读取文件中的 query 语句,存入 unordered_set 中
  3. 如果小文件读取结束后,没有发生异常情况,说明属于情况1:大多都是相同、重复的 query 语句,把这些重复率高的数据打散,放置其他 512 Mb 的小文件中
  4. 如果小文件读取过程中,出现了一个异常,捕获结果为 bad_alloc,说明读取到的大多都是不重复的 query 语句,因为我们内存只有 1 GB抛出的异常是 内存爆了,异常的抛出意味着这个小文件属于情况2,可以使用其他的 哈希函数 对其进行再分割,分成 512 Mb 的小文件

如此一来,这个文件就被解决了,核心在于:利用哈希切割将数据分为有特性的小文件、利用抛异常得知小文件的实际情况

;