哈希的应用——布隆过滤器

文章目录

前言

上一篇文章,我们学习了位图,位图在某些场景下是非常适用的,非常快捷方便。
但是,在文章的最后,我们也提出了位图的一些缺陷------比如位图只能映射整型数据,其它类型的数据则不行。
因为位图里面的元素去映射的其实就是下标嘛,而下标的话都是整型啊。

那有没有什么 办法可以解决呢?

这就是我们今天要学的布隆过滤器(Bloom Filter)

1. 布隆过滤器提出

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

那这就涉及到一个问题:面对海量的数据,如何进行快速的查找筛选呢?

  1. 用哈希表或红黑树存储用户记录,缺点:空间问题,因为它们除了存储数据之外还有额外存储一些指针,结点颜色这些东西,而且数据量过大的时候可能直接就存不下了。
  2. 用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理了。(这也是我们上面提到的问题)
  3. 就是我们这篇文章要重点学的------将哈希与位图结合,即布隆过滤器(不仅可以提升查询效率,也可以节省大量的内存空间)

2. 布隆过滤器概念

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

那接下来我们就来详细讲解一下布隆过滤器

3. 布隆过滤器的插入

上面提到布隆过滤器其实就是用哈希函数把数据映射到位图结构中。

现在有这样一个位图结构:

例如现在我们要插入一些元素------"百度"、"美团"、"Google",一些字符串,那字符串没法直接映射到位图中,怎么办?

那这没什么难的,我们直接玩过的东西,可以搞一个仿函数把字符串转成整型,然后就可以往位图里面映射了。

那转成整型之后确实可以映射了,但是有没有存在什么问题呢?

是不是会存在冲突 啊。
我们的位图之所以没有考虑冲突的问题因为我们说了位图适用的是海量数据,数据无重复的场景,而且位图是一种直接定址的映射。通常是用来判断某个数据存不存在的。

那我们可以使用字符串哈希等一些方法减少冲突,当然不能完全避免

而且字符串往整型的映射本身就是一个大范围到小范围的映射。
就比如一个长度为10的字符串,大家算一下有多少种?
char有256种取值,那就是256的10次方,而整形只有2^32个。
而且这还只是长度为10的一种情况,那...

多哈希函数映射减少冲突

那布隆过滤器呢采用这样一种方法来进一步的减少冲突:

比如现在我们插入了3个值

这时还没有发生冲突,然后再插入一个值

这时候B站就和美团发生了冲突。
那布隆就想到了这样一个方法来降低冲突:
既然一个值映射一个位置容易发生冲突,那我就用多个哈希函数让一个值同时映射到多个位置,就可以再进一步减少冲突。(因为如果一个元素映射多个位置的话那就需要这多个位置同时被多个元素映射才算冲突)

比如现在我们让每个插入的元素映射2个位置:

那怎么做到映射多个位置呢?
很简单,让一个元素分别通过多个哈希函数计算映射地址就行了,然后将这个多个结果对应的位置都置成1。
这样只有这多个位置都为1,才算这个元素是存在的。

大家看,现在我们让每个元素映射两个位置,这样的话即使某些元素的其中一个映射位置与别人发生了冲突,但是也没有构成冲突。
这样的话冲突的概率就会更小一点。

结构定义及set(插入)函数实现

先来定义一下布隆过滤器的结构:


这里我们给3个哈希函数,实际应用中看具体情况。N代表插入的数据个数。

那我们来写一下set:

那set的话就是用三个哈希函数计算出来三个映射地址,然后把这三个比特位都置成1

那我们可以找三个字符串哈希用一下(大家可以自己去网上查找)

cpp 复制代码
struct BKDRHash
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			hash += ch;
			hash *= 31;
		}

		return hash;
	}
};

struct APHash
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (long i = 0; i < s.size(); i++)
		{
			size_t ch = s[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& s)
	{
		size_t hash = 5381;
		for (auto ch : s)
		{
			hash += (hash << 5) + ch;
		}
		return hash;
	}
};

然后模板参数的地方,我们可以直接给缺省值:


K我们默认给一个string类型,布隆过滤器面对的场景大多都是字符串。

4. 布隆过滤器的查找

test(查找)函数实现

那我们查找的时候如何判断一个元素在不在呢?

那其实就是去判断它映射的位置是否都置成了1就行了。
如果全部为1,就是在,如果有一个不为1,就是不存在。

布隆过滤器允许误判

那这里我要问大家一个问题:判断在和不在那种情况会存在误判?

🆗,要告诉大家的是,对于布隆过滤器来说:
判断在是可能不准确的,可能会误判;而判断不在是一定准确的。

