C++ | 位图与布隆过滤器

目录

前言

一、位图

1、位图的引入

2、位图的实现

(1)基本结构

(2)构造函数

(3)插入数据

(4)删除数据

(5)是否存在

3、位图的优缺点

4、位图的应用

二、布隆过滤器

1、布隆过滤器的引入

2、布隆过滤器的实现

(1)布隆过滤器的框架

(2)布隆过滤器的插入

(3)布隆过滤器的查找

(4)关于布隆过滤器的删除

3、布隆过滤器的优缺点

三、哈希切割


前言

        本文主要讲解位图及位图应用,布隆过滤器及其引用,以及我们的对于海量数据的处理,如何正确理解位图与布隆过滤器的使用场景;

一、位图

1、位图的引入

        首先,我们来看看一道来自腾讯的面试题;

        1、给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。

你如果遇到这题,你会想着如何处理这题呢?

思路1:将数据放入set/unordered_set中;

        首先我们来分析一下,40亿个整型,每个整型4字节,因此总共160亿字节;160亿字节大概多少内存呢?我们都知道1KB = 1024Byte,1MB = 1024KB,1GB = 1024MB,因此我们可以推出 1GB = 1024*1024*1024Byte,大约10亿字节左右,因此160亿字节约等于16GB内存;这还仅仅只是数据占16GB内存,我们的容器里存放相应的指针也需要占用内存,明显是不合理的;故这种方案不合理

思路2:前面我们存放一个数据占用了4个字节,我们可不可以少用一些内存来存储这个数据呢?我们最低使用多大内存可以储存下这一个数据呢?想一想,我们是否可以使用一个比特位存储下这个数据,此时我们仅仅只需要0.5GB左右就可以存下这40亿的数据(以前需要一个整型的大小32比特位,现在只需要1比特位,16GB / 32 = 0.5GB),我们使用哈希映射的思想,我们给每一个数据映射到一个比特位位置,1表示这个数据存在,0表示这个数据不存在,如下图所示;我们存入一个7,我们将7对应的比特位位置置为1即可;这就是我们接下来要实现的位图的结构;

2、位图的实现

(1)基本结构

首先我们写出基本框架,具体如下图所示;

	template<size_t N>
	class bitset
	{
	public:

	private:
		std::vector<char> _bits;
	};
(2)构造函数

位图的构造函数主要是完成vector容器的初始化,我们想一想,传入N个数据的位图,我们需要开多大的空间合适;如果N为14,我们需要开2个字节空间并不合适,我们需要开三个字节的空间,即N / 8 + 1;

		// 构造
		bitset()
		{
			size_t len = N / 8 + 1;
			_bits.resize(len, 0);
		}
(3)插入数据

我们在位图中插入一个数据就是将对应的比特位置为1,我们可以通过将数据x / 8得到该数据在哪一个字节,将数据 x % 8 可以得到该数据在字节的哪一个比特位,最后通过按位或运算移位运算符将对应得比特位置为1;

		// 插入
		void set(size_t val)
		{
			// 第几个字节中
			size_t x = val / 8;
			// 字节的第几个比特位中
			size_t y = val % 8;
			// 置1
			_bits[x] |= (1 << y);
		}
(4)删除数据

位图数据得删除即将对应得比特位置为0,我们想要将对应得比特位置为零也需要得到对应比特位得位置,与插入相同的方式得到比特位的字节与字节中的位置;然后通过按位与移位运算按位取反运算将对应的比特位置为0;

		// 删除
		void reset(size_t val)
		{
			// 第几个字节中
			size_t x = val / 8;
			// 字节的第几个比特位中
			size_t y = val % 8;
			// 置0
			_bits[x] &= ~(1 << y);
		}
(5)是否存在

我们除了要实现插入与删除,我们还要提供一个测试该数据是否存在的接口;我们同样通过按位与运算查看该比特位的数据是否存在;

		// 在不在
		bool test(size_t val)
		{
			// 第几个字节中
			size_t x = val / 8;
			// 字节的第几个比特位中
			size_t y = val % 8;
            // 0是不存在,非0代表存在
			return _bits[x] & (1 << y);
		}

