Bootstrap

位图与布隆过滤器

                       

                              欢迎拜访: 羑悻的小杀马特.-CSDN博客

                                   本篇主题:位图与布隆过滤器

                                    制作日期:2024.11.25

                                    隶属专栏:c++的不归之路

 

------ ->欢迎阅读             欢迎阅读             欢迎阅读                   欢迎阅读 <-------    

目录

本篇简介:

一·位图:

1.1为何而由来:

1.2如何实现:

1.2.1 set的实现:

1.2.2reset的实现:

1.2.3test的实现:

 1.2.4bitset.h:

1.3如何使用:

1.4实际应用场景:

二·布隆过滤器:

2.1为何而由来:

2.2如何实现:

2.2.1set的实现:

2.2.2test的实现:

2.2.3 bloomfilter.h: 

2.3如何使用:

2.4实际应用场景:

2.4.1 爬⾍系统中URL去重:

2.4.2  垃圾邮件过滤:

2.4.3 预防缓存穿透:

2.4.4 对数据库查询提效:


本篇简介:

此篇文章针对大量数据进行筛选或者判断重复,存在等一系列侧重于在特定空间,时间等允许范围内进行选择的问题提出解决方案。

一·位图:

1.1为何而由来:

首先盲目的说起位图,可能会有些疑问;下面我们由一个例子把它印出来。

有一个问题:给40亿个不重复的⽆符号整数,没排过序。给⼀个⽆符号整数,如何快速判断⼀个数是否在这40亿个数中。

这里也许可以说用排序加二分,但是数据过大,也就意味着内存过大,是无法加载到内存中进行相应的有序查找的。

因此因为它是无符号整型,符合我们位图使用的规则,因此就过渡到了位图。

下面介绍一下什么是位图:它说白了,其实就是一个整型的数组,我们对应给它传无符号整型;然后它就通过映射对应到某个整型的某一位把它变成1;如果想把某个数从位图中移除只需要把对应位变成0即可;这样利用位图可以解决一系列对大量无符号整型的相关问题;后序我们会说到。

1.2如何实现:

我们大概对它几个重要的接口模拟实现一下;如set;reset;test等;

位图本质是⼀个直接定址法的哈希表;我们根据它直接映射位置完成布置:

一开始位图也就是数组的每一位都是0;也就是说默认0的初始化;然后我们要利用位操作符改变它的位来进行那三个接口的实现。

注:这里映射到数组某个元素的某一位和取出它进行位运算,以及它在数组中排布有点不同:我们就拿vs2022,已知它是个小端机器;那么就是低位低地址;高位高地址;而我们知道数组就是由低到高地址排布;因此我们放入的某个数,把对应位置改成1;它在数组中排布是我们预期的;但是取出来对这个数比如获取;它会颠倒一下;看图就方便理解了。 

而我们的位移操作符也是一样按照低地址到高地址开始的。 

下面就是我们如何推导出某个数对应的映射位置:

也就是这个公式

i = x / 32;
j = x % 32;

i是对应数组元素的下标;而j对应就是它的某一位(从数组中是从前往后;而取出这个元素就是从后往前了;简单说就是从低位开始数的) 

注:这里我们把每个元素的32个比特位看成了一个数组;那么j就是它的下标;这样方便了以后得位操作改变对应的位了。

那要开多少空间呢?首先我们要知道;我们给它传过去的是这个数字;因此要保证它能够映射到对应的位;因此假设我们传32;它就要映射到第33个比特位上也就是第二个元素的末位;因此推导出公式:

(N/32+1)

(N >> 5) + 1

这里我们的N就是数据最大值也就是说传给位图,能确保这个最大值有映射的位置。 

这两个公式是一样的;这也就是我们的模拟的vector要开辟的整型空间个数 。

下面我们就来实现一下它的三个接口:

1.2.1 set的实现:

set的功能也就是把我们传递的无符号整型对应的bit位变成1;下面就用到我们的位运算操作了;明显其他位不变;指定位变成1,我们用的就是与000000000100000000000进行或操作(也就是与1左移j位后的结果)

这里我们就不过多解释了;下面就是代码实现:

void set(size_t x) {
	size_t i = x / 32;
	size_t j = x % 32;
	_bs[i] |= (1 << j);
}

1.2.2reset的实现:

set的功能也就是把我们传递的无符号整型对应的bit位变成0;下面就用到我们的位运算操作了;明显其他位不变;指定位变成1,我们用的就是与111111111101111111111进行与操作(也就是与1左移j位后取个反的结果)

代码实现:

void reset(size_t x) {
	size_t i = x / 32;
	size_t j = x % 32;
	_bs[i] &= (~(1 << j));
}

1.2.3test的实现:

 这里就是检测某一bit位是0还是1;返回true或者false;判断思路可以这莫想:如果想要它是零我们就让它变成0;那么也就是与00000000100000000与一下;那么如果某一位不是0;不就返回的非0,强转bool后变成true。

代码实现:

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

	return _bs[i] & (1 << j);

}

 1.2.4bitset.h:

#pragma once
#include<iostream>
#include<vector>

using namespace std;
namespace bis {
	template<size_t T>
	class bitset {
	public:
		bitset()
		{
			_bs.resize(T / 32 + 1);
		}
		void set(size_t x) {
			size_t i = x / 32;
			size_t j = x % 32;
			_bs[i] |= (1 << j);
		}
		void reset(size_t x) {
			size_t i = x / 32;
			size_t j = x % 32;
			_bs[i] &= (~(1 << j));
		}
		bool test(size_t x) {
			size_t i = x / 32;
			size_t	j = x % 32;

			return _bs[i] & (1 << j);

		}
	private:
		vector<int> _bs;
	};

	template<size_t N>
	class twobitset {
	public:
		void set(size_t x) {
			bool b1 = _bs1.test(x);
			bool b2 = _bs2.test(x);

			if (!b1 && !b2) // 00->01
			{
				_bs2.set(x);
			}
			else if (!b1 && b2) // 01->10
			{
				_bs1.set(x);
				_bs2.reset(x);
			}
			else if (b1 && !b2) // 10->11
			{
				_bs1.set(x);
				_bs2.set(x);
			}
			else {}
			
		}
		int get_count(size_t x) {
			bool b1 = _bs1.test(x);
			bool b2 = _bs2.test(x);

			if (!b1 && !b2)
			{
				return 0;
			}
			else if (!b1 && b2)
			{
				return 1;
			}
			else if (b1 && !b2)
			{
				return 2;
			}

			else {
				return -1;//3个及3个以上规定返回-1
			}
		}
	private:
		bitset<N>_bs1;
		bitset<N>_bs2;

	};


}
 

1.3如何使用:

下面我们简单测试一下它的基础功能:

这里就是我们把32放入;它把对应的下标为1的元素的末位修改成了1;也就验证了小端机器。 

void test_bs1() {
	
	int a1[] = { 5,7,9,2,5,99,5,5,7,5,3,9,2,55,1,5,6 };//出现多个返回-1
	//std::bitset<100> bs1;
	bis::bitset<100> bs1;
	bs1.set(32);
	bs1.set(33);
	cout<<bs1.test(32)<<endl;

	for (auto e : a1)
	{
		bs1.set(e);
	}
	cout << bs1.test(99) << endl;
	cout << bs1.test(10) << endl;

	

	
}

 测试结果验证基础功能还是欧克的。

当然库里面 也实现了自己的bitset在头文件bitset里;下面是链接;可以去了解一下:bitset - C++ Reference

这里我们说一下库里面实现的这个bitset有一个缺陷;就是当我们数据足够大的时候;它会崩掉;下面演示一下:

这里原因是什么;其实就是当无符号整型过大的时候;而库里面对应的类似栈上开的静态数组;因此栈的空间没有堆大;因此这里导致了进程崩了;我们自己实现的这个bitset就没有问题:

那有没有解决方案呢? 间接给它转移到堆上就可以了:我们在栈上创建个指针才几个字节空间;让资源都到堆上;以后操控位图直接就用这个指针不就好了。

bitset<N>* _pbs = new bitset<N>(); 

当然所有的事情都是利弊共存的;位图也不例外:

优点:增删查改快,节省空间。

缺点:只适⽤于整形 。

 为了解决它的缺点问题;比如字符串就不可以;所以我们后面引入了布隆过滤器的概念。

1.4实际应用场景:

下面有三个问题:

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

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

//一个文件有100亿个整数,1G内存,设计算法找到出现次数不超过且不等于n次的所有整数?

