yo!这里是哈希应用相关介绍

目录

前言

位图

模拟实现

应用举例

布隆过滤器

模拟实现

应用举例

后记


前言

在介绍unordered系列容器时,我们知道其底层使用的是哈希表,其实哈希是一种方法,是一种思想,哈希思想(Hashing)是一种在常数时间内完成数据插入和查找的算法思想。其基本思想是通过对数据进行一个映射函数的变换,把数据存储在一个数组中,这个数组称为哈希表。受这种思想启发,许多哈希应用应运而生,包括位图、布隆过滤器、海量数据处理等,下面我们逐一进行介绍,深度理解一下哈希这种思想,无论是在解决笔试题还是面试题都能有所帮助。

位图

在32位机器下,一个整数最大可以表示到2^32,也就是42亿多。如果给定40亿个数,如何查找一个数是否在这40亿个数中?理想情况下,创建一个size为2^32的vector,用1标识存在,用0标识不存在,再将这40亿个数映射到vector中,查找一个数只要查看对应下标所在元素是1or0即可,但是想一下,这个vector有多大,2^32(个数)*4(int大小)Byte=16G,这是何等浪费。

想一下,非要用一个int来记录0、1以标记存在情况吗?是不是可以考虑使用一个比特位,如果用一个比特位标记那需要多大空间呢?2^32bit=512MB,这极大地减少了内存的消耗同时又达到了目的。因此,可以在vector中存储字符,一个字符是8个比特位,使用的时候是一个比特位一个比特位的用,设计出这么一个类在处理这种海量数据上面可以说是相当的合适了,stl的位图(bitset)就是这样实现的,如下图。所谓位图,就是用每一位比特位来存放某种状态,适用于海量数据且数据无重复的场景,来判断某个数据存不存在的。具体使用不过多介绍,参考

https://cplusplus.com/reference/https://cplusplus.com/reference/类中主要函数的使用在模拟实现时会介绍到,继续往下看!

模拟实现

首先,通过模板参数将需要存储的个数传进来,因为我这里将成员属性vector的元素设置成了char(也可设置成int),所以在构造函数中将N个bit除以8,就是char的个数,再+1的原因是预留(比如N为6,则N/8就是0,+1才可以进行存储,多出来两个bit也没事)。

其次,对于set,将N除以8得到第几个char,将N取模8得到此char的第几个比特位,将对应比特位【或】上1,其余【或】上0不变;对于reset,如set一样,不同在于将对应比特位【且】上0,其余位【且】上1不变;对于test,将对应比特位【且】上1得到0则当前比特位是0,得到1则当前比特位是1。

代码:

cpp 复制代码
template<size_t N>
class Bitset
{
public:
	Bitset()
	{
		_bits.resize(N / 8 + 1, 0);
	}
	//将x位置的比特位设置成1
	void set(size_t x)
	{
		size_t i = x / 8;
		size_t j = x % 8;
		_bits[i] |= (1 << j);
	}
	//将x位置的比特位设置成0
	void reset(size_t x)
	{
		size_t i = x / 8;
		size_t j = x % 8;
		_bits[i] &= ~(1 << j);
	}
	//x位置的比特位是否为1
	bool test(size_t x)
	{
		size_t i = x / 8;
		size_t j = x % 8;
		if ((_bits[i] & (1 << j)) == 0)
			return false;
		else
			return true;
	}
private:
	vector<char> _bits;
};

应用举例

1.给100亿个整数,找到只出现一次的整数

因为整数范围是0~2^32-1,即最大是42亿多,所以这100亿个整数肯定存在重复。

这里我们借助两个bitset,对应两个比特位结合起来标识不同的情况,即00表不存在;01表仅存在一个;10表存在两个及以上,将100亿个整数插入之后,查看对应比特位是01的就是只出现一次的整数。

实现代码参考如下,twoBitset1是对此实现的一个类,成员对象包括两个位图bitset;对于set函数,若遇到00的情况说明不存在此整数,将其变成01,表示插入了一个,若遇到01说明此整数仅存在一个,将其变成10,表示再插入一个,若遇到10,说明此整数有两个或以上,无需再插入了;对于print_once_num函数,功能是同时遍历两个位图,对应比特位是01的记录下来,即为仅出现一次的整数。

代码:

cpp 复制代码
template <size_t N>
class twoBitset1
{
public:
	void set(size_t x)
	{
		bool bs1bool = _bs1.test(x);
		bool bs2bool = _bs2.test(x);
		if (bs1bool == false && bs2bool == false)
		{
			_bs2.set(x);
		}
		else if (bs1bool == false && bs2bool == true)
		{
			_bs1.set(x);
			_bs2.reset(x);
		}
	}

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

private:
	bitset<N> _bs1;
	bitset<N> _bs2;
};

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

