【C++】哈希的应用 -- 布隆过滤器

一、布隆过滤器的引入

我们在上一节中学习了 位图,知道了位图可以用来快速判断某个数据是否在一个集合中,但是位图有如下的缺点

  1. 位图只适用于数据范围集中的情况,当数据范围分散时,存在空间浪费;
  2. 位图只能针对整形,对于非整形数据它不能处理。

其中位图只能针对整形这一缺陷我们可以想办法解决,其中最常见的方法就是针对某一种特定类型定义一个 HashFunc 函数,将其转化为整形;比如当数据是 string 类型时,我们可以使用字符串哈希算法将字符串转化为整形,然后再将这个整形映射到位图中;

但是这种方法存在一种很大的缺陷 – 不同的字符串通过同一个 HashFunc 函数转换出来的值可能是一样的,也就是说,可能会发生误判 (哈希冲突),在这种情况下:

  • 位图中该字符串存在是不准确的,因为该比特位可能原本为0,但是和其他字符冲突,发生了误判,导致该比特位变为1;
  • 位图中字符串不存在是准确的,因为比特位为0说明该字符串以及可能与该字符串发生冲突的其他字符串都没有插入过,当然前提是不考虑删除的情况。

同时,由于通过字符串哈希函数转换出来的值的范围是不确定的,所以我们通常会对结果进行取模,以此来节省空间,但是取模又会增加哈希冲突的概率,因为不同的整形取模后得到结果可能是一样的。

那么我们如何降低误判率呢?此时布隆过滤器就登场了。

布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,其特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间

可以看到,布隆过滤器通过使用多个哈希函数的方法来降低误判率,即让同一个元素映射多个下标位置,在查询时只有当这些位置都为1时才表示该元素存在,而同一元素通过不同哈希函数映射出的不同下标同时被误判的概率肯定是比一个下标位置被误判的概率要低很多的。image-20230412004456684

特别注意:布隆过滤器只能降低误判率,而不能彻底消除误判。


二、哈希函数个数的选择

那么是不是映射的下标位置越多越好呢?当然不是,因为一个元素映射的下标位置越多,那么浪费的空间也就越多;所以有的大佬就针对如何选择哈希函数个数和布隆过滤器长度专门写了一篇博客,大家可以参考参考:详解布隆过滤器的原理,使用场景和注意事项 - 知乎 (zhihu.com)

其博客中给出了哈希长度、布隆过滤器长度、插入元素个数与误判率的关系图:image-20230412001109387

以及它们之间的关系式:image-20230412001758953

对上面的K取值进行带入可得:

  • k == 3 时,m ≈ 4.3 n;即一个元素要消耗四个左右的比特位;
  • k == 5 时,m ≈ 7.2 n;即一个元素要消耗七个左右的比特位;
  • k == 8 时,m ≈ 11.6 n;即一个元素要消耗12个左右的比特位;

结合关系图和关系表达式可以看出,哈希函数的个数取 3~5 个是比较合适的,取8的话空间消耗有点大,但是忍忍其实也能接受。


三、布隆过滤器的实现

布隆过滤器的实现其实很简单,位图直接使用库中的 bitset 即可,字符串哈希算法可以从下面这篇博客介绍的算法里面挑选几个得分比较高的:各种字符串Hash函数 - clq - 博客园 (cnblogs.com)

代码实现如下:

#pragma once

#include <bitset>
#include <string>
using std::bitset;
using std::string;

struct BKDRHash {
    
    
	size_t operator()(const string& s) {
    
    
		size_t value = 0;
		for (auto ch : s) {
    
    
			value *= 131;
			value += ch;
		}
		return value;
	}
};

struct APHash {
    
    
	size_t operator()(const string& s) {
    
    
		size_t hash = 0;
		for (size_t i = 0; i < s.size(); i++) {
    
    
			if ((i & 1) == 0) {
    
    
				hash ^= ((hash << 7) ^ s[i] ^ (hash >> 3));
			}
			else {
    
    
				hash ^= (~((hash << 11) ^ s[i] ^ (hash >> 5)));
			}
		}
		return hash;
	}
};

struct DJBHash {
    
    
	size_t operator()(const string& s) {
    
    
		size_t hash = 5381;
		for (auto ch : s) {
    
    
			hash += (hash << 5) + ch;
		}
		return hash;
	}
};