这样我们一下如果靠位图是不是实现不了了;因为位图只能操作一个数字;如果来的重复的怎麽办?这时我们把思路放宽一个不行那就两,多个位图来搞定。这三个问题可以理解为最后一个问题演变而来的:也就是我们利用位图实现从大量数据中找到出现几次的问题:

我们可以封装多个位图成一个类;通过这个n的取值我们可以判断出需要的位图个数:

k=log(N+1);具体是怎么操作呢?

之前我们是标记在不在,只需要⼀个位即可,这⾥要统计出现次数不超过2次的,可以每个值⽤两个位标记即可,00代表出现0次,01代表出现1次,10代表出现2次,11代表出现2次以上。最后统计出所有01和10标记的值即可。

因此下面我们就以n=3 ;也就是需要封装两个位图来模拟一下:

 

发现确实没多大问题;

封装bitset代码:

template<size_t N>
class twobitset {
public:
	void set(size_t x) {
		bool b1 = _bs1.test(x);
		bool b2 = _bs2.test(x);

		if (!b1 && !b2) // 00->01
		{
			_bs2.set(x);
		}
		else if (!b1 && b2) // 01->10
		{
			_bs1.set(x);
			_bs2.reset(x);
		}
		else if (b1 && !b2) // 10->11
		{
			_bs1.set(x);
			_bs2.set(x);
		}
		else {}
		
	}
	int get_count(size_t x) {
		bool b1 = _bs1.test(x);
		bool b2 = _bs2.test(x);

		if (!b1 && !b2)
		{
			return 0;
		}
		else if (!b1 && b2)
		{
			return 1;
		}
		else if (b1 && !b2)
		{
			return 2;
		}

		else {
			return -1;//3个及3个以上规定返回-1
		}
	}
private:
	bitset<N>_bs1;
	bitset<N>_bs2;

};

但是如果是字符串等等呢;这就到了它的缺点的时候;因此后面布隆过滤器就是对它的解决。

二·布隆过滤器:

2.1为何而由来:

有⼀些场景下⾯,有⼤量数据需要判断是否存在,⽽这些数据不是整形,那么位图就不能使⽤了,使⽤红⿊树/哈希表等内存空间可能不够。这些场景就需要布隆过滤器来解决。

介绍由来:

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

首先它是怎么映射的;我们先看个图:

这里的“猪八戒”和“孙悟空”就是我们要处理的数据;这里我们可以发现如果我们把一个字符串经过处理后搞成多个映射位置(也就是多个hash函数操作) ;在这几个不同的位置对应映射成1;那么猪八戒存在就是对应位置都是1;而孙悟空对应位置只要出现0就不存在。

当然也会有误判,它的概率是很低的如果hash函数足够多;以及如何降低它;数据多少,空间大小等之间的关系;后面我们会讲到;。

因此在使用我们布隆过滤器的同时要记住一句话:布隆过滤器告诉存在是不准确的;告诉不存在就一定是准确的。

下面就是对相关量的一些推导:

首先明确后面要出现的量的含义:

m:布隆过滤器的bit⻓度(这里准确来说是长度-1)。

n:插⼊过滤器的元素个数。

k:哈希函数的个数。

这里我们只需要记住几个关键的结论即可:

布隆误判率:

hash函数个数:

  bit位长度-1:

可得出对m/n所带来影响的解释 :

 因此根据公式我们就可以得到:对于m/n如果越大也就是相对的bit位长度越长那么自然冲突就会减少;故误判率减少;但是如果n越大也就是数据量大但是呢,长度短映射肯定重复就高了;不用多解释了。

因此后面我们模拟实现的时候因为n是我们自己输入的;故我们把m/n当成一个整体设它是X来操作;这样就可以控制误判率了。

这样我们就大概了解布隆过滤器的概念了;也了解大概实现操作了。

2.2如何实现:

首先阐述整体设计思路:因为根据上面的推导公式;由误判率多少以及我们数据大小等来实现hash函数个数等的选择相对较麻烦;因此这里我们直接被把hash函数个数给写死;这里我们假设函数个数为3;因此推导出这里的X(也就是m/n)为5;来进行相关操作;因此我们K就写死了。

外界可以调节n以及x来调整开的bit长度;误判率等。

也就是首先我们调用的是位图;由传递过来的字符串(因为这里一般传参都是用的字符串)根据给定的hash函数得到多个位的映射;然后都置1;查找呢就是全1则在。

首先这里可以调用我们上面实现的bitset;这里我们演示的时候就先调用库里的;注意要给它转移到堆上。

