【C++】位图、布隆过滤器概念与模拟实现

目录

一、位图

1.1 位图的概念

1.2 位图的使用

1.3 位图的实现

1.4 位图的应用

二、布隆过滤器

2.1 布隆过滤器

2.2 布隆过滤器的实现

2.3 布隆过滤器练习题


一、位图

1.1 位图的概念

所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在。

1.2 位图的使用

首先我们来看一道题目:

给定40亿个不重复的无符号整数,没有进行排序。现在给一个无符号整形,如何快速判断一个数是否存在这40亿个数中。

现在有三种方法:

1.遍历,时间复杂度O(N)

2.排序后使用二分查找,时间复杂度为:排序(O(N logN)) + 二分查找(O(logN))

3.位图

如果我们使用位图解决该的问题,我们只需要开辟一个40亿个 bit 的空间(如果直接存放40亿的整数约占16G,开辟40亿bit约占512MB).

使用直接定址法进行映射,如果该位置是0,则表示该数据不存在,如果是1表示该数据存在。

如下图:

1.3 位图的实现

接下来是位图的接口展示:

template<size_t N>
class bit_set
{
public:
	//默认构造
	bit_set()
	{}

	//将映射的地方改为1
	void set(size_t x)
	{}

	//删除数据
	void reset(size_t x)  
	{}

	//判断x在不在
	bool test(size_t x)
	{}
private:
	vector<char> _bits;
};

我们可以设置一个非模板参数来控制开辟空间的大小,在构造函数中进行空间的开辟。

bit_set()
{
	_bits.resize(N / 8 + 1, 0);
}

接下来就是 set 的编写了,目的就是将映射的地址改为1即可,我们使用/8求出该值在第几个char上,再进行模8求出在第几位上,再进行进行位移+或的方式进行即可:

//将映射的地方改为1
void set(size_t x)
{
	//1.除8再模8
	size_t i = x / 8;     //求在第几个char处
	size_t j = x % 8;     //求在第几位上
	_bits[i] |= (1 << j); 
}

reset表示删除该数,我们直接将该bit位上的数据置为0即可,我们找到该将1左移到该位置上,然后使用取反操作,这样除了第j位的都是1,再进行与操作,即可完成数据的删除。

void reset(size_t x)  //删除这个数据
{
	size_t i = x / 8;
	size_t j = x % 8;
	_bits[i] &= ~(1 << j);    //左移取反再 与
}

test接口就是将传入的数据的映射位直接返回即可。

bool test(size_t x)//判断x在不在
{
	size_t i = x / 8;
	size_t j = x % 8;
	return _bits[i] & (1 << j);
}

写完之后我们来测试一下:

注意,这里其实不用关注当前是小端存储还是大端存储,因为我们的存储规则和查询规则是一致的,其中我们的位操作(例如左移,本质上是从低地址往高地址进行位移,而不是方向的位移)。

1.4 位图的应用

  • 给定100亿个int,1G内存,设计算法找到只出现一次的整数。

第一题:

首先,1G内存大约有80亿的bit位,而100亿个int,int 最多能表示大约42亿9千万个数,也就是说100亿的数据一半以上都是重复的;我们只用43亿个bit位就可以解决该问题,所以这里使用1G空间完全可以解决该问题。

这是一个KV统计搜索模型,我们可以使用两个位图来解决,用两个位图中对应位置的值来表示这个整数的出现情况:

0次  --->   00

1次  --->   01

2次及以上---> 10

 其实在STL库中就有位图容器,我们可以直接进行使用:

 接下来就是set的实现,首先要检测两张位图中该数据的存在情况,然后根据其状态做出处理:

void set(size_t x)
{
	bool inset1 = _bs1.test(x);      //检测当前数据存在情况
	bool inset2 = _bs2.test(x);      //检测当前数据存在情况

	// 00 -> 01  表示出现一次
	if (inset1 == false && inset2 == false)
	{
		_bs2.set(x);
	}
	// 01 -> 10  表示出现一次
	else if (inset1 == false && inset2 == true)
	{
		_bs1.set(x);
		_bs2.reset(x);
	}
}

test的实现就是位图1中该位置是0并且位图2该位置是1则为真,反之则为假:

bool test(size_t x)
{
	return (_bs1.test(x) == false && _bs2.test(x) == true);
}