template<size_t N,	//数据范围
	size_t X = 5,    //每个元素最多消耗的比特位的位数
	class K = string,    //数据类型
	class HashFunc1 = BKDRHash,   //哈希函数
	class HashFunc2 = APHash,
	class HashFunc3 = DJBHash>
	class BloomFilter {
    
    
	public:
		void set(const K& key) {
    
    
			size_t hash1 = HashFunc1()(key) % (N * X);
			size_t hash2 = HashFunc2()(key) % (N * X);
			size_t hash3 = HashFunc3()(key) % (N * X);

			//将三个位置的比特位都置1才表示插入key
			_bs.set(hash1);
			_bs.set(hash2);
			_bs.set(hash3);
		}

		bool test(const K& key) {
    
    
			size_t hash1 = HashFunc1()(key) % (N * X);
			size_t hash2 = HashFunc2()(key) % (N * X);
			size_t hash3 = HashFunc3()(key) % (N * X);

			//key映射的三个下标位置都为真才表示key可能存在
			//也可能不存在,此时映射的位置全部冲突,从而发生误判
			if (_bs.test(hash1) && _bs.test(hash2) && _bs.test(hash3)) {
    
    
				return true;
			}

			return false;
		}
	private:
		bitset<N* X> _bs;
};

在我们上面的实现中,第一个模板参数N为数据的范围,第二个X为每一个数据最多占用多少个比特位,它与哈希函数的个数有关,由于我们实现的版本中默认使用的是三个哈希函数,所以X的缺省值为5,但我们也可以显示传递X的值来增加/减少哈希冲突的概率,最后三个模板参数分别为三个哈希函数,这里我们使用的字符串哈希算法分别为BKDRHash、APHash 和 DJBHash;对程序进行简单测试结果如下:

image-20230412011831223

image-20230412011900623

在上面的测试程序中,由于每次产生的数是随机的,所以测试结果有时会发生误判,有时不会发生误判。

现在我们加大测试用例,并分别构造相似字符串集和不相似字符串集来分别测试其误判率,测试代码如下:

void BloomFilter_test2()
{
    
    
	srand(time(0));
	const size_t N = 100000;
	BloomFilter<N> bf;

	std::vector<std::string> v1;
	std::string url = "https://coder-yzpq.blog.csdn.net/?type=blog";

	for (size_t i = 0; i < N; ++i)
	{
    
    
		v1.push_back(url + std::to_string(i));
	}

	for (auto& str : v1)
	{
    
    
		bf.set(str);
	}

	// v2跟v1是相似字符串集
	std::vector<std::string> v2;
	for (size_t i = 0; i < N; ++i)
	{
    
    
		std::string url = "https://coder-yzpq.blog.csdn.net/?type=blog";
		url += std::to_string(999999 + i);
		v2.push_back(url);
	}

	size_t n2 = 0;
	for (auto& str : v2)
	{
    
    
		if (bf.test(str))
		{
    
    
			++n2;
		}
	}
	cout << "相似字符串误判率:" << (double)n2 / (double)N << endl;

	// v3跟v1是不相似字符串集
	std::vector<std::string> v3;
	for (size_t i = 0; i < N; ++i)
	{
    
    
		string url = "csdn.com";
		url += std::to_string(i + rand());
		v3.push_back(url);
	}

	size_t n3 = 0;
	for (auto& str : v3)
	{
    
    
		if (bf.test(str))
		{
    
    
			++n3;
		}
	}
	cout << "不相似字符串误判率:" << (double)n3 / (double)N << endl;
}

n == 10 万, k == 3, X == 5 时,测试结果如下:image-20230412014241859

n == 10 万, k == 3, X == 8 时,测试结果如下:image-20230412014107700

n == 10 万, k == 3, X == 12 时,测试结果如下:image-20230412014151090

n == 100万, k == 3, X == 12 时,测试结果如下:image-20230412014550862

从这些测试结果中可以看出,布隆过滤器虽然存在误判的情况,但其误判率是可控的 – 我们可以根据具体的应用场景来测试调整哈希函数的个数以及布隆过滤器的长度,最终实现出最符合当前应用场景的布隆过滤器

布隆过滤器的删除布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素;但是我们也可以使用计数的方式强行让其支持删除操作,即使用多个位图来标记某一个元素出现的次数,其思路和 位图 中查找出现一次或两次的元素的思路一样,不过这里还存在一个问题 – 我们不知道元素最多的出现次数为几,所以无法确定要使用几个位图来标记一个元素;所以如果不是在某些特殊场景下布隆过滤器是不支持删除操作的。


四、布隆过滤器的应用

