位图和布隆过滤器

一.位图

   我们先来看一道题:


   给定40亿个不重复的正整数,如何快速判断一个数组是否在这40亿个数中。

   以我们目前的思维,我们肯定是只能想到以下几种方法:

一    直接遍历,时间复杂度O(n)

二    二分查找,时间夫复杂度O(nlogn)
三    放入某种数据结构,如红黑树等等,时间复杂度最好O(logn)

但是无论是哪种方法,对空间的需求都是极大的, 40亿个整数 ≈ 14个g的内存  ,像我们平常电脑内存也就是16个 g或者说 32个g的内存,并且还有很多小内存电脑,可能很难应对如此大的空间需求。那么此时就可以引入位图。

1.1 位图的概念

  实际上,位图的本质也是一种数据结构。

  其作用是通过每一个bit位来标识每一种状态(其实也是哈希思想的一种体现),适合存储海量的数据,整数,数据无重复的场景, 一般是用来判断某一个数据是否存在的。

  比如上面那个例子,如果用每一个bit位来表示,一个整数原本是四个字节,总共需要4∗4000000000/1024/1024/1024,大约是占用了15G的内存,

而如果使用一个比特位标记这40亿个正整数存在不存在,4000000000*4/32/1024/1024,大约就是480M的内存。这个内存占用直接降低了一个数量级。

1.2 位图的原理  

  一个数据是否存在也就两种状态,在或者不在,通过二进制的0和1就可以表示。位图正是利用了这个特性,因为用一个bit位恰好可以表示这两种状态。 

  但因为我们在计算机语言中,无法直接拿取一个bit位,因此我们需要经过一些特殊的转换,进而拿到一个bit位。

1.3 位图的基本实现

  那么我们可以猜想,实际上位图的底层就是一个数组,但是我们需要通过一些特殊的转换,拿到数组里面的每一个bit位。 

  这里我们建议底层使用 char 数组,因为int类型会存在某些大小端问题,但是char 数组不会存在。

namespace BitMap {
	 
	template
	class BitMap {
		
	private:
		vector _tables;
	};
}

  •  使用非类型模板参数,该参数用来指定位图比特位的个数
  •  底层使用的是vector,vector中是char类型变量。

1.4 位图的映射思想

  那么,我们该如何从一个char数组里面,准确的取到一个bit位呢?

  假设我们要插入一个9 我们要先确定它在数组中哪个char,然后再确定它在char 中哪一个位置。

  位图和布隆过滤器_第1张图片

我们可以发现 ,当9/8 时,我们能得到 9应该存在数组中的哪个位置,当9%8时,我们就可以得到9 在 char数组中的某个位置。

因此,我们得出:

  1. 求x在数组中的位置 :x/8
  2. 求x在char中的位置:x%8

1.5 位图的初始化

  我们都知道,位图采用了非类型模板参数,传入的N就代表了位图的大小,那么我们在初始化时,就应该规定好位图的大小。

  根据位图的映射思想,求出N在数组中的位置,也就是数组的大小,但我们需要把N+1 ,因为C++的除法思想,除数会小一个,因此,为了保险,需要把结果+1.

  

namespace BitMap {
	 
	template
	class BitMap {
	public:
		BitMap() {
			//初始化为0 代表没有这个元素
			_tables.resize((N >> 3) + 1, 0);
		}
	private:
		vector _tables;
	};
}

1.6 位图的set函数 

 位图的set函数及其简单,我们根据位图的映射思想求出插入的数字需要映射到哪个位置,然后根据位运算直接将其映射到位图中。

	void set(size_t x)
	{
		//size_t i = x / 8;//映射到第几个char中
		size_t i = x >> 3;
		size_t j = x % 8;//映射到char中第几个比特位
		//将其映射到位图中
		_tables[i] |= (1 << j);
	}

示例,当我们插入9时,我们通过i求出它在哪一个 char中,然后通过j求出它在这个char中的哪一个比特位,同时按照由低到高的顺序,利用左移操作符(将一向高位移动),异或一下当前位置的值。

  从而把当前位置的0异或为1,我们不考虑重复问题(假设没有重复数字)

