数据结构(十二) 位图 & 布隆过滤器

目录

位图引入

位图实现

[C++ 提供的 bitset](#C++ 提供的 bitset)

位图拓展应用

布隆过滤器引入

布隆过滤器实现

布隆过滤器应用

哈希切割


本篇文章我们主要介绍哈希思想的应用,两个很实用的数据结构:位图 & 布隆过滤器

前置知识:

位运算总结

数据结构(十一) 哈希表

位图引入

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

方法一:暴力查找,时间复杂度为 O(N)

方法二:先排序 + 二分查找,时间复杂度是 O(N * logN),还有致命问题是无法一次性加载到内存, 1G大约是10亿字节,40亿整数所占空间大约是 16G,内存不够存储,也就无法排序

方法三:位图

判断一个数在不在,只需要两个状态:在 / 不在,因此只需要用1个比特位就可以表示该数在不在,1(真)表示存在,0(假)表示不存在,那么就可以将 40亿 个整数依次映射到相应的比特位,而无符号整数的最大值是42亿多,映射的最大比特位也就是 42亿,42亿个比特位,大约需 0.5 G即可

位图是一种用于表示和管理一组二进制位(0或1)的数据结构,通常用于表示一组布尔值或者处理大量的布尔标志,可以高效地利用内存

位图实现

我们使用一个整形数组就可以实现位图,并且使用非类型模版参数在编译时就确定位图大小

cpp 复制代码
namespace dck
{
	template<size_t N> //非类型模版参数, N表示比特位个数
	class bitset
	{
	public:
		bitset()
		{
			_bits.resize(N / 32 + 1); // "/"是向下取整, 如果N不是32的整数倍, N / 32 空间就小了
		}
	private:
		vector<int> _bits;
	};
}

将x对应的比特位置为1 :

cpp 复制代码
void set(size_t x) 
{
	size_t i = x / 32; //第i个整数
	size_t j = x % 32; //第i个整数的第j个比特位
	_bits[i] |= (1 << j);
}

将x对应的比特位置清0 :

cpp 复制代码
void reset(size_t x) 
{
	size_t i = x / 32;
	size_t j = x % 32;
	_bits[i] &= ~(1 << j);
}

判断 x 在不在:

cpp 复制代码
bool test(size_t x)
{
	size_t i = x / 32;
	size_t j = x % 32;
	return _bits[i] & (1 << j);
}

位图实现代码及测试:

cpp 复制代码
#include <iostream>
#include <vector>
using namespace std;

namespace dck
{
	template<size_t N> //非类型模版参数, N表示比特位个数
	class bitset
	{
	public:
		bitset()
		{
			_bits.resize(N / 32 + 1);
		}

		void set(size_t x)  //将x对应的比特位置1
		{
			size_t i = x / 32;
			size_t j = x % 32;
			_bits[i] |= (1 << j);
		}

		void reset(size_t x) //将x对应的比特位清0
		{
			size_t i = x / 32;
			size_t j = x % 32;
			_bits[i] &= ~(1 << j);
		}

		bool test(size_t x) //检测x在不在
		{
			size_t i = x / 32;
			size_t j = x % 32;
			return _bits[i] & (1 << j);
		}
	private:
		vector<int> _bits;
	};
}

int main()
{
	dck::bitset<100> bt;
	bt.set(5);
	bt.set(33);
	bt.set(38);
	bt.set(25);
	cout << bt.test(28) << endl; //0
	cout << bt.test(38) << endl; //1
	bt.reset(38);
	cout << bt.test(28) << endl; //0
	cout << bt.test(38) << endl; //0
	return 0;
}

C++ 提供的 bitset

cpp 复制代码
#include <iostream>
#include <bitset>
using namespace std;
int main()
{
	bitset<8> bt;
	bt.set(0);
	bt.set(5);
	bt.set(2);
	bt.set(4);
	cout << bt.count() << endl; //4, 位图中有多少个1被设置
	cout << bt.size() << endl; //8, 位图大小(一共有多少个比特位)
	
	bt.reset(2);
	cout << bt.count() << endl; //3
	cout << bt << endl; //00110001

	bt[3] = 1; //将3位置比特位设置为1
	cout << bt << endl; //00111001
	cout << bt.test(3) << endl; //1, test(pos) 检测pos位置有没有被设置为1
	bt[3] = 0;
	cout << bt.test(3) << endl; //0

	cout << bt.any() << endl; //1, 是否有比特位被设置了
	cout << bt.none() << endl; //0, 是否没有比特位被设置
	cout << bt.all() << endl; //0, 是否所有比特位都被设置了

	cout << bt[2] << endl; //0
	bt.flip(2); // 翻转比特位
	cout << bt[2] << endl; //1
	return 0;
}

位图拓展应用

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

每个数的出现情况只有三种:0次 / 1次 / 2次及以上,因此每个数只需要两个比特位便可以表示这三种状态,00表示出现0次,01表示出现1次,10表示出现两次及以上,我们只需要两个位图即可

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

与问题一解法一样,只是问题二每个树的出现情况分为4种,也是用2个比特位就可以表示,00表示出现0次,01表示出现1次,10表示出现两次,11表示出现超过2次,只需两个位图即可

问题二代码:(问题一代码类似)

cpp 复制代码
namespace dck
{
	template<size_t N>
	class twobitset
	{
	public:
		void set(size_t x)
		{
			if (_bs1.test(x) == false && _bs2.test(x) == false) //00 -> 01
			{
				_bs2.set(x);
			}
			else if (_bs1.test(x) == false && _bs2.test(x) == true) //01 -> 10
			{
				_bs1.set(x);
				_bs2.reset(x);
			}
			else if(_bs1.test(x) == true && _bs2.test(x) == false) //10 -> 11
			{
				_bs2.set(x);
			}
		}

		void print()
		{
			for (size_t i = 0; i < N; i++)
			{
				if (_bs1.test(i) == 0 && _bs2.test(i) == 0)
				{
					cout << "出现0次: " << i << endl;
				}
				else if (_bs1.test(i) == 0 && _bs2.test(i) == 1)
				{
					cout << "出现1次: " << i << endl;
				} 
				else if (_bs1.test(i) == 1 && _bs2.test(i) == 0)
				{
					cout << "出现2次: " << i << endl;
				}
				else
				{
					cout << "出现2次及以上: " << i << endl;
				}
			}
		}
	private:
		dck::bitset<N> _bs1;
		dck::bitset<N> _bs2;
	};
}

int main()
{
	dck::twobitset<10> bt;
	bt.set(7);
	bt.set(2);
	bt.set(5);
	bt.set(3);
	bt.set(2);
	bt.set(7);
	bt.set(1);
	bt.set(4);
	bt.set(3);
	bt.set(9);
	bt.set(7);
	bt.print();
	return 0;
}

布隆过滤器引入

位图的缺陷是 只能解决整数 在不在及扩展问题,对于字符串在不在的问题无法很好处理,因为位图是用 整数 / 32 以及 整数 % 32 直接定址的,每一个比特位固定对应了一个整数。

判断字符串在不在依旧采用哈希思想使用位图,我们需要先要采用哈希算法将字符串映射成一个对应的整数,然后再去定址,整数数量是有限的,无符号整数一共也就42亿多个,但字符串数量有无限多个(不考虑内存上限),那么必然会有不同的字符串最终映射到了同一位置,也就是产生"哈希冲突",由于哈希冲突的存在,就可能存在"误判"的情况

● 判断结果为"不在":没有误判,一定准确

● 判断结果为"在":可能会误判,也就是字符串本身不存在,但是判定为存在

为啥判断结果为"在"可能是误判呢??? 假设字符串A和字符串B映射到了同一位置,字符串A不存在,而字符串B存在,那么判定字符串A是否存在时,计算映射位置,发现该位置的值是 1,判定为存在,就发生了误判。

由于必然存在哈希冲突,所以误判是完全无法避免的,但我们是有办法降低误判的!!

**例如:**同学A说"我喜欢学习",他说谎的概率是%50,同学B说同学A没有说谎,这也无法说明同学A没有说谎,但同学A说谎的概率会降低,如果还有同学C说同学A没有说谎,那么同学A说谎的概率会进一步降低

一个字符串映射到一个位置误判的概率是比较高的,而如果让一个字符串映射到多个位置,那么误判概率就会降低不少。

字符串A映射到 p1 / p2 / p3 三个位置,字符串B映射到 p2 / p3 / p4 三个位置,假如字符串A存在,字符串B不存在,现在要判定字符串B是否存在,先使用哈希函数求出 p2 / p3 / p4 三个位置,发现 p2 和 p3 位置的比特位都是1,但是 p4 位置的比特位是0,因此结果判定为不存在!

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

布隆过滤器实现

cpp 复制代码
#include "bitset.h"

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

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

template<size_t N, class K = string, class HashFunc1 = BKDRHash, class HashFunc2 = APHash, class HashFunc3 = DJBHash>
class BloomFilter
{
public:
	void Set(const K& key)
	{
		size_t hash1 = HashFunc1()(key) % N;
		size_t hash2 = HashFunc2()(key) % N;
		size_t hash3 = HashFunc3()(key) % N;
		_bs.set(hash1);
		_bs.set(hash2);
		_bs.set(hash3);
	}

	//一般不支持删除, 因为某个key的删除会影响其他key, 非要删除可以采用引用计数实现
	void Erase() {}

	bool Test(const K& key)
	{
		size_t hash1 = HashFunc1()(key) % N;
		if (_bs.test(hash1) == false)
			return false;
		size_t hash2 = HashFunc2()(key) % N;
		if (_bs.test(hash2) == false)
			return false;
		size_t hash3 = HashFunc3()(key) % N;
		if (_bs.test(hash3) == false)
			return false;
		
		return true; //困难存在误判
	}
private:
	dck::bitset<N> _bs;
};

测试布隆过滤器:

cpp 复制代码
void TestBF1()
{
	BloomFilter<100> bf;
	bf.Set("猪八戒");
	bf.Set("沙悟净");
	bf.Set("孙悟空");
	bf.Set("二郎神");

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

void TestBF2()
{
	srand(time(0));
	const size_t N = 100000;
	BloomFilter<N * 10> bf; //开的空间越大, 误判率越低

	std::vector<std::string> v1;
	//std::string url = "https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html";
	std::string url = "猪八戒";

	for (size_t i = 0; i < N; ++i)
	{
		v1.push_back(url + std::to_string(i));
	}

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

	// v2跟v1是相似字符串集(前缀一样),但是不一样
	std::vector<std::string> v2;
	for (size_t i = 0; i < N; ++i)
	{
		std::string urlstr = url;
		urlstr += std::to_string(9999999 + i);
		v2.push_back(urlstr);
	}

	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";
		string url = "孙悟空";
		url += std::to_string(i + rand());
		v3.push_back(url);
	}

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

int main()
{
	TestBF1();
	TestBF2();
	return 0;
}

布隆过滤器应用

布隆过滤器对于"在不在"可能会误判,而有些场景是容许误判的存在的!

上述在网页中注册昵称的时候就可以用布隆过滤器进行过滤,如果判定为昵称不在,那就一定不在,就不用再去服务器中查找了,但是如果判定为在,可能是误判了,仍要去服务器中查找,但起码过滤了部分数据,网络请求的代价还是很高的,布隆过滤器可以提高效率

哈希切割

场景一: 给两个文件,分别有100亿个query,假设一个 query 50个字节,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法

思路:

100个 query 所占大小为 5000亿个字节,也就是 500个G,显然内存是存不下的,因此无法直接将两个文件全部读取到内存中!

既然文件太大无法直接读到内存,那么我们可以先将大文件切分成小文件,而切分的方法采用的是哈希切割,也就是对于每个 query 字符串,进行哈希映射,决定 query 进入哪个小文件,文件A和文件B中相同的query进入的必然是同一个下标的文件(采用的是同样的哈希函数),因此求交集的时候只需要求 A0 和 A1 的交集,B0 和 B1的交集... 等即可,每次只需要将 A0 & B0 加载到内存,使用 两个set 求交集即可,A1 & B1 依次类推...

问题是如果小文件也太大了呢?此时分两种情况

a. 这个小文件中大多数都是 1 个 query

b. 这个小文件中有很多不同的 query

我们的做法是:不管文件大小,直接将两个小文件读取到内存中,插入到各自的set中,如果是情况a,尽管文件很大,但是有很多重复,那么后续插入重复的值就会失败,可以在内存中完成操作;如果是情况b,不断插入set,内存不足会抛异常,需要换一个哈希函数,进行二次切分,再找交集

场景二:给一个超过100G大小的 log file,log中存着IP地址,设计算法找到出现次数最多的IP地址?与上题条件相同,如何找到 top K的 IP?

思路:

与场景一思路一样,依旧采用哈希切割将大文件切割成多个小文件,相同的 IP地址必然进入了同一个小文件,此时再将小文件加载到内存,使用 unordered_map 统计次数即可

如果是求 top K 的 IP,也采用哈希切割,将小文件加载到内存中 使用 堆求解即可;当然我们也可以不用哈希切割,直接 采用 解决 Top-K 问题的 建堆方法即可

相关推荐
无言(* ̄(エ) ̄)2 小时前
C语言--运算符/函数/结构体/指针
c语言·开发语言·数据结构·数据库·算法·mongodb
im_AMBER2 小时前
Leetcode 91 子序列首尾元素的最大乘积
数据结构·笔记·学习·算法·leetcode
CoderCodingNo2 小时前
【CSP】CSP-XL 2025辽宁复赛真题-第三题, 小L打比赛(match)
数据结构·算法
TheSumSt11 小时前
Python丨课程笔记Part3:语法进阶部分(控制结构与基础数据结构)
数据结构·笔记·python
长安er14 小时前
LeetCode 20/155/394/739/84/42/单调栈核心原理与经典题型全解析
数据结构·算法·leetcode·动态规划·
!停17 小时前
c语言动态申请内存
c语言·开发语言·数据结构
悟能不能悟17 小时前
list<string> 和String[],转化为jsonstr是不是一样的
数据结构·windows·list
TechNomad18 小时前
二叉堆&大根堆&小根堆的介绍和使用
数据结构
发疯幼稚鬼18 小时前
图的存储与拓扑排序
数据结构·算法·排序算法·拓扑学