3、位图的优缺点

位图优缺点如下:

优点:

1、查找速度快,O(1)的时间复杂度内就能查找一个数据是否存在;

2、节省内存空间,40亿数据才占用0.5G内存

缺点:

1、只适用于整型的存储

4、位图的应用

位图主要应用于以下场景:

1、快速查找某个数据是否在一个集合中

2、排序 + 去重

3、求两个集合的交集、并集等

4、操作系统中磁盘块标记

接下来我以几道面试题来举例讲解位图的应用;

1. 给定100亿个整数,设计算法找到只出现一次的整数?

思路:题目说了100亿数据,实际上会有大量重复,因为无符号整型的最大值也只有42亿9千万左右;我们可以设计一个类,该类有连个位图,每个位置可以分别用 00 表示 该数据一次都没出现,用 01 表示该数据只出现了一次,用 10 表示该数据出现了两次及以上;最后遍历整个位图,输出 01 比特位映射的数据不就是只出现一次的数据吗?

	template<size_t N>
	class twobits1
	{
	public:
		void set(size_t val)
		{
			// 00 -> 01
			if (_bits1.test(val) == false && _bits2.test(val) == false)
			{
				_bits2.set(val);
			}// 01 -> 10
			else if (_bits1.test(val) == false && _bits2.test(val) == true)
			{
				_bits1.set(val);
				_bits2.reset(val);
			}
            // 两次及以上不必处理
		}

		void print()
		{
			for (size_t i = 0; i < N; i++)
			{
				if (_bits1.test(i) == false && _bits2.test(i) == true)
					std::cout << i << std::endl;
			}
		}
	private:
		bitset<N> _bits1;
		bitset<N> _bits2;
	};

2. 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?

思路1:只需创建一个位图,遍历文件1,存入位图中,然后遍历文件2,每个数据与位图中对比看是否存在,存在即为交集;这里有一个问题,假如文件1的数据为 1  2  4,文件2的数据为 1  2  1   1   1,此时遍历文件2输出的结果会有大量重复的1,因此我们还需要去重,我们可以在遍历文件2的同时,第一次找到位图中的交集时,我们将这个位图中的比特位置为0,这时,在后续的查找遍历中,不会再重复输出这个数据了,便可以达到去重的效果;

思路2:创建两个位图,分别遍历这两个文件,将文件1存入位图1中,文件2存入位图2中,然后从0一直遍历到无符号整型最大值,如果对应比特位都为1时,此时便是这两个文件数据的交集;

3、1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数

思路:同题目1,我们设计一个类,有两个位图,分别定义状态 00 表示该位置映射数据为0个,01表示该位置映射数据为1个,10表示该位置映射数据为两个,11表示该位置映射数据为2个及以上,遍历完文件存入位图后,我们再从0遍历到无符号整型最大值,然后输出状态为 01 或 10的位置映射的值;稍稍该该题目1的代码便可实现,具体代码如下;

	template<size_t N>
	class twobits2
	{
	public:
		void set(size_t val)
		{
			// 00 -> 01
			if (_bits1.test(val) == false && _bits2.test(val) == false)
			{
				_bits2.set(val);
			} // 01 -> 10
			else if (_bits1.test(val) == false && _bits2.test(val) == true)
			{
				_bits1.set(val);
				_bits2.reset(val);
			} // 10 -> 11
			else if(_bits1.test(val) == true && _bits2.test(val) == false)
			{
				_bits1.set(val);
				_bits2.set(val);
			}
		}

		void print()
		{
			for (size_t i = 0; i < N; i++)
			{
				if ((_bits1.test(i) == false && _bits2.test(i) == true)
					|| (_bits1.test(i) == true && _bits2.test(i) == false))
					std::cout << i << std::endl;
			}
		}
	private:
		bitset<N> _bits1;
		bitset<N> _bits2;
	};