这里我们进行hash映射需要找到几个hash函数;因此我们选择了几个冲突率小的函数法:

struct func_BKDR {
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			hash *= 31;
			hash += ch;
		}
		return hash;
	}
};
struct func_AP {
	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 func_DJB {
	size_t operator()(const string& s)
	{
		size_t hash = 5381;
		for (auto ch : s)
		{
			hash = hash * 33 ^ ch;
		}

		return hash;
	}
};

bloomfilter框架: 

template<size_t N,size_t X=5, class K = string,
	class Hash1 = func_BKDR,
	class Hash2 = func_AP,
	class Hash3 = func_DJB >//这里的x是判断hash函数个数k=m/n* ln2的m/n;此时默认x=5;那么n*x就是m。
//也就是我们要开的bit为,即bitset要传的模参

class bloomfilter {
public:

private:
	static const size_t M = N * X;//bitset需要传常量,此时static修饰才行
	bitset<M>*_pbs=new bitset<M>();//避免了M很大的时候,栈上空间不足

};

2.2.1set的实现:

这里set的实现上面介绍思路也大致说了;就是调用封装的bitset把映射的都置。

	void set(const K& key) {
		size_t hash1 = Hash1()(key) % M;//防止大于M的情况。取模
		size_t hash2 = Hash2()(key) % M;
		size_t hash3 = Hash3()(key) % M;
		_pbs->set(hash1);
		_pbs->set(hash2);
		_pbs->set(hash3);
	 }

2.2.2test的实现:

这里我们就只需注意检测有0就fasle都是1才能true。

bool test(const K& key) {
	size_t hash1 = Hash1()(key) % M;
	size_t hash2 = Hash2()(key) % M;
	size_t hash3 = Hash3()(key) % M;
	if (_pbs->test(hash1) && _pbs->test(hash2) && _pbs->test(hash3)) return true;
	else return false;
}

2.2.3 bloomfilter.h: 

#pragma once
#include<iostream>
#include<bitset>
#include<string>
using namespace std;

struct func_BKDR {
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			hash *= 31;
			hash += ch;
		}
		return hash;
	}
};
struct func_AP {
	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 func_DJB {
	size_t operator()(const string& s)
	{
		size_t hash = 5381;
		for (auto ch : s)
		{
			hash = hash * 33 ^ ch;
		}

		return hash;
	}
};


template<size_t N,size_t X=5, class K = string,
	class Hash1 = func_BKDR,
	class Hash2 = func_AP,
	class Hash3 = func_DJB >//这里的x是判断hash函数个数k=m/n* ln2的m/n;此时默认x=5;那么n*x就是m。
//也就是我们要开的bit为,即bitset要传的模参

class bloomfilter {
public:
	void set(const K& key) {
		size_t hash1 = Hash1()(key) % M;//防止大于M的情况。取模
		size_t hash2 = Hash2()(key) % M;
		size_t hash3 = Hash3()(key) % M;
		_pbs->set(hash1);
		_pbs->set(hash2);
		_pbs->set(hash3);
	 }
	bool test(const K& key) {
		size_t hash1 = Hash1()(key) % M;
		size_t hash2 = Hash2()(key) % M;
		size_t hash3 = Hash3()(key) % M;
		if (_pbs->test(hash1) && _pbs->test(hash2) && _pbs->test(hash3)) return true;
		else return false;
	}
	double getfalserate()
	{
		double p = pow((1.0 - pow(2.71, -3.0 / X)), 3.0);//误差=(1-e^-(m/kn))^k

		return p;
	}
private:
	static const size_t M = N * X;//bitset需要传常量,此时static修饰才行
	bitset<M>*_pbs=new bitset<M>();//避免了M很大的时候,栈上空间不足

};

	

 我们会发现这里为什么没有reset呢?明显默认布隆过滤器是不支持删除操作的;因为如果多个数据映射的bit位有重叠,我们给它删除相当于置0;那么其他数据会找不到的。

也许有人会说用引用计数:也就是我们set入一个数据,把它对应位累加;reset的时候就累减;当减为0我们才算是真正置0;但是这样我们会发现如果一个数不存在本身就是0;我们再去减就会出现问题;因此它只适用于已经存在的数据去删除 ;我们可以考虑计数⽅式⽀持删除,但是定期重建⼀ 下布隆过滤器,这样也是⼀种思路。

2.3如何使用:

下面我们就验证一下上面所说的:

这里如果我们把数据个数N给成1;此时我们的m/n;也就是x还是5;但是此时可用bit位长度就是6了;很容易造成重叠;那么就导致了此时我们触发的误判。

此时我们调整一下x;让m稍微大点(这里会导致hash函数个数k变化,这里我们暂时忽略):

这样就符合了,但是根据我们把hash函数个数写死了;因此尽量不要改变x 

 

以上就是我们会重现冲突的情况和要怎么解决。 