位图和布隆过滤器_第2张图片

 1.7 位图的reset函数

  位图的reset(删除函数),基本思想上与set函数一致,但注意,这里不可以再使用异或操作符,而是采用在位运算中的清0操作。 

	//清零
	void reset(size_t x)
	{
	
		size_t i = x >> 3;
		size_t j = x % 8;
		//将比特位清0
		_tables[i] &= (~(1 << j));
	}

1.8 位图的test函数

  查找是否存在,这里和reset函数的思想基本一致。

//查找是否存在
bool test(size_t x)
{
	//映射到位图中的位置
	size_t i = x >> 3;
	size_t j = x % 8;

	return _tables[i] & (1 << j);
}

注意:这里的返回值涉及了一个整形提升问题,一个bool 是四个字节,从而产生了整形提升,故没有影响。

1.9 完整代码

template
class BitMap {
public:
	BitMap() {
		//初始化为0 代表没有这个元素
		_tables.resize((N >> 3) + 1, 0);
	}
	//置一
	void set(size_t x)
	{
		//size_t i = x / 8;//映射到第几个char中
		size_t i = x >> 3;
		size_t j = x % 8;//映射到char中第几个比特位
		//将其映射到位图中
		_tables[i] |= (1 << j);
	}

	//清零
	void reset(size_t x)
	{
	
		size_t i = x >> 3;
		size_t j = x % 8;
		//将比特位清0
		_tables[i] &= (~(1 << j));
	}

	//查找是否存在
	bool test(size_t x)
	{
		//映射到位图中的位置
		size_t i = x >> 3;
		size_t j = x % 8;

		return _tables[i] & (1 << j);
	}

private:
	vector _tables;
};

1.10 测试函数

void test1() {
	BitMap<10000> bt;

	bt.set(1);
	bt.set(7);
	bt.set(100);
	bt.set(2);
	
	cout << bt.test(1) << endl;
	cout << bt.test(7) << endl;
	cout << bt.test(100) << endl;
	cout << bt.test(2) << endl;

	cout << endl << endl;
	bt.reset(1);
	bt.reset(7);
	bt.reset(100);

	cout << bt.test(1) << endl;
	cout << bt.test(7) << endl;
	cout << bt.test(100) << endl;
	cout << bt.test(2) << endl;
}

代码结果为:
位图和布隆过滤器_第3张图片

 当然,除了我们写的这三个函数之外,位图还有很多函数,具体可以看一下库里面的函数:
  https://cplusplus.com/reference/bitset/

1.11 位图的经典习题

  • 问题一:给定100亿个整数,设计算法找到只出现一次的整数?

  首先这个问题,以往我们学过的数据结构和算法都不行,只能用位图。

  但是我们也提到了,单个位图只能用来表示数字存在的问题,那么我们该如何做呢? 

  两个bit位能表示多少东西呢?0,1,2 显然是三个状态,因此,这个问题我们可以用两个位图来解决。

  完整代码::

#pragma once
#include"BitMap.h"
using namespace bitMap;
namespace dBitMap {

	template
	class DBitMap {
	public:
		
		//因为由两个位图,所以我们可以有三种状态
		// 不存在这个数时,两个位图中的这个位置都为0
		// 存在一次 bit1 为0 bit2 为1   也就是01
		// 存在两次  bit1 为1 bit2 为0   10
		// 其可以不用管 三次 但我们因为可以表示三种状态 因此  11为 三次
		// 三次以上 不用管,和本题无关,如果有要求,底层再加位图
 		void set(size_t x){
			//两个都不存在 变为01
			if (!bit1.test(x) && !bit2.test(x)) {
				bit2.set(x);
			}
			//01 变为 10
			else if (!bit1.test(x) && bit2.test(x)) {
				bit1.set(x);
				bit2.reset(x);
			}
			//10 变成11 
			else if (bit1.test(x) && !bit2.test(x)) {
				bit2.set(x);
			}
			//其它情况不处理
		}

		//这里我们设为一次只删除一个
		void reset(size_t x){

			//这里我们设为一次只删除一个
		
			//01 变为 00
			if (!bit1.test(x) && bit2.test(x)) {
				bit2.reset(x);
			}
			//10 变成01
			else if (bit1.test(x) && !bit2.test(x)) {
				bit2.set(x);
				bit1.reset(x);
			} 
			//11 变为10
			else {
				bit2.reset(x);
			}

			//想要一次全删除直接
		/*	bit1.reset(x);
			bit2.reset(x);*/
		}