接下来我们可以写一个打印当前位图存放了所有数据的Print函数:

void Print_once_num()
{
	cout << "Print_once_num:" << endl;
	for (size_t i = 0; i < N; i++)
	{
		if (_bs1.test(i) == false && _bs2.test(i) == true)
		{
			cout << i << " ";
		}
	}
	cout << endl;
}

然后我们写一段代码简单测试一下我们的双位图结构:

位图快并且节省空间,但是其局限在于只能处理整形,接下来我们就来学习布隆过滤器来处理字符串。

二、布隆过滤器

2.1 布隆过滤器

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

如上图,x、y、z都映射了3处,但是发现 x 和 z 以及 y 和 z 有相同的映射处,这就说明布隆过滤器是存在不准确的情况。

再观察W,w不是过滤器中的值,进行检测映射后发现一个位置为0,则能表示w不在过滤器中。这便能得出结论。

误判情况:

存在:不准确,有可能是其它数据也映射到了此处。

不存在:准确,表示该值并没有把其应该映射的位置进行修改。

布隆过滤器的存在的误判是被允许的,因为在很多场景需要快速地进行判断。

  • 比如游戏中的起网名,服务器不可能将你的游戏 ID 拿到数据库中进行查询,而是直接将你的游戏 ID 在过滤器中进行查询,如果过滤器查询结果是 ID 已存在,系统则提示你 ID 被占用。即使这个ID在数据库中并不存在,但是这样的操作节省了服务器的运行压力。
  • 再比如网络失信名单,将身份证号在失信名单过滤器中进行查询,如果查询结果显示为失信人员,则再由服务器将身份证在数据库中进行二次查询;而如果显示非失信人员时,直接返回结果即可。

所以,布隆过滤器是非常适合字符串的快速查询,即使存在缺陷,但是我们可以采取多次映射的方式,即使用不同的字符串哈希算法,来降低误判的几率。

理论而言:一个值映射的位越多或表的长度越长,误判概率越低。但是也不能映射太多,不然会导致布隆过滤器优势丧失。

这有一篇相关的证明博客:详解布隆过滤器的原理,使用场景和注意事项

根据上面博客的中的内容,使用越多的字符串哈希函数其冲突率会逐渐降低。

接下来我们分析我们应该如何设计m和k,即过滤器长度和哈希函数的个数

 所以,接下来的布隆过滤器的实现,比如我们要标记N个数,则应开辟4.2*N以上的空间(方便计算取5)

2.2 布隆过滤器的实现

布隆过滤器的底层使用的位图来进行记录数据,这次模拟实现使用3套哈希函数,所以要设置5个模板参数(1.数据个数;2.数据类型;3.哈希函数1;4哈希函数2;5.哈希函数3)

1.哈希函数

注意:这次是使用字符串类型进行测试,所以哈希函数都是字符串的哈希函数;如果想让过滤器支持自定义类型直接编写对应的哈希函数即可。

各种字符串哈希函数:各种字符串Hash函数

这里直接使用几种常见的字符串哈希函数进行用于传参即可,如下:

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

struct HashString2
{
	size_t operator()(const string& key)
	{
		size_t hash = 5381;
		for (auto ch : key)
		{
			hash += (hash << 5) + ch;
		}
		return hash;
	}
};
struct HashString3
{
	size_t operator()(const string& key)
	{
		size_t hash = 0;
		for (size_t i = 0; i < key.size(); i++)
		{
			if ((i & 1) == 0)
			{
				hash ^= ((hash << 7) ^ key[i] ^ (hash >> 3));
			}
			else
			{
				hash ^= (~((hash << 11) ^ key[i] ^ (hash >> 5)));
			}
		}
		return hash;
	}
};

2.标记数据

过滤器的标记则是使用传入的哈希函数算出映射位置,然后调用位图得 set 进行标记即可。