 下面我们来谈一下误判率;它只与m/n有关也就是我们这里的第二个模版参数x:

当我们增大m/n;其实就间接增大了hash函数个数以及开的bit位为代价让误判率减少。

当然真正的布隆过滤器的误判率是经过大量数据,它们有的极为相似经过重复试验得来的这里我们就不做模拟;记住这个结论就好;而且我们上面模拟的只是简单的情况来帮助我们理解。

而且布隆过滤器大多数都是在实际项目的应用场景出现(后面将会举例说明);我们为此需要了解好它的概念及怎么操作就好。 

2.4实际应用场景:

 ⾸先我们分析⼀下布隆过滤器的优缺点:

优点:效率⾼,节省空间,相⽐位图,可以适⽤于各种类型的标记过滤。

缺点:存在误判(在是不准确的,不在是准确的),不好⽀持删除。

下面我们说一些实际应用场景:

其实下面的场景都是应用了布隆过滤器返回的如果是存在是不准确;但是不存在一定准确的特性。

2.4.1 爬⾍系统中URL去重:

 在爬⾍系统中,为了避免重复爬取相同的URL,可以使⽤布隆过滤器来进⾏URL去重。爬取到的URL可 以通过布隆过滤器进⾏判断,已经存在的URL则可以直接忽略,避免重复的⽹络请求和数据处理。

简单解释:这里对应的爬取;如果没有布隆过滤器的话可以会继续爬取重复的网页那么可能会出现循环等造成一下浪费;但是如果在这里使用了布隆过滤器的话;如果我们爬过了就放进去;然后当在遇到重复的就直接跳过;而没爬过的可能会忽略,但是几率太小我们后面可以在设置一层检测等。

2.4.2  垃圾邮件过滤:

在垃圾邮件过滤系统中,布隆过滤器可以⽤来判断邮件是否是垃圾邮件。系统可以将已知的垃圾邮件的特征信息存储在布隆过滤器中,当新的邮件到达时,可以通过布隆过滤器快速判断是否为垃圾邮 件,从⽽提⾼过滤的效率。

 简单解释:这里利用布隆过滤器特性;如果是垃圾邮件直接干掉;不是的话就不用过滤了;当然也忽略了可能不是垃圾邮件但映射位置恰好对上了;因此还要套一层措施。

2.4.3 预防缓存穿透:

在分布式缓存系统中,布隆过滤器可以⽤来解决缓存穿透的问题。缓存穿透是指恶意⽤⼾请求⼀个不 存在的数据,导致请求直接访问数据库,造成数据库压⼒过⼤。布隆过滤器可以先判断请求的数据是 否存在于布隆过滤器中,如果不存在,直接返回不存在,避免对数据库的⽆效查询。

简单解释:这里相当于布隆过滤器把数据库的数据都吸入了;然后判断是否查询数据库;先去布隆过滤器看看是否存在;因为不存在一定是准确的:故如果返回不存在我们就可以不用去数据库找了;存在的话可能在数据库也可能不在(这里主要利用它不存在的特性;减少了负担) 

2.4.4 对数据库查询提效:

在数据库中,布隆过滤器可以⽤来加速查询操作。例如:⼀个app要快速判断⼀个电话号码是否注册 过,可以使⽤布隆过滤器来判断⼀个⽤⼾电话号码是否存在于表中,如果不存在,可以直接返回不存 在,避免对数据库进⾏⽆⽤的查询操作。如果在,再去数据库查询进⾏⼆次确认。

这里同上面的预防缓存穿透。

总之;对应布隆过滤器的使用我们大多用它的返回不存在性;而如果使用返回存在性那么就要考虑误判带来的影响了,做好防护措施 。

本篇已到尾声,感谢阅读, 望此篇让你对位图和布隆过滤器有所了解,感谢支持!

;