【 C++ 】哈希应用

bitset位图

位图的引用

看这样一道面试题:

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

单纯从判断一个数是否在一串数字的角度看,我们很容易想到下面的方法:

  1. 把这40亿个整数放到set、unordered_set容器里头,调用find函数来判断。
  2. 把这40亿个整数进行外排序,再去二分查找。

单就此题而言,为了推翻上面两种方法,首先,我们要清楚40亿个整数,占用多少空间:

40亿个整数 = 160亿个字节
1GB = 1024MB
1024MB = 1024 * 1024KB
1024 * 1024KB = 1024 * 1024 * 1024Byte ≈ 10亿字节

综上1GB ≈ 10亿字节
   16GB ≈ 160亿字节

计算得知,40亿整数占16个G内存,光数据就占了16G,若放到set容器,其底层红黑树的内部也有负载的消耗(存颜色,三叉连……),再算上16G的消耗,消耗太大了,内存不够,承受不住。同理,内存不够,数据压根放不到内存,也就不能进行排序。

为了解决此问题,这就需要我们用到位图来解决。

  • 我们在判断一个数据是否在给定的整形数据中,结果只有在或者不在这两种状态,那么就可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。这里我们采用直接定址法的哈希,用一个比特位标识映射值在不在,这就是位图。示例:

在这里插入图片描述

对于40亿个整数,我们要我们要开整型的最大值(2^32 - 1)个bit位,大概占500MB的内存:

1G = 2^30Byte ≈ 10亿字节
(2^32 - 1)Byte ≈ 40亿字节 ≈ 4G
1Byte = 8bit
(2^32 - 1)bit = 4G/8500MB
 

由此可见,使用位图的方法,大大减少了内存的消耗,并且能很好的解决此问题。

位图的概念

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

位图的应用

  • 快速查找某个数据是否在一个集合中。
  • 排序 + 去重。
  • 求两个集合的交集、并集等。
  • 操作系统中磁盘块标记。

bitset的构造方式

  1. 使用默认构造函数,构造一个16位的位图,默认初始化为0。
bitset<16> bs1;
  1. 元素按照给定整数的二进制位进行初始化:0xff ——> 1111 1111。
bitset<16> bs2(0xfa2);//0000111110100010
  1. 使用01的string进行初始化:std::string(“01101001”) ——> 01101001。
bitset<16> bs3(string("01101001"));//0000000001101001
  1. 使用01的字符串进行初始化:(“01101001”) ——> 01101001。
bitset<16> bs4("01101001");//0000000001101001

bitset成员函数的使用

bitset常用成员函数如下表格所示:

在这里插入图片描述

具体实例:

int main()
{
	bitset<16> bs;
	bs.set(4);
	bs.set(6);
	bs.set(2);
	cout << bs.size() << endl;//16
	cout << bs << endl;//0000000001010100
	//获取指定位的状态
	cout << bs.test(0) << endl;//0
	cout << bs.test(2) << endl;//1
	//反转所有位
	bs.flip();
	cout << bs << endl;//1111111110101011
	//反转第1位
	bs.flip(1);
	cout << bs << endl;//1111111110101001
	cout << bs.count() << endl;//12
	//清空第3位
	bs.reset(3);
	cout << bs << endl;//1111111110100001
	//清空所有位
	bs.reset();
	cout << bs.none() << endl;//1
	cout << bs.any() << endl;//0
	//设置所有位
	bs.set();
	cout << bs.all() << endl;//1
	return 0;
}

使用成员函数set、reset、flip时,若指定了某一位则操作该位,若未指定位则操作所有位。

bitset运算符的使用

如表格所示:

在这里插入图片描述

具体实例:

int main()
{
	//>>输入、<<输出运算符
	bitset<8> bs;
	cin >> bs;//10100
	cout << bs << endl;//00010100
	//复合赋值运算符
	bitset<8> bs1("101011");
	bitset<8> bs2("100100");
	cout << (bs1 >>= 2) << endl;//00001010
	cout << (bs2 |= bs1) << endl;//00101110
	//位运算符
	bitset<8> bs3("10010");
	bitset<8> bs4("11001");
	cout << (bs3 & bs4) << endl;//00010000
	cout << (bs3 ^ bs4) << endl;//00001011
	//operator[]运算符
	cout << bs3[4] << endl;//1
	cout << bs3[2] << endl;//0
}

布隆过滤器

布隆过滤器的引出

我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那些已经存在的记录。 如何快速查找呢?

  1. 用哈希表存储用户记录,缺点:浪费空间。

  2. 用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理了。但我们可以使用一些哈希算法把字符串类型转换成整型,比如BKDR哈希算法,但是这里还存在一个问题。字符串的组合方式太多了,一个字符的取值有256种,一个数字只有10种,所以不可避免会出现哈希冲突。

  3. 将哈希与位图结合的方法,即布隆过滤器。

布隆过滤器的概念

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

在这里插入图片描述
点击跳转->详解布隆过滤器的原理,使用场景和注意事项

布隆过滤器的误判