void Set(const K& key)
{
	//将哈希函数映射处进行标记
	size_t hash1 = Hash1()(key) % (_ratio * N);
	size_t hash2 = Hash2()(key) % (_ratio * N);
	size_t hash3 = Hash3()(key) % (_ratio * N);

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

3.查询数据

查询数据其实就是找对应的映射位置,如果3个映射位置有一个为0,则表示数据不存在,并且该结果准确,如果三个都为1,则表示该数据可能存在,这是布隆过滤器不可避免的问题。

实现方式是根据哈希函数求出对应的3个映射位置,然后使用位图的 test,如果有一处为0则返回false,反之返回true;

bool Test()
{
	//检测对应的3处标记为位
	size_t hash1 = Hash1()(key) % (_ratio * N);
	size_t hash2 = Hash2()(key) % (_ratio * N);
	size_t hash3 = Hash3()(key) % (_ratio * N);
	//3处都不为零返回真,1处为假则返回假
	if (_bits.test(hash1) && _bits.test(hash2) && _bits.test(hash3))
		return true;
	return false;
}

4. 效果测试

测试思路:插入一组字符串arr1,然后让arr2中的字符串进行查询,观察查询结果。

void TestBloomFilter1()
{
	string arr[] = { "苹果","西瓜","菠萝","草莓","梨子","葡萄"};
	BloomFilter<100, string, HashString1, HashString2, HashString3> bf;
	for (auto str : arr)
	{
		bf.Set(str);
	}
	cout << "Test:" << endl;
	string arr2[] = { "梨子","苹果","草莓","李子","西瓜2"," ","字符","排序"};
	for (auto str : arr2)
	{
		cout << str << ":";
		if (bf.Test(str)) cout << "存在" << endl;
		else cout << "不存在" << endl;
	}
}

 5. 误判率的检测

接下来是一段测试误判率的代码

void TestBloomFilter2()
{
	srand(time(0));
	const size_t N = 100000;
	BloomFilter<100000, string, HashString1, HashString2, HashString3> bf;
	cout << sizeof(bf) << endl;

	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(1234 + i));
	}

	for (auto& str : v1)
	{
		bf.Set(str);
	}

	// 相似
	std::vector<std::string> v2;
	for (size_t i = 0; i < N; ++i)
	{
		std::string url = "http://www.cnblogs.com/-clq/archive/2021/05/31/2528153.html";
		url += std::to_string(99999999 + 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 = "zhihu.com";
		url += std::to_string(rand() + i);
		v3.push_back(url);
	}

	size_t n3 = 0;
	for (auto& str : v3)
	{
		if (bf.Test(str))
		{
			++n3;
		}
	}
	cout << "不相似字符串误判率:" << (double)n3 / (double)N << endl;
}

2.3 布隆过滤器练习题

第一题

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

首先,query表示的是请求,比如网络请求,SQL语句,本质就是字符串。

其次,近似算法的意思是允许该算法存在一些误判,而精确算法的要求是绝对准确。

近似算法:

使用布隆过滤器:

答:将 A 文件中的数据放到布隆过滤器中,然后遍历 B 文件中的数据与布隆过滤器中的数据进行比较,如果是存在则是交集,不存在则不是交集。

但是这种方法虽然实现简单,但是存在误判重复项过多两个问题。

精确算法:

接下来介绍一种思想:哈希切分

  1. 假设每个query占30byte,100亿query则是3000亿byte,则约为300G(1G约10亿byte)。
  2. 然后我们将每一个 query 使用哈希函数转为整形,将该整形进行取模放入对应的 i 文件中,则相同的 query 就被放到了相同编号的小文件
  3. 让 Ai 与 Bi 的文件放入内存一一进行对比,如果对比结果相同,则是交集。


第二题

  • 如果扩展BloomFilter使得它支持删除元素的操作。

布隆过滤器不支持删除操作。因为删除一个标记位可能会影响其它的数据在改为的映射关系。如果想要实现删除操作,可以使用引用计数的思路来实现BloomFilter的删除操作,但是如果使用引用计数的方式支持删除,空间消耗会更多,会导致BloomFilter的优势消失。


第三题

  • 给一个超过100G大小的 log file,log中存着 IP 地址,设计算法找到出现次数最多的 IP 地址?那如果找到 top K 的 IP 呢?

解决方式:

  1. 读取每个ip,i=Hash(ip)%500,即 ip 进行第 i 个文件。
  2. 依次使用map<string,int>,对每个小文件统计次数,即映射的 ip 最多的文件。
  3. 取出出现次数最多的文件,然后建立K个值为<ip,count>的小堆,即可求出出现次数最多的K个ip。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Brant_zero2022

素材免费分享不求打赏,只求关注

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值