二、布隆过滤器

1、布隆过滤器的引入

        首先,我们还是来看一道经典的面试题;

1、给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出
精确算法和近似算法

思路:此时,我们发现用我们的位图无法解决这个问题了,因为位图只针对整型家族的数据才有效,此时我们引入一个新的数据结构 ----- 布隆过滤器,其思想是将字符串通过hash函数转化成我们的整型;然后将一个文件中的query存进我们的位图中,另一个文件在布隆过滤器中查询,这就是布隆过滤器的主要思想,哈希函数+位图;也是我们这道题的近似算法;

字符串转整型哈希函数

2、布隆过滤器的实现

虽然我们使用一些哈希函数将字符串映射成我们的整型,这些哈希函数虽然可以减少冲突,但不能完全避免,为了进一步减少冲突,我们通过多个哈希函数将一个字符串映射多个位置来达到减少冲突的效果;如下面实例;

上图是使用两个哈希函数,将我们的一个字符串映射到两个位置,当然,我们在查找时,映射的两个位置必须都存在,才能表示这个字符串可能存在;

(1)布隆过滤器的框架

        关于布隆过滤器还有一个问题,就是应该有多少个哈希函数映射,这其实是数学问题了,有兴趣升入了解的可以阅读下方链接文章;

布隆过滤器哈希函数深入研究

        如上公式中,k是哈希函数个数,m是布隆过滤器长度,n是数据个数;我们通过如上公式推导出 m = (k / ln 2)* n;我们写出如下代码结构;

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

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

	template<size_t N, 
		class K = std::string, 
		class HashFunc1 = BKDRHash,
		class HashFunc2 = APHash,
		class HashFunc3 = DJBHash>
	class BloomFilter
	{
	public:

	private:
		static const int _a = 4;  // k / ln 2
		bitset<N * _a> _bits;
	};

        本文挑选了三个哈希函数,读者可不必一定选这三个哈希函数,可从之前字符串转整型的哈希函数中任选;本文还算出了k / ln 2 的值并命名成a;

(2)布隆过滤器的插入

        我们分别算出我们的hashi,然后对相应位置取余数,然后再插入;

		void set(const K& key)
		{
			int len = _a * N;
			size_t hashi1 = HashFunc1()(key) % len;
			size_t hashi2 = HashFunc2()(key) % len;
			size_t hashi3 = HashFunc3()(key) % len;

			_bits.set(hashi1);
			_bits.set(hashi2);
			_bits.set(hashi3);
		}
(3)布隆过滤器的查找

        布隆过滤器的查找主要查找key对应的hashi是否全都映射到了位图中;具体代码如下所示;

		bool test(const K& key)
		{
			int len = _a * N;
			size_t hashi1 = HashFunc1()(key) % len;
			size_t hashi2 = HashFunc2()(key) % len;
			size_t hashi3 = HashFunc3()(key) % len;

			if (_bits.test(hashi1) && _bits.test(hashi2) && _bits.test(hashi3))
			{
				return true;
			}
			else
			{
				return false;
			}
		}

        此外,我们还需要注意的一点是布隆过滤器的查找在的情况是不准确的,不在的情况是完全准确的;为什么呢?下图有做解释;

        如若生物并不存在,但是映射的位置是别的字符串映射的位置(因为哈希函数不能完全保证不冲突),此时查找结果却显示生物存在布隆过滤器中;因此在有可能是不准确的;而不在一定是准确的,但凡之前插入过,其映射位置一定会被设置成1,若指定位置没设置成1,则一定不存在; 

(4)关于布隆过滤器的删除

        布隆过滤器一般不会提供删除接口,如下图所示;

        若布隆过滤器提供删除接口,如若删除语文,将语文对应的比特位设置成0,此时我们查找英语和数学时,我们会发现也查不到的情况;

        若是必须提供删除接口,你有什么办法呢?