		//查找是否存在
		bool test(size_t x){
			//也是分三种情况
				if (bit1.test(x) && bit2.test(x)) {
					cout << "出现三次" << endl;
				}
				else if (bit1.test(x) && !bit2.test(x)) {
					cout << "出现两次" << endl;
				}
				else if (!bit1.test(x) && bit2.test(x)) {
					cout << "出现一次" << endl;
					return true;
				}
				else {
					cout << "未出现" << endl;
				}
				return false;
		}

	private:
		BitMap bit1;
		BitMap bit2;
	};


	void test1() {

		DBitMap<100> DB;

		DB.set(1);
		DB.set(2);
		DB.set(3);
		DB.set(4);
		DB.set(5);
		DB.set(6);
		DB.set(1);
		

		DB.test(1);
		DB.test(2);
		DB.test(3);
		DB.test(4);
	}
}

代码结果:

位图和布隆过滤器_第4张图片

1.12 位图的优劣 

优点:节省空间,效率高。
缺点:一般要求数据相对集中,否则会导致空间消耗上升。

位图的一个致命缺点:只能针对整形。

如果要位图存储整形之外的话,那么就必须像哈希函数一样,经过转化,但就拿字符串来说吧,即使经过转化,那么也可能存在不同的值,转化后变为相同的值这种情况,位图用一个bit位来标识这种情况,显然,更容易出现问题。

这时候就需要布隆过滤器了。

二.布隆过滤器

2.1 位图的概念

  布隆过滤器实际上就是位图的一种进阶形式,也是哈希思想的一种体现,其主要作用是检查字符串是否出现过。

  在布隆过滤器中,我们将字符串通过哈希函数转化为整形,然后插入到布隆过滤器中。

2.2 布隆过滤器的误判

位图和布隆过滤器_第5张图片

  但是布隆过滤器也无法全部解决字符串误判问题,因为字符串实在是太大太大,太多太多了。

但是有以下两种情况:

位图中存在:不一定真正存在。
  因为可能有误判,那么他就不一定真正存在,针对这种情况,我们需要再进行详细的查找啊。

位图不存在:必然不存在。
  位图中本来就应该插入的位置没有元素,那就也没有其它误判的字符串,自己的字符串也没有,故肯定没有。

所以根据位图判断出的结构,不存在是准确的,存在是不准确的

有没有办法能提高一下判断的准确率呢?答案是有的,布隆过滤器就可以降低误判率,提高准确率。

2.3 布隆过滤器减少误判

  

布隆过滤器相比于位图有一个很重要的方法,它用多个哈希函数,将一个数据映射到位图结构中

  也就是说,我们不在单纯的用一个bit位标识这个数据是否存在,而是通过多个哈希函数,用多个bit位来标识这个数据是否存在。

只有一个字符串在位图中的几个比特位同时为1才能说明该字符串存在。

 位图和布隆过滤器_第6张图片

借用一下图片。

  但是此时只能减少误判,依旧不能避免误判。

2.4 布隆过滤器的应用场景

对数据不需要太准确的场景,比如注册昵称时的存在判断。

位图和布隆过滤器_第7张图片 如上图中,一个昵称的数据库是放在服务器中的,这个数据库中昵称的存在情况都放在了布隆过滤器中,当从客户端注册新的昵称时,可以通过布隆过滤器快速判断新昵称是否存在。

  这里的话,只要数据库中没有昵称,我们就可以创建,而布隆过滤器中,“没有”的判断场景是绝对正确的,因此,布隆过滤器完美适配这种情况,至于用户有没有取到好名字,那就不是我们用来关心的了。

2.5 哈希函数个数和布隆过滤器长度的关系

   现在知道布隆过滤器是什么了,但是我们到底该创建多少个比特位的位图(布隆过滤器长度),又应该使用多少个哈希函数来映射同一个字符串呢?

 如何选择哈希函数个数和布隆过滤器长度一文中,对这个问题做了详细的研究和论证:

位图和布隆过滤器_第8张图片

  •  哈希函数个数和布隆过滤器长度以及误判率三者之间的关系曲线。