先想一下强调只有1G内存的意义在哪。前面提到过整数范围是0~2^32-1,也就是有2^32个整数,虽然文件里的整数有100亿个,但是说明其中肯定有重复的,映射到0~2^32-1的范围内最多也就是有2^32个,也就是2^32个bit,即2^29个byte=2^19个kb=2^9个mb,即512mb,使用两个这样的位图正好1G内存。

这里我们就是使用两个位图,将两个文件的整数分别映射到这两个位图中,再让两个位图的对应比特位【且】一下(可以【且】到某一个位图上),然后找到此位图上比特位为1的整数就是两个文件的交集,思路很清晰,实现也不难,这里无参考代码,可以自己实现一下。

3.一个文件中有100亿个整数,有1G内存,找到出现次数不超过两次的所有整数。

如题,不超过两次,也就是仅出现一次或出现两次的整数。其实,这道题是第一题的变形,思路跟第一个大差不差,就是需要多标识一种情况,即00标识没出现,01标识仅出现一次,10标识仅出现两次,11标识出现三次及以上,实现过程与第一题也一样,这里也不多赘述,可以自己实现一下。

布隆过滤器

思考一下上面位图的缺点,是不是只能映射整数,如果关键字是非整数,比如浮点数、string,面对海量数据又如何处理呢,是不是可以通过哈希函数将这类关键字进行一个转换再映射到位图当中来标记数据是否存在啊,布隆过滤器(bloomfilter)就是这么操作的。布隆过滤器是一种概率性数据结构,使用多个哈希函数将同一个数据映射到位图中,可以高效地插入和查询,用来告诉某样东西一定不存在或者可能存在,如下图。

比如说我们设计三个哈希函数来映射key,对于插入函数set,根据三个哈希函数将对应三个比特位改为1即可;对于查询函数,给一个key,也通过三个哈希函数计算出对应下标查看三个位置是否都是1,首先有一个不是1那肯定就是不存在,那三个都是1就一定存在吗?我们说不一定,因为这三个位置可能是另外一个key映射过来的,并不一定是你当前查询的key映射的,这也就是布隆过滤器为什么告诉你某样东西一定不存在或可能存在的原因,要是判断为存在,那就是可能存在也可能不存在,具体存不存在需要进一步判断,但这不是布隆过滤器的任务了。

模拟实现

先看属性,属性是一个位图,大小是ratio*N,其中N是插入元素的个数,ratio是一定的比率,详细可看详解布隆过滤器的原理,使用场景和注意事项 - 知乎 (zhihu.com)https://zhuanlan.zhihu.com/p/43263751https://zhuanlan.zhihu.com/p/43263751再看模板参数,除了N以外,布隆过滤器需要传入一个类型,这里默认是string,因为布隆过滤器常用于处理字符串,还需要传入三个Hash函数用以处理同一个key映射到三个比特位,这里Hash函数也是针对于string,若是不同的类型,可以针对性的传入,其中针对于string的Hash函数的选取可参考各种字符串Hash函数(转) - 鸭子船长 - 博客园 (cnblogs.com)https://www.cnblogs.com/zl1991/p/11820922.htmlhttps://www.cnblogs.com/zl1991/p/11820922.html这里我选择了其中的三个。

对于插入函数set,使用Hash函数映射出三个哈希值,分别将其变成1;对于判断存在函数test,实现逻辑一样,使用位图的test函数判断是否存在,实现代码可参考下方。

代码:

cpp 复制代码
#pragma once
#include <iostream>
#include <bitset>
#include <string>
#include <vector>
using namespace std;

//将关键字类型默认为string,是因为布隆过滤器常用于处理字符串
template<size_t N, class K = string, 
	class Hash1 = HashBKDR, class Hash2 = HashAP, class Hash3 = HashDJB>
class BloomFilter
{
public:
	void set(const K& key)
	{
		Hash1 hash1;
		Hash2 hash2;
		Hash3 hash3;

		size_t i1 = hash1(key) % (ratio * N);   //注意ratio*N加上括号,否则会有优先级问题
		size_t i2 = hash2(key) % (ratio * N);
		size_t i3 = hash3(key) % (ratio * N);

		_bs.set(i1);
		_bs.set(i2);
		_bs.set(i3);
	}

	bool test(const K& key)
	{
		Hash1 hash1;
		Hash2 hash2;
		Hash3 hash3;

		size_t i1 = hash1(key) % (ratio * N);   //注意ratio*N加上括号,否则会有优先级问题
		size_t i2 = hash2(key) % (ratio * N);
		size_t i3 = hash3(key) % (ratio * N);
		
		if (!_bs.test(i1))
			return false;   //明确不存在
		if (!_bs.test(i2))
			return false;   //明确不存在
		if (!_bs.test(i3))
			return false;   //明确不存在
		return true;   //可能存在(即有误判)
	}

private:
	const static size_t ratio = 5;
	bitset<ratio* N> _bs;
};