为什么呢?

如果是不在的话,那么只要有一个映射的位置为0,那他就一定不在,这是不会出错的。
而判断在的话,就可能出现这样的情况:

大家看,上面的4个字符串是已经插入到布隆过滤器里面的值,已经把它们映射的位置都设置成了1.
现在有一个待插入元素"腾讯",还没有插入,但是它映射的几个位置已经被之前插入的其它元素设置为1了。
那这时我们去查找"腾讯"的话,实际它是没有插入的,但是test的时候,会发现它映射的几个位置都已经被set成了1,那这时候就会误判"腾讯"是存在的。

总结一下:

布隆过滤器的思想是将一个元素用多个哈希函数映射到一个位图中,因此被映射到的位置的比特位一定为1。
所以可以按照以下方式进行查找:分别计算每个哈希值对应的比特位置存储的是否为零,只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中。
注意:布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可能存在,可能并不存在,因为这里可能发生误判。

5. 布隆过滤器的适用场景

那正由于会出现上面误判情况的原因,所以布隆过滤器的适用场景是有限的,即它适用于一些允许误判的场景

比如:

我们下载一个游戏,比如说王者荣耀,然后我们注册一个新账号的时候要给自己起一个昵称,或者使用什么改名卡修改昵称的时候
可能会出现这样的场景

我们输入一个昵称之后,系统提示你这个昵称已经存在了,让你换一个。
那这种情况就是允许你误判的,你输入一个昵称之后,系统提示已经存在,那这里就有两种情况:
第一种就是真的已经被用过了;
第二种就是实际没有被使用过,但是它误判了。
那这种情况即使它误判了,其实也没什么影响,因为对于用户来说,他也不知道自己输入的昵称到底有没有被用过,系统提示被用过了,那用户就会认为真的被用过了(即使是误判了),就再换一个。

那这种场景用布隆过滤器其实就挺合适的:

假如这个游戏现在有10亿用户,这些用户的数据(可能包括昵称、账号、密码这些东西)存储在数据库里面,数据库通常存在磁盘上。
那我们去查找判断的时候为什么不直接去数据库查找呢?
因为太慢了,效率太低。
所以就可以这样做:

我们就把所有的昵称存到布隆过滤器里面,然后用户注册新的昵称或者修改昵称的时候,就可以快速的反馈给用户昵称是否存在。

那大家想手机号码这样的信息能不能也存到布隆里面?

如果是手机号码的话有没有注册过用户自己是不是应该知道啊,那如果再误判的话是不是就被用户喷了啊。
但是其实也是可以借助布隆过滤器处理的,而且这种情况反而更能体现布隆"过滤器"的价值。
怎么做呢?
还是把用户的手机号都放到布隆里面

然后新用户注册的时候,如果这个手机号不在布隆里面,那就可以直接返回,因为我们上面分析了判断不在是一定准确的。
那如果反馈的信息是已存在,那这时候就可能出现了误判,那这时候我们再去数据库里面进行一个确认,然后再返回。

那这样的话其实就可以认为布隆过滤器实现了一个很好的"过滤"的作用。
它能够把大多数不在的情况快速的"过滤"掉,只剩下少数在的情况需要去数据库里面查询确认,那这样与全部到数据库里面查找还是效率高了很多的可以避免不必要的查询操作,节省时间和资源。

所以对于布隆过滤器来说:

相比于传统的 List、Set、Map 等数据结构,它更高效、占用空间更少,但是缺点是其返回的结果是概率性的,而不是确切的。
常见的实践有:利用布隆过滤器减少磁盘 IO 或者网络请求,因为一旦一个值必定不存在的话,我们可以不用进行后续昂贵的查询请求。

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

那大家思考一下,如果我们现在有N个待插入数据,那布隆过滤器底层的位图我们要开多大呢?哈希函数要选择多少个呢?

就开N个吗?好像不行,因为一个值就要映射多个位置啊。
然后哈希函数多一点的话,误判率肯定会小一点,但是哈希函数也不能搞太多,太多的话一个值映射的位置就会变多,那使用的空间就会变大。

那怎么样选择比较合适呢?有人给出了这样的公式:

k 为哈希函数个数,m 为布隆过滤器长度,n 为插入的元素个数,p 为误报率
则:

那按照我们上面写的,我们给了3个哈希函数:

则K为3,然后ln2大概为0.69
那么可以得出

m=4.3*n,即布隆过滤器的长度等于元素个数的4到5倍是比较合适的,一个元素分配4到5个空间