前面不管,后面不管,得出一个关系公式。

  • 位图和布隆过滤器_第9张图片
  • m:表示布隆过滤器长度。
  • k:表示哈希函数个数。
  • n:表示插入的元素个数。
  • 其中:ln2约等于0.69。

2.6 布隆过滤器的哈希函数

  首先需要写几个哈希函数来将字符串转换成整形,各种字符串Hash函数一文中,介绍了多种字符串转换成整数的哈希函数,并且根据冲突概率进行了性能比较。 

  这里我们选择四个哈希函数:

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

struct APHash
{
	size_t operator()(const string& key)
	{
		unsigned int hash = 0;
		int i = 0;

		for (auto ch : key)
		{
			if ((i & 1) == 0)
			{
				hash ^= ((hash << 7) ^ (ch) ^ (hash >> 3));
			}
			else
			{
				hash ^= (~((hash << 11) ^ (ch) ^ (hash >> 5)));
			}

			++i;
		}

		return hash;
	}
};

struct DJBHash
{
	size_t operator()(const string& key)
	{
		unsigned int hash = 5381;

		for (auto ch : key)
		{
			hash += (hash << 5) + ch;
		}

		return hash;
	}
};

struct JSHash
{
	size_t operator()(const string& s)
	{
		size_t hash = 1315423911;
		for (auto ch : s)
		{
			hash ^= ((hash << 5) + ch + (hash >> 2));
		}
		return hash;
	}
};

2.7 布隆过滤器的框架

namespace BloomFliter
{
	template 
		class BloomFilter
		{
		public:
		private:
			std::bitset _bs;
		};
}

  该模板有多个参数,但是大部分都是使用的缺省值,不用必须去传参,底层使用的STL库中的bitset。

size_t N:最多存储的数据个数。
size_t X:平均存储一个值,需要开辟X个位,这个值是根据上面的公式算出来的,此时哈希函数是4个,所以m = 4n/ln2 = 5.8n,取整后X位6,这里先给个缺省值是6。
class K:布隆过滤器处理的数据类型,默认情况下是string,也可以是其他类型。
哈希函数:将字符串或者其他类型转换成整形进行映射,给的缺省值是将字符串转换成整形的仿函数。

2.8 set 

	void set(const K& key) {

		//映射并插入四个位置
		size_t hashi1 = HashFunc1()(key) % (N * X);
		size_t hashi2 = HashFunc2()(key) % (N * X);
		size_t hashi3 = HashFunc3()(key) % (N * X);
		size_t hashi4 = HashFunc4()(key) % (N * X);

		_bset.set(hashi1);
		_bset.set(hashi2);
		_bset.set(hashi3);
		_bset.set(hashi4);
}

2.9 test()

  

bool test(const K& key) {
	//只要有一个位置不存在,就必定不存在,并且是准确的
	size_t hashi1 = HashFunc1()(key) % (N * X);

	if (!_bset.test(hashi1)){
		return false;
	}

	size_t hashi2 = HashFunc2()(key) % (N * X);
	if (!_bset.test(hashi2)) {
			return false;
	}
	size_t hashi3 = HashFunc3()(key) % (N * X);
	if (!_bset.test(hashi3)) {
			return false;
	}
	size_t hashi4 = HashFunc4()(key) % (N * X);

	if (!_bset.test(hashi4) ){
			return false;
	}

	return true;
}

2.10 测试代码

 

	void TestBF1()
	{
		BloomFilter<100> bf;
		bf.set("猪八戒");
		bf.set("沙悟净");
		bf.set("孙悟空");
		bf.set("二郎神");

		cout << bf.test("猪八戒") << endl;
		cout << bf.test("沙悟净") << endl;
		cout << bf.test("孙悟空") << endl;
		cout << bf.test("二郎神") << endl;
		cout << bf.test("二郎神1") << endl;
		cout << bf.test("二郎神2") << endl;
		cout << bf.test("二郎神 ") << endl;
		cout << bf.test("太白晶星") << endl;
	}

代码结果为:
位图和布隆过滤器_第10张图片

2.11 布隆过滤器的优缺点 

  首先,布隆过滤器不能删除元素,因为可能会连带其它元素被删除

缺点:

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

优点:

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

你可能感兴趣的:(算法,数据结构,java,开发语言,c++,服务器,1024程序员节)