哈希的应用:布隆过滤器(C++实现)

1. 布隆过滤器

1.1 背景

位图(bitmap算法)告诉我们,想判断一个元素是否存在于某个集合中,如果数据量少,使用搜索树和哈希表是非常快速的。但是一旦元素个数从亿级起步,所需要的内存空间就不足以让这些数据结构发挥作用。位图用一个比特位的0和1表示元素的状态,极大地提高了空间利用率。

然而,位图最大的缺陷是它只能处理整型值,而实际上常常以字符串为key值存储数据,有的人会说,用大佬写的字符串转整型的哈希算法不就好了?结合以前学过的哈希算法,一个字符(char)有256种情况,就算字符是常用的26个英文字母,那它也比数字字符10种情况多。从原理上看,不论通过如何巧妙的哈希函数,字符串转整型发生哈希冲突是不可避免的。

我们一定经历过“该用户名已被占用”这样的场景,常常是输入的过程或者输完一会(甚至不用按回车)就在旁边提示你“已被占用”,这个功能就用到了布隆过滤器。

除此之外,还可以用过滤垃圾网站来理解布隆过滤器:

在黑名单内的垃圾网站毕竟是少数,如果每次都在黑名单里查找,效率太低了,所以可以在数据库之前加上一个布隆过滤器,如果垃圾网站在过滤器中,才会继续在数据库中搜索,这样不在黑名单中的垃圾网站就被过滤掉了。

1.2 概念

布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询。它是位图的优化版本,哈希冲突既然不能避免,那我们就尽量减少出现冲突带来的误判情况。

什么是“冲突带来的误判情况”?

首先,布隆过滤器为了减少哈希冲突(注意是冲突而不是冲突带来的误判),使用了多个哈希函数,将多个位置的比特位设置为1,每一个key值都能用几个比特位表示它的状态。

如何判断一个元素是否存在?

  • 只要它的所有哈希函数得到的比特位上都已经被设置为1,则说明它有可能已经存在。

至于为什么说有可能,请看下面的分析。

image-20221216171659052

像这样就不算误判,因为即使苹果的2、3和香蕉的1、2共用了同一个位置,但是苹果的1和香蕉的3是不同位置,它们就不会构成误判情况。所以真正标识苹果和香蕉是否存在的哈希函数是1和3。

image-20221216172004601

葡萄的每一个哈希函数得出的位置都已经被占用了,但显然它们是被不同元素占用了,而集合中并没有葡萄这个元素,这就是冲突带来的误判情况。

1.3 控制误判率

从原理上,误判情况出现的次数也是不可避免的,只能降低可能性。它取决于两个方面:

  • 哈希函数的个数:哈希函数越多,标识一个元素的哈希函数组合也就越多,出现误判率的可能性越小;
  • 总比特位的个数:决定了哈希函数发生哈希冲突的概率。

但是,它们之间也必须达到平衡,因为哈希函数的个数越多,多次计算会导致效率降低,而布隆过滤器的长度太长也会影响效率。

有人给出了哈希函数个数和布隆过滤器长度在保证效率的情况下最均衡的关系:
m = − ( n l n p ) / ( l n 2 ) 2 m = - (nlnp)/(ln2)^2 m=(nlnp)/(ln2)2

k = ( m / n ) l n 2 k = (m/n)ln2 k=(m/n)ln2

其中:

  • k:哈希函数个数;
  • m:布隆过滤器长度;
  • n:插入的元素个数;
  • p:误判率。

当哈希函数个数k=3时,取 l n 2 ln2 ln2=0.7,可以得出布隆过滤器长度m和插入元素个数n之间的关系:m=4n,即布隆过滤器长度为插入元素个数4倍。

2. 实现布隆过滤器

2.1 布隆过滤器类

  • 首先底层用STL中的bitset容器实现,所以需要模板参数N,用于规定布隆过滤器长度。

  • 由于处理的数据类型不一定每次都是字符串类型,所以用模板参数K表示,而字符串类型又是常客,所以赋予数据类型模板参数K以缺省值为string类型。

  • 其次模板参数中还要添加3个哈希函数,都各自缺省地赋予处理string类型的哈希函数。

template<size_t N,
class K = string, class Hash1 = BKDRHash, class Hash2 = APHash, class Hash3 = DJBHash>
class BloomFilter
{
    
    
	// ...
private:
	bitset<N> _bs;
};

由于K的缺省值是string,所以默认的哈希函数也要是处理字符串的。

BKDRHash、APHash和DJBHash是个最有效的哈希函数,它们都使用仿函数实现:

struct BKDRHash
{
    
    
	size_t operator()(const string& s)
	{
    
    
		size_t value = 0;
		for (auto ch : s)
		{
    
    
			value = value * 131 + ch;
		}
		return value;
	}
};
struct APHash
{
    
    
	size_t operator()(const string& s)
	{
    
    
		size_t value = 0;
		for (size_t i = 0; i < s.size(); i++)
		{
    
    
			if ((i & 1) == 0)
			{
    
    
				value ^= ((value << 7) ^ s[i] ^ (value >> 3));
			}
			else
			{
    
    
				value ^= (~((value << 11) ^ s[i] ^ (value >> 5)));
			}
		}
		return value;
	}
};
struct DJBHash
{
    
    
	size_t operator()(const string& s)
	{
    
    
		if (s.empty())
			return 0;
		size_t value = 5381;
		for (auto ch : s)
		{
    
    
			value += (value << 5) + ch;
		}
		return value;
	}
};

2.2 Set

  • Set:插入元素到布隆过滤器中。

插入元素时,需要通过三个哈希函数分别计算出元素对应的三个比特位的序号,然后复用bitset的set接口将位图中的这三个比特位设置为1即可。

void Set(const K& key)
{
    
    
    size_t hash1 = Hash1()(key) % N;
    size_t hash2 = Hash2()(key) % N;
    size_t hash3 = Hash3()(key) % N;

    _bs.set(hash1);
    _bs.set(hash2);
    _bs.set(hash3);
}

2.3 Test

  • Test:查找某个元素是否在某个集合中。

同样地,通过三个哈希函数分别计算出元素对应的三个比特位的序号,然后复用bitset的test接口,判断位图中的这三个比特位是否被设置为1。

  • 只要三个比特位中有一个比特位未被设置为1则说明该元素一定不存在;
  • 如果三个比特位全部被设置,返回true。

注意:

三个比特位全部被设置为1并不代表这个元素一定存在于这个集合中,以最开始的葡萄例子就可以知道,不过经过两层的优化(哈希函数减少冲突,多个哈希函数减少误判),误判率已经很低了。

也就是说:

  • 返回false,不存在就是一定不存在,是准确的;
  • 返回true,并不是100%存在,可能存在误判。
bool Test(const K& key)
{
    
    
    size_t hash1 = Hash1()(key) % N;
    if (_bs.test(hash1) == false)
    {
    
    
        return false;				// 准确
    }

    size_t hash2 = Hash2()(key) % N;
    if (_bs.test(hash2) == false)
    {
    
    
        return false;				// 准确
    }

    size_t hash3 = Hash3()(key) % N;
    if (_bs.test(hash3) == false)
    {
    
    
        return false;				// 准确
    }

    return true;					// 可能误判
}

2.4 删除

布隆过滤器一般不支持删除操作:

  1. 首先从原理上删除操作会直接影响哈希函数的结果,那么每一次删除都要重新把这个容器中的所有元素重新再映射一遍,影响了其他元素,降低效率。
  2. 其次布隆过滤器要删除一个元素,首先要保证它是真正存在这个集合中的,但是误判是无法避免的,所以删除有一定的风险。

要让布隆过滤器支持删除,必须要满足:

  1. 为每一个比特位增加一个引用计数,当在一个位置上增加一个元素,引用计数+1,反之-1,这样删除就不会改变布隆过滤器的长度,也就不会影响其他元素。但是这违背了布隆过滤器(位图)本身的应用场景:省空间+快速查询。

  2. 当使用Test接口得知元素可能存在于映射以后的布隆过滤器中,再进一步去原始文件验证这个元素是否存在于集合中。但这就像从内存中突然跳到磁盘文件中查找,文件IO和磁盘IO相对于内存而言是很慢的,所以还是降低了效率。

结合上面两点,再加上布隆过滤器本身的应用就是为了查询,而删除对它而言不痛不痒,因为位图这个容器是直接在内存中操作比特位的,即使有很多剩下的没用的元素,对计算机而言它也只是几个比特位,这是无关痛痒的。

3. 优点

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

4. 缺陷

  • 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再自建一个白名单,存储可能会误判的数据)
  • 不能获取元素本身。
    本身,在某些对保密要求比较严格的场合有很大优势。
  • 在能够承受一定的误判时,布隆过滤器比其他数据结构有着很大的空间优势。
  • 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能。
  • 使用同一组哈希函数的布隆过滤器可以进行交、并、差运算。

4. 缺陷

  • 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再自建一个白名单,存储可能会误判的数据)
  • 不能获取元素本身。
  • 一般情况下不能从布隆过滤器中删除元素。

猜你喜欢

转载自blog.csdn.net/m0_63312733/article/details/128358787