布隆过滤器是一个大型位图(bit数组或向量) + 多个无偏哈希函数

在这里插入图片描述

如果我们要映射一个值到布隆过滤器中,我们需要使用多个不同的哈希函数生成多个哈希值,并对每个生成的哈希值指向的 bit 位置 1,例如针对值 “source” 和三个不同的哈希函数分别生成了哈希值 2、4、7,则上图转变为:

在这里插入图片描述

现在,如果我们要查询"source"这个字符串是否存在,就要判断位图中下标2,4,7对应的值是否均为1,若是,则说明此字符串“可能”存在。注意这里就可能出现误判了,至于为什么我们先再存一个字符串"create",假设哈希函数返回3,4,8,则对应的图如下:

在这里插入图片描述

值得注意的是,4 这个 bit 位由于两个值的哈希函数都返回了这个 bit 位,因此它被覆盖了。现在我们如果想查询 “flower” 这个值是否存在,哈希函数返回了 2、5、8三个值,结果我们发现 5 这个 bit 位上的值为 0,说明没有任何一个值映射到这个 bit 位上,因此我们可以很确定地说 “flower” 这个值不存在。而当我们需要查询 “source” 这个值是否存在的话,那么哈希函数必然会返回 2、4、7,然后我们检查发现这三个 bit 位上的值均为 1,那么我们可以说 “source” 存在了么?答案是不可以,只能是 “source” 这个值可能存在(发生了误判)。

这是为什么呢?答案很简单,因为随着增加的值越来越多,被置为 1 的 bit 位也会越来越多,这样某个值 “taobao” 即使没有被存储过,但是万一哈希函数返回的三个 bit 位都被其他值置位了 1 ,那么程序还是会判断 “taobao” 这个值存在。像上面的字符串source,哈希函数返回的是2,4,7,可是先前的字符串create,哈希函数返回的是3,4,8,你怎么知道比特位4的值对应的是字符串source呢?我说它是字符串create的也没毛病吧,因此“source”可能存在。这就是误判出现的典型现象。

布隆过滤器是无法解决误判的问题的,一个key通过多种哈希函数映射多个比特位只能说是降低误判的概率,但无法去除。

布隆过滤器的应用场景

根据布隆过滤器的概念,我们得知,只要数据允许误判,并且不会对业务造成影响,就允许使用布隆过滤器,有如下场景。

  1. 注册的时候,快速判断一个昵称是否使用过

    • 如果一个不在布隆过滤器里头,表示没有用过;如果在,就需要再去数据库确认查找一遍
  2. 黑名单

    • 如果一个人不在布隆过滤器里头,表示可同行;如果在,需要再去系统确认
  3. 过滤层,提高查找数据效率

    • 如果一个数据在布隆过滤器里头,接着去数据系统中查找具体的那个;如果不在,直接返回,可以不用进行后续昂贵的查询请求。
  4. 对爬虫网址进行过滤,爬过的不用再爬;

布隆过滤器优缺点

优点:

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

缺点:

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

如何选择哈希函数个数和布隆过滤器长度

很显然,过小的布隆过滤器很快所有的 bit 位均为 1,那么查询任何值都会返回“可能存在”,起不到过滤的目的了。布隆过滤器的长度会直接影响误报率,布隆过滤器越长其误报率越小。

另外,哈希函数的个数也需要权衡,个数越多则布隆过滤器 bit 位置位 1 的速度越快,且布隆过滤器的效率越低;但是如果太少的话,那我们的误报率会变高。

误判率和哈希函数个数及布隆过滤器长度之间的关系:

在这里插入图片描述

如何选择适合业务的哈希函数的个数和布隆过滤器长度呢,一大佬给出的一个公式:

在这里插入图片描述

其中k为哈希函数个数,m为布隆过滤器长度,n为插入的元素个数,p为误判率。

我们可以大概估算一下如果使用3个哈希函数,k = 3,ln2≈0.7,k = m/n * 0.7
通过计算得知m和n的关系大概是m = 4.3n,
也就是布隆过滤器的长度应该是插入元素个数的4倍。

布隆过滤器的框架

这里布隆过滤器要实现成一个模板类,因为布隆过滤器插入的元素类型不固定(整型、字符串……),正因为元素类型不固定,所以要通过哈希函数把数据类型转换为整型。但一般情况下布隆过滤器都是用来处理字符串的,所以这里可以将模板参数K的缺省类型设置为string。这里我们假定传入3个哈希函数,通过上述计算,布隆过滤器的长度大概是插入元素个数的四倍。

布隆过滤器的成员也是一个位图,我们可以在布隆过滤器设置一个非类型模板参数M,用于调用者指定位图的长度。

template<size_t M,
	class K = string,
	class HashFunc1 = BKDRHash,
	class HashFunc2 = APHash,
	class HashFunc3 = DJBHash>
class BloomFilter
{
public:
	//……
private:
	bitset<M> _bs;
};

布隆过滤器的三个哈希函数的作用是把数据转换成三个不同的整型,便于后续建立映射关系,这里我们使用BKDRHash、APHash和DJBHash这三种算法:

struct BKDRHash
{
	size_t operator()(const string& s)
	{
		size_t value = 0;
		for (auto ch : s)
		{
			value *= 31;
			value += ch;
		}
		return value;
	}
};
struct APHash
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (long 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 DJBHash
{
	size_t operator()(const string& s)
	{
		size_t hash = 5381;
		for (auto ch : s)
		{
			hash += (hash << 5) + ch;
		}
		return hash;
	}
};

其它哈希函数算法的链接:各种字符串Hash函数算法

布隆过滤器的Set插入

布隆过滤器的插入就是提供一个Set接口,核心思想就是把插入的元素通过三个哈希函数获取对应的整型并%比特位数从而获得对应的3个映射位置,再把这三个位置置为1即可。

在这里插入图片描述

//set插入
void Set(const K& key)
{
	//把插入的元素通过哈希函数获取对应的三个映射位置
	size_t hash1 = HashFunc1()(key) % M;
	size_t hash2 = HashFunc2()(key) % M;
	size_t hash3 = HashFunc3()(key) % M;
	//把映射的三个位置设置为1
	_bs.set(hash1);
	_bs.set(hash2);
	_bs.set(hash3);
}

布隆过滤器的Test查找

布隆过滤器的查找就是提供一个Test接口,实现规则如下:

  1. 把测试数据通过三个哈希函数获取对应的整型并%比特位数从而获得对应的3个映射位置。
  2. 如果三个位置中有任何一个位置不是1,直接返回false,说明查找的值不可能存在。
  3. 只有三个位置全部为1,才可返回true,但是可能会存在误判。
//test查找
bool Test(const K& key)
{
	size_t hash1 = HashFunc1()(key) % M;
	//依次判断key通过哈希函数映射的三个位置是否都被设置
	if (_bs.test(hash1) == false)
	{
		return false;//key必定不在
	}
	size_t hash2 = HashFunc2()(key) % M;
	if (_bs.test(hash2) == false)
	{
		return false;//key必定不在
	}
	size_t hash3 = HashFunc3()(key) % M;
	if (_bs.test(hash3) == false)
	{
		return false;//key必定不在
	}
	//三个位置都在,才能返回true,但是会存在误判
	return true;
}

布隆过滤器的删除

布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。

  • 比如:删除上图中"create"元素,如果直接将该元素所对应的二进制比特位置0,“source”元素也被删除了,因为这两个元素在多个哈希函数计算出的比特位上刚好有重叠。

一种支持删除的方法(计数法删除):

  • 将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作。

在这里插入图片描述

缺陷:

  1. 无法确认元素是否真正在布隆过滤器中。
  2. 存在计数回绕。

总结:

布隆过滤器不支持直接删除归根结底在于其主要就是用来节省空间和提高效率的,在计数法删除时需要遍历文件或磁盘中确认待删除元素确实存在,而文件IO和磁盘IO的速度相对内存来说是很慢的,并且为位图中的每个比特位额外设置一个计数器,就需要多用原位图几倍的存储空间,这个代价也是不小的。若支持删除就不那么节省空间了,也就违背了布隆过滤器的本质需求。

#pragma once
#include<iostream>
using namespace std;
#include<bitset>
#include<string>
namespace cpp
{
	struct BKDRHash
	{
		size_t operator()(const string& s)
		{
			size_t value = 0;
			for (auto ch : s)
			{
				value *= 31;
				value += ch;
			}
			return value;
		}
	};
	struct APHash
	{
		size_t operator()(const string& s)
		{
			size_t hash = 0;
			for (long 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 DJBHash
	{
		size_t operator()(const string& s)
		{
			size_t hash = 5381;
			for (auto ch : s)
			{
				hash += (hash << 5) + ch;
			}
			return hash;
		}
	};

	template<size_t M,
		class K = string,
		class HashFunc1 = BKDRHash,
		class HashFunc2 = APHash,
		class HashFunc3 = DJBHash>
	class BloomFilter
	{
	public:
		//set插入
		void Set(const K& key)
		{
			//把插入的元素通过哈希函数获取对应的三个映射位置
			size_t hash1 = HashFunc1()(key) % M;
			size_t hash2 = HashFunc2()(key) % M;
			size_t hash3 = HashFunc3()(key) % M;
 			//把映射的三个位置设置为1
			_bs.set(hash1);
			_bs.set(hash2);
			_bs.set(hash3);
		}
		//test查找
		bool Test(const K& key)
		{
			size_t hash1 = HashFunc1()(key) % M;
			//依次判断key通过哈希函数映射的三个位置是否都被设置
			if (_bs.test(hash1) == false)
			{
				return false;//key必定不在
			}
			size_t hash2 = HashFunc2()(key) % M;
			if (_bs.test(hash2) == false)
			{
				return false;//key必定不在
			}
			size_t hash3 = HashFunc3()(key) % M;
			if (_bs.test(hash3) == false)
			{
				return false;//key必定不在
			}
			//三个位置都在,才能返回true,但是会存在误判
			return true;
		}
	private:
		bitset<M> _bs;
	};
}

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值