布隆过滤器适用于不需要完全准确,允许出现一定误判的场景,例如如下场景:

  • 用户注册时的昵称判重:某些网站在注册不允许出现重复昵称,而已注册的昵称都保存在服务器的数据库中,因为数据库存放在磁盘上,访问速度非常慢,所以如果用户每选择一个昵称都去数据区中查找是否已经存在的话其效率就会非常低,并且在实际中用户昵称已存在的概率是比较大的

    这时我们就可以在服务器前面加一个布隆过滤器进行过滤 – 将所有已注册的昵称都映射到布隆过滤器中,如果该昵称没被注册,则该昵称不在布隆中,而不在是一定准确的,此时允许用户使用该昵称;如果该昵称在布隆中,说明该昵称已被使用,则提示用户重新输入;尽管昵称在可能会发生误判,但这并不影响用户的使用,仅仅相当于发生误判的昵称不允许被任何人使用而已;我们也可以当在时再去数据库中查找存在该昵称是否真的存在,从而保存查询结果的完全准确,但在此场景下是没必要的。

    如上,我们通过添加一个布隆过滤器就能过滤掉大部分无用的查询请求,从而有效提高服务器的性能。(注:在实际的联网软件中此方法不可行,因为可能存在多个用户在不同的客户端同时注册相同昵称的场景,此方法只适用于单机的场景,但这里也仅仅是用其举例而已)

  • 查询个人数据:比如我们要在公司的客户资料数据库中以身份证号码为key值查找某一个客户的具体信息,由于直接访问数据库效率非常低,且大部分情况下该身份证号码所对应的客户都不在公司的数据库中;

    所以我们可以选择在公司服务器前面加一个布隆过滤器,其中映射了所有公司客户的身份证号码,当我们进行查询时先到布隆过滤器中进行查询,如果不在则直接返回不在,且返回结果一定是准确的;如果在那么结果不一定准确,我们还需要进一步到服务器的数据库中去查找该客户,如果查找成功就返回该客户的所有资料,如果发生误判即不在仍然返回不在即可;这样我们也能有效提高服务器的性能。

在实际开发中布隆过滤器的应用场景还有许多,比如网站黑名单的设计等;所以布隆过滤器在实际开发中是比较重要的,在面试时被考察的也比较多,大家需要理解它的原理,特别是布隆过滤器到底是在是正确的还是不在是准确的,大家必须要能够正确回答并且清晰阐释这个问题。


五、布隆过滤器总结

布隆过滤器的引出

  • 解决位图只能处理整形和数据范围集中的缺陷 – 哈希函数和取模,但这样会导致哈希冲突从而发生误判,为了降低误判率我们需要合理选择哈希函数的个数以及布隆过滤器的长度。

布隆过滤器的优点

  • 增加和查询元素的时间复杂度为 O(K),与数据量大小无关;(K为哈希函数的个数,一般都不会超过10)
  • 不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势;
  • 在允许一定误判率的场景中,具有很大的空间优势和时间优势;
  • 数据量很大时,布隆过滤器可以表示全集;
  • 使用同一组散列函数的布隆过滤器可以进行交、并、差运算,从而实现计数功能。

布隆过滤器的缺点

  • 有一定的误判率,即存在假阳性,不能准确判断元素是否在集合中,但误判率是可控的;(补救方法:建立一个白名单,其中存储可能会误判的数据)
  • 不能获取元素本身;
  • 一般情况下不能从布隆过滤器中删除元素;
  • 如果采用计数方式进行删除,会存在空间浪费,还可能会存在计数回绕问题。(计数回绕是指在计数的过程中,当计数器达到其最大值之后,继续累加将导致计数器值回到零)

最后,给出一道与布隆过滤器相关的面试题:给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法。

解析:这道题和上一节 位图 中求IP地址个数那道题一样,都是考察哈希切割 – 使用相同的哈希函数分别对这两个文件进行切割,切割结果为 A0 ~ Ai,B0 ~Bi,因为哈希函数相同,所以 Ai 和 Bi 中相同的 query 及发生冲突的 query 都在同一个小文件中,此时我们只需要分别求出 Ai 和 Bi 相同下标小文件中的交集即可,需要注意的是,如果小文件很大,说明某一个或某几个 query 有大量重复,此时换一个哈希函数再分别对 Ai 和 Bi 小文件递归子问题进行哈希切割即可;

对于精确算法来说,我们需要先将 Ai 号小文件中的元素全部存入 set/map 中,再依次取 Bi 号小文件中的数据到 set/map 中查询即可得到交集,注意结果需要去重;

对于近似算法来说,我们可以先将 Ai 号小文件中的元素全部映射到一个布隆过滤器中,然后再依次取 Bi 号小文件中的数据到布隆过滤器中查询即可得到交集,注意结果也需要去重。


猜你喜欢

转载自blog.csdn.net/m0_62391199/article/details/130137184
今日推荐