那我们就按照这个关系去改造一下我们实现的布隆过滤器,因为前面我们都没有考虑这个:


相关的地方也要改一下

7. 测试

我们来搞一点数据测试测试

先来看一下set:


每次set我们可以打印一下它映射的3个位置

运行一下

目前我们这些数据是没什么冲突的。

然后test我们也测试一下:



🆗,没问题,不过我们的数据量也比较小。

所以呢,我这里也有一个写好的测试的程序,我们来测试几把:

cpp 复制代码
void test_bloomfilter2()
{
	srand((unsigned int)time(nullptr));
	const size_t N = 10000;
	BloomFilter<N> bf;

	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(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://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html";
		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;

	// 不相似字符串集
	std::vector<std::string> v3;
	for (size_t i = 0; i < N; ++i)
	{
		string url = "https://www.cctalk.com/m/statistics/live/16845432622875";
		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可以控制插入字符串的数量。
三个vector v1、v2、v3里面插入的字符串数量都是N,我们搞了一个url字符串,v1里面插入的都是这个url+to_string(i),i在循环过程是不断变化的嘛。
v1set到一个布隆过滤器里面
v2插入的都是这个url+to_string(999999 + i),所以v2跟v1是相似字符串集,但是不一样
然后v3就用了另一个url+to_string(i + rand()),所以 v3跟v1不相似字符串集
然后我们先后遍历v2,v3,判断它们里面的每个字符串在不在布隆过滤器里面,最后得到两个误判率(分别对应字符串相似和不相似的情况下)

我们来运行一下:

首先N等于1万的时候,我们看一下

大家看,相似不相似都没差多少。但是这个误判率其实还是比较高的,超过了10%。
来10万个试一下(嫌慢可以换成release)

现在基本在10~20%

那我们如何能控制一下这个误判率呢?

可以适当增加哈希函数的数量,但这个有点治标不治本。
我们可以去扩大空间比较好,之前我们设置的倍数是5,那我们扩大到6试一下

🆗,还是有一个明显的下降。
来增到7呢?

会再降低一点,不会再降太多了。
来直接到10

就降低到1%左右了。
所以去扩大这个空间还是比较有效的降低误判率。

8. 布隆过滤器删除(reset)的思考

大家会发现我们上面没有实现reset,布隆过滤器可以置成reset(删除)吗?

🆗,传统的布隆过滤器是不支持删除操作的。

为什么不支持呢?

因为你删除一个元素之后可能会对其它元素的查找造成影响。
举个栗子

这里我们如果先把美团删除的话,那就把美团映射的两个位置置成0嘛,然后查找B站的时候,会发现B站映射的两个位置有一个为0,那就会误判B站是不存在的,但是B站我们并没有删除。

那有没有什么办法能让他支持删除呢?有人提出这样一种方法:

就是不再让布隆过滤器的每个位置存储0或1,而是让它直接存储这个位置被set的次数

这样

那这样我们把美团删除的话,就把它映射两个位置的次数减一,然后再查找B站就不受影响了。
但这样做的话就涉及到你要给每个位置分配几个位的问题,因为你分配的bit数量不同,那它能存储的次数的范围就不同。
所以这样就会存在一些问题:
就是你的位数如果给的不合适,可能某一次次数更新之后就会溢出,造成计数回绕(计数器值增加到达其最大范围后,再次增加会导致计数器值重新回到初始状态),而且这样做使用的空间肯定会变多
除此之外还存在一个问题:
就是我们查找一个元素的时候无法确认该元素是否真的存于布隆过滤器中。
因为我们删除一个元素的时候一定要确保它是存在的,再去删除(减减对应位置的次数),不存在是不能删除的,但是判断一个元素是否在布隆过滤器中是可能误判的。
所以我们在删除一个元素的时候无法确认它是否存在。

所以我觉得不能认为这种计数的方法可以实现删除,可以说它提供了实现删除的可能。

9. 布隆过滤器的优缺点分析

布隆过滤器的优点

  1. 增加和查询元素的时间复杂度为:O(K),(K为哈希函数的个数,一般比较小,所以可以认为是O(1)),与数据量大小无关
  2. 哈希函数相互之间没有关系,方便硬件并行运算
  3. 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
  4. 在能够承受一定的误判时,布隆过滤器比其他数据结构有着很大的空间优势
  5. 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
  6. 使用同一组散列函数的布隆过滤器可以进行交、并、差运算

布隆过滤器的缺陷

  1. 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
  2. 不能获取元素本身
  3. 一般情况下不能从布隆过滤器中删除元素
  4. 如果采用计数方式删除,可能会存在计数回绕问题且无法判断要删除的元素是否存在

10. 源码

bitset.h

cpp 复制代码
#pragma once
#include <vector>

template <size_t N>
class bitset
{
public:
	bitset()
	{
		_bits.resize(N / 8 + 1, 0);
	}

	void set(size_t x)
	{
		size_t i = x / 8;
		size_t j = x % 8;

		_bits[i] |= (1 << j);
	}

	void reset(size_t x)
	{
		size_t i = x / 8;
		size_t j = x % 8;

		_bits[i] &= ~(1 << j);
	}

	bool test(size_t x)
	{
		size_t i = x / 8;
		size_t j = x % 8;

		return _bits[i] & (1 << j);
	}
private:
	vector<char> _bits;
};

BloomFilter.h

cpp 复制代码
#pragma once
#include "bitset.h"
#include <time.h>
#include <string>

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

		return hash;
	}
};

struct APHash
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (long i = 0; i < s.size(); i++)
		{
			size_t ch = s[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& s)
	{
		size_t hash = 5381;
		for (auto e : s)
		{
			hash += (hash << 5) + e;
		}
		return hash;
	}
};


template<size_t N,
class K = string,
class Hash1 = BKDRHash,
class Hash2 = APHash,
class Hash3 = DJBHash>
class BloomFilter
{
public:
	void set(const K& key)
	{
		size_t len = N * _mul;
		size_t hash1 = Hash1()(key) % len;
		_bs.set(hash1);

		size_t hash2 = Hash2()(key) % len;
		_bs.set(hash2); 
		
		size_t hash3 = Hash3()(key) % len;
		_bs.set(hash3);

		//cout << hash1 << " " << hash2 << " " << hash3 << " " << endl << endl;
	}

	bool test(const K& key)
	{
		size_t len = N * _mul;
		size_t hash1 = Hash1()(key) % len;
		if (!_bs.test(hash1))
		{
			return false;
		}

		size_t hash2 = Hash2()(key) % len;
		if (!_bs.test(hash2))
		{
			return false;
		}

		size_t hash3 = Hash3()(key) % len;
		if (!_bs.test(hash3))
		{
			return false;
		}

		return true;
	}

private:
	static const size_t _mul = 6;
	bitset<N*_mul> _bs;
};

void test_bloomfilter1()
{
	BloomFilter<100> bs;
	bs.set("sort");
	bs.set("bloom");
	bs.set("hello world hello bit");
	bs.set("test");
	bs.set("etst");
	bs.set("estt");

	cout << bs.test("sort") << endl;
	cout << bs.test("bloom") << endl;
	cout << bs.test("hello world hello bit") << endl;
	cout << bs.test("etst") << endl;
	cout << bs.test("test") << endl;
	cout << bs.test("estt") << endl;

	cout << bs.test("ssort") << endl;
	cout << bs.test("tors") << endl;
	cout << bs.test("ttes") << endl;
}

void test_bloomfilter2()
{
	srand((unsigned int)time(nullptr));
	const size_t N = 10000;
	BloomFilter<N> bf;

	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(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://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html";
		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 = "https://www.llllll.com/m/statistics/live/16845432622875";
		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;
}

Test.cpp

cpp 复制代码
#define _CRT_SECURE_NO_WARNINGS

#include <iostream>
using namespace std;
#include "BloomFilter.h"

int main()
{
	test_bloomfilter2();
	return 0;
}
相关推荐
Lenyiin8 分钟前
第146场双周赛:统计符合条件长度为3的子数组数目、统计异或值为给定值的路径数目、判断网格图能否被切割成块、唯一中间众数子序列 Ⅰ
c++·算法·leetcode·周赛·lenyiin
郭wes代码15 分钟前
Cmd命令大全(万字详细版)
python·算法·小程序
scan72430 分钟前
LILAC采样算法
人工智能·算法·机器学习
菌菌的快乐生活1 小时前
理解支持向量机
算法·机器学习·支持向量机
大山同学1 小时前
第三章线性判别函数(二)
线性代数·算法·机器学习
axxy20001 小时前
leetcode之hot100---240搜索二维矩阵II(C++)
数据结构·算法
黑客Ash1 小时前
安全算法基础(一)
算法·安全
AI莫大猫2 小时前
(6)YOLOv4算法基本原理以及和YOLOv3 的差异
算法·yolo
taoyong0012 小时前
代码随想录算法训练营第十一天-239.滑动窗口最大值
c++·算法
Uu_05kkq2 小时前
【C语言1】C语言常见概念(总结复习篇)——库函数、ASCII码、转义字符
c语言·数据结构·算法