思路:我们每个映射的位置用多个比特位来储存,而不是只用一个,假设用四个比特位代表一个位置,然后我们用引用计数的思想,每插入一个哈希映射,我们对其++。比如四个比特位可以表示15,即为 1111 ;而删除一个哈希映射位置时,我们必须对其-1操作;这样就可以实现布隆过滤器的删除接口,且删除一个数据不会对别的数据产生影响;但是这种方法是有弊端的,其一是占用空间增多了,其二是引用计数的数据范围有限;

3、布隆过滤器的优缺点

布隆过滤器的优缺点如下:

优点:

1、增加与查找的时间复杂度为O(K)(K为哈希函数个数,一般很少);

2、布隆过滤器不需要存储元素本身,对于某些场合有数据安全性;

3、可以存储数据规模较大的数据群体;

4、允许小规模误判的情况下,布隆过滤器其存储数据有很大的空间优势

缺点:

1、有误判率(在有可能不准确)。

2、不能获取元素本身

3、一般情况下,不能删除布隆过滤器的元素

4、即使提供引用计数来实现删除,也可能会产生计数回绕的问题(unsigned int性质)

        下面我们还提供了一个测试布隆过滤器的准确性的测试接口;我们可以通过调节_a来控制误判率;

void test_bloomfilter()
{
	srand((unsigned int)time(0));
	const int N = 1000000;
	MySpace::BloomFilter<N> bf;
	string str1 = "https://mp.csdn.net/mp_blog/creation/editor/132078512?spm=1001.2014.3001.5352";
	// 源字符串
	vector<string> v1;
	for (int i = 0; i < N; i++)
	{
		v1.push_back(str1 + to_string(i));
	}
	for (auto& e : v1)
	{
		bf.set(e);
	}

	// 近似字符串
	vector<string> v2;
	for (int i = 0; i < N; i++)
	{
		v2.push_back(str1 + to_string(999999 + rand()));
	}
	size_t count1 = 0;
	for (auto& e : v2)
	{
		if (bf.test(e))
		{
			count1++;
		}
	}
	// 不近似字符串
	vector<string> v3;
	string str2 = "www.baidu.com";
	for (int i = 0; i < N; i++)
	{
		v3.push_back(str2 + to_string(rand() + i));
	}
	size_t count2 = 0;
	for (auto& e : v3)
	{
		if (bf.test(e))
		{
			count2++;
		}
	}
	cout << count1 << endl;
	cout << count2 << endl;
	cout << "近似字符串的重复率:" << (double)count1 / (double)N << endl;
	cout << "不近似字符串的重复率:" << (double)count2 / (double)N << endl;

}

三、哈希切割

        哈希切割是本章的一个补充话题,关于哈希切割首先看如下面试题;

1、给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?
与上题条件相同,如何找到top K的IP?如何直接用Linux系统命令实现?

思路:首先,我们看题目是让我们找到出现次数最多的IP地址,我们首先想到这就是一个经典的TopK问题;我们可以通过堆来找,但是堆有无法存放这么多的数据,如何处理呢?我们可以将数据通过哈希函数划分成500份文件,然后将每个文件中通过map/unordered_map得到出现次数最多的IP并记录下来,可是,我们是通过哈希函数切分,并不能保证每个文件的大小一致,仍可能出现有的文件过大;

对于过大的小文件,有如下两种情况:

1、小文件有很多重复IP;

2、小文件没有很多重复IP,仅仅是哈希切割恰好切到了一起;

我们如何区分这两种情况呢?我们可以将这个小文件插入map/unordered_map中,若是因为小文件有很多重复IP,我们可以统计出IP文件个数,因为有很多会插入失败;若是情况2,我们插入过程中,肯定会抛出bad_alloc的异常;这时我们对这个小文件换哈希函数继续切分即可;最终每个小文件肯定能统计出一个出现次数最多的IP,我们将这些IP以pair<IP, 次数>的形式放进大堆中(按次数比较),最后便可得到出现最多的IP地址;

猜你喜欢

转载自blog.csdn.net/Nice_W/article/details/132078512