注意:为什么布隆过滤器没有支持reset删除函数?

因为删除某一个key时可能会影响到其他key,eg:如下图,删除美团,百度也会受到影响

那如何扩展布隆过滤器使得支持删除?可以使用计数技术为每个比特位增加一个计数器(类似硬链接),有key映射到比特位,计数器就++,删除就--,当减到0才真正的删除,比如:

但是布隆过滤器并没有这样做,因为空间消耗更加的大了,本身的优势就被削弱了,能应用到布隆过滤器的地方也不是很需要删除操作。

应用举例

那布隆过滤器能给出某样东西一定不存在,或者可能存在,这样的数据结构能应用在什么情形呢?其实还是比较多的,有很多地方就是不需要特别的准确,只需要一个概率即可,比如说游戏的昵称存在机制、预备黑名单等。

先说游戏的昵称存在机制,在刚开始注册游戏时需要输入一个昵称,当你输入一个已经存在的,游戏会让你重新输入,直到输入一个游戏不存在的昵称。其中可以用一个布隆过滤器实现,将所有昵称放进一个布隆过滤器,当玩家输入一个昵称时,就会到这个布隆过滤器查询,若是不存在则真是不存在,此昵称可以使用,若时是存在则是可能存在,直接让玩家重新输入一个。

一般情况下,黑名单会放进一个数据库,当判断一个ip是不是在黑名单中时,一个个去遍历查询就很麻烦,可以在这个正式黑名单之前放一个预备黑名单,用布隆过滤器实现,查询一个ip时,若不存在则肯定不在正式黑名单中,若存在则可能存在,则需要再进入正式黑名单中遍历检查。
再来看一个有关的面试题:给两个文件,分别有100亿个query(请求(字符串)),只有1G内存,如何找到两个文件的交集,分别给出近似算法和精确算法。

近似算法:

将一个文件的query映射到一个布隆过滤器,遍历另外一个文件的query去查看是否存在。这样做会存在两个问题:①会有误判,因为这是布隆过滤器自身的缺陷;②得到的交集存在重复,但是这也算是达到了近似算法的要求了。

精确算法:

精确算法要求真真切切的交集,不能有重复不能有误判,那这就不能使用布隆过滤器了,这里我们使用哈希切分的思想。假设两个文件分别是A、B,

①一次读取A中的query,根据i=Hash(query)%份数M,将此query放进名为Ai的小文件,B中的query也是如此,分别放进Bi小文件中;

②将对应i相等的Ai、Bi两个小文件加载到内存,用set去判断两个小文件的交集,然后将所有对应小文件的交集放在一起即可,如下图。

原理:A、B中相同的query会进入相同编号的小文件,避免了A中的一个query要与B中所有的query都比较一番。

份数M的选取:按照平分情况分割下的小文件大小能加载进内存的份数。比如说,若一个query10byte,100亿个query就是3000亿type=300G,平均分成1000份下来,一份就是300MB左右,可加载进内存,因此M可选1000。但是值得注意,实际上切割并不是平分,而是哈希切割,也就是可能某一份小文件大小并不是300MB左右,可能已经多的加载不进内存了,此时需要一个循环重新选择哈希函数再去分割。

后记

从上面两种应用以及举例可以看出,哈希思想特别适合处理海量数据的情形,可以将海量的数据通过哈希中一一映射的原理分类,从而解决"是否存在"、"出现几次"问题,这种题型在面试时很大概率会被提到,希望大家能够理解这种思想,在面对各种此类型的题目时都可以不变应万变,有不理解的地方可以问在评论区大家讨论,拜拜!

相关推荐
计算机学姐几秒前
基于SpringBoot+Vue的在线投票系统
java·vue.js·spring boot·后端·学习·intellij-idea·mybatis
penguin_bark6 分钟前
69. x 的平方根
算法
FL16238631299 分钟前
[C++]使用纯opencv部署yolov11-pose姿态估计onnx模型
c++·opencv·yolo
sukalot13 分钟前
windows C++-使用任务和 XML HTTP 请求进行连接(一)
c++·windows
这可就有点麻烦了16 分钟前
强化学习笔记之【TD3算法】
linux·笔记·算法·机器学习
苏宸啊22 分钟前
顺序表及其代码实现
数据结构·算法
lin zaixi()25 分钟前
贪心思想之——最大子段和问题
数据结构·算法
FindYou.26 分钟前
C - Separated Lunch
算法·深度优先
ぃ扶摇ぅ31 分钟前
Windows系统编程(三)进程与线程二
c++·windows
夜雨翦春韭31 分钟前
【代码随想录Day30】贪心算法Part04
java·数据结构·算法·leetcode·贪心算法