C++篇(17)哈希拓展学习

一、位图

1.1 位图的引入

腾讯/百度等公司曾出过这样一道面试题:给你40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。

解题思路1:暴力遍历,时间复杂度O(N),太慢

解题思路2:排序+二分查找。时间复杂度O(N*logN) + O(logN)

深入分析一下,解题思路2是否可行,我们先来算一算40亿个数据大概需要多少内存?

1G = 1024MB = 1024 * 1024KB = 1024*1024*1024Byte,约等于10亿多Byte,那么40亿个整数约等于16G,说明40亿个数是无法直接放到内存中的,只能放到硬盘文件中,但是二分查找只能对内存数组中的有序数据进行查找。

解题思路3:判断数据是否在给定的整型数据中,结果是在或者不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,二进制位1代表存在,为0代表不存在。那么我们设计一个用位表示数据是否存在的数据结构,这个数据结构就叫位图。

1.2 位图的模拟实现

cpp 复制代码
#pragma once
#include <vector>

namespace bit
{
	template<size_t N>
	class bit_set
	{
	public:
		bit_set()
		{
			_bs.resize(N / 32 + 1);
		}

		//x映射的位标记成1
		void set(size_t x)
		{
			size_t i = x / 32;
			size_t j = x % 32;

			_bs[i] |= (1 << j);
		}

		//x映射的位标记成0
		void reset(size_t x)
		{
			size_t i = x / 32;
			size_t j = x % 32;

			_bs[i] &= (~(1 << j));
		}

		//x映射的位是1返回真
		//x映射的位是0返回假
		bool test(size_t x)
		{
			size_t i = x / 32;
			size_t j = x % 32;

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

	private:
		std::vector<size_t> _bs;
	};
}
cpp 复制代码
#include <iostream>
#include "BitSet.h"
using namespace std;

int main()
{
	bit::bit_set<100> bs;
	bs.set(32);
	bs.set(33);

	bs.reset(33);
	bs.set(34);

	cout << bs.test(31) << endl;
	cout << bs.test(32) << endl;
	cout << bs.test(33) << endl;
	cout << bs.test(34) << endl;
	cout << bs.test(35) << endl;

	return 0;
}

回到一开始的问题上来,我们如何开42亿九千万的空间呢?有以下三种方式:

cpp 复制代码
bit::bit_set<-1> bs1;
bit::bit_set<0xffffffff> bs2;
bit::bit_set<UINT_MAX> bs3;

1.3 C++库的位图

https://legacy.cplusplus.com/reference/bitset/

这里要注意一下,标准库里的位图是不能像我们模拟实现的位图那样开42亿九千万的,因为库里面的位图是用静态数组来实现的,而静态数组的空间是在栈上开辟的,空间大小是不够的。想要解决这个问题的话,我们可以把它转移到堆上去(像下面这样)。

cpp 复制代码
std::bitset<-1>* ptr = new std::bitset<-1>();

1.4 位图的优缺点

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

缺点:只适用于整型

1.5 位图的相关考察题目

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

A:虽然是100亿个数,但还是按范围开空间,所以还是开2^32个位,和前面题目是一样的。

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

A:把数据读出来,分别放到两个位图中,依次遍历,同时在两个位图的值就是交集。

Q:一个文件有100亿个整数,1G内存,设计算法找到出现次数不超过两次的所有整数?

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

cpp 复制代码
#pragma once
#include <vector>

namespace bit
{
	template<size_t N>
	class bit_set
	{
	public:
		bit_set()
		{
			_bs.resize(N / 32 + 1);
		}

		//x映射的位标记成1
		void set(size_t x)
		{
			size_t i = x / 32;
			size_t j = x % 32;

			_bs[i] |= (1 << j);
		}

		//x映射的位标记成0
		void reset(size_t x)
		{
			size_t i = x / 32;
			size_t j = x % 32;

			_bs[i] &= (~(1 << j));
		}

		//x映射的位是1返回真
		//x映射的位是0返回假
		bool test(size_t x)
		{
			size_t i = x / 32;
			size_t j = x % 32;

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

	private:
		std::vector<size_t> _bs;
	};


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

			if (!bit1 && !bit2)  // 00->01
			{
				_bs2.set(x);
			}
			else if (!bit1 && bit2)  // 01->10
			{
				_bs1.set(x);
				_bs2.reset(x);
			}
			else if (bit1 && !bit2)  // 10->11
			{
				_bs1.set(x);
				_bs2.set(x);
			}
		}


		//返回0  出现0次
		//返回1  出现1次
		//返回2  出现2次
		//返回3  出现2次以上
		int get_count(size_t x)
		{
			bool bit1 = _bs1.test(x);
			bool bit2 = _bs2.test(x);

			if (!bit1 && !bit2)
			{
				return 0;
			}
			else if (!bit1 && bit2)
			{
				return 1;
			}
			else if (bit1 && !bit2)
			{
				return 2;
			}
			else
			{
				return 3;
			}
		}

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

二、布隆过滤器

2.1 布隆过滤器的引入

在一些场景下面,有大量数据需要判断是否存在,而这些数据不是整型,那么位图就不能使用了,使用红黑树/哈希表等内存空间可能不够。这些场景就需要用布隆过滤器来解决。

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

布隆过滤器的思路就是把key先映射转成哈希整型值,再映射一个位。但是如果只映射一个位的话,冲突率会比较高,所以可以通过多个哈希函数映射多个位,降低冲突率。

布隆过滤器和哈希表不一样,它无法解决哈希冲突,因为它压根不存储这个值,只标记映射的位。它的思路是尽可能降低哈希冲突。判断一个值key在是不准确的,判断一个值key不在是准确的。

2.2 布隆过滤器的模拟实现

cpp 复制代码
#pragma once
#include <string>
#include "BitSet.h"

struct HashFuncBKDR
{
	size_t operator()(const std::string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			hash *= 31;
			hash += ch;
		}
		return hash;
	}
};

struct HashFuncAP
{
	size_t operator()(const std::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 HashFuncDJB
{
	size_t operator()(const std::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 = std::string, 
	class Hash1 = HashFuncBKDR,
	class Hash2 = HashFuncAP,
	class Hash3 = HashFuncDJB>
class BloomFilter
{
public:
	void Set(const K& key)
	{
		size_t hash1 = Hash1()(key) % M;
		size_t hash2 = Hash2()(key) % M;
		size_t hash3 = Hash3()(key) % M;

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

	bool Test(const K& key)
	{
		size_t hash1 = Hash1()(key) % M;
		if (!_bs.test(hash1))
		{
			return false;
		}

		size_t hash2 = Hash2()(key) % M;
		if (!_bs.test(hash2))
		{
			return false;
		}

		size_t hash3 = Hash3()(key) % M;
		if (!_bs.test(hash3))
		{
			return false;
		}

		return true;  //可能存在误判
	}

private:
	const size_t M = N * X;
	bit::bit_set<N * X> _bs;
};

2.3 布隆过滤器的应用

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

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

布隆过滤器在实际中还有爬虫系统中URL去重、垃圾邮件过滤、预防缓存穿透、数据库查询提效等应用。

三、海量数据处理

3.1 十亿个整数里面求最大的前100个

经典的TopK问题,用堆解决。这个在数据结构初阶堆章节具体讲解过。

3.2 位图章节讲解的位图相关题目

3.3 给两个文件,分别有100亿个query,我们只有1G内存,如何找出两个文件的交集?

**分析:**假设平均每个query字符串50Byte,100亿个query就是5000亿Byte,约等于500G,用哈希表/红黑树等数据结构肯定是无能为力的。

**解决方案1:**首先可以用布隆过滤器解决,一个文件的query放进布隆过滤器,另一个文件一次查找,在就是交集。问题就是找到的交集不够准确,因为在的值可能是误判的,但是交集一定被找到了。

**解决方案2:**哈希切分。首先内存的访问速度远大于硬盘,大文件放到内存中搞不定,那么我们可以考虑切分为小文件,再放进内存处理。但是这里不能平均切分,因为平均切分之后,每个小文件都需要依次暴力处理,效率还是太低了。这里就要利用到哈希切分,依次读取文件中的query,i = HashFunc(query) % N,N为准备切分多少份小文件。N取决于切成多少份内存能放得下,query放进第i号小文件,这样A和B中相同的query算出的hash值i是一样的,相同的query就进入的编号相同的小文件就可以和编号相同的文件直接找交集,不用交叉找,效率就提升了。

但是哈希切分也会有一个小问题,就是每个小文件不是均匀切分的,可能会导致某个小文件很大,内存放不下。而某个小文件很大有两种情况:1.这个小文件中大部分都是同一个query。 2.这个小文件是由很多不同的query构成的,本质是这些query冲突了。针对情况1,其实放到内存的set是可以放的下的,因为set能去重。针对情况2,需要换个哈希函数继续二次哈希切分。所以我们遇到大于1G的小文件,可以继续读到set中找交集。若set insert时抛出了异常(set插入数据抛异常只可能是申请内存失败了,不会有其他情况),那么说明内存放不下,是情况2,换个哈希函数进行二次哈希切分后再对应找交集。

3.4 给一个超过100G大小的log file,log中存着IP地址,设计算法找到出现次数最多的IP地址?查找出现次数前十的IP地址?

本题思路和上一题完全类似,依次读取文件A中的ip,i = HashFunc(ip) % 500,ip放进Ai号小文件,然后依次用map<string,int>对每个Ai小文件统计ip次数,同时求出现次数最多的ip或者topK的ip。本质是相同的ip在哈希切分的过程中,一定进入同一个小文件Ai,不可能出现同一个ip进入Ai和Aj的情况,所以对Ai进行统计次数就是准确的ip次数。

相关推荐
“愿你如星辰如月”2 小时前
Linux:进程间通信
linux·运维·服务器·c++·操作系统
灵晔君3 小时前
C++标准模板库(STL)——list的模拟实现
c++·list
Justinyh4 小时前
1、CUDA 编程基础
c++·人工智能
white-persist5 小时前
差异功能定位解析:C语言与C++(区别在哪里?)
java·c语言·开发语言·网络·c++·安全·信息可视化
ShineWinsu5 小时前
对于数据结构:链式二叉树的超详细保姆级解析—中
数据结构·c++·算法·面试·二叉树·校招·递归
liu****6 小时前
20.传输层协议TCP
服务器·网络·数据结构·c++·网络协议·tcp/ip·udp
沐怡旸6 小时前
【穿越Effective C++】条款20:宁以pass-by-reference-to-const替换pass-by-value——参数传递的效率与语义
c++·面试
八个程序员6 小时前
c++音乐——《两只老虎》
c++·游戏
..空空的人6 小时前
C++基于websocket的多用户网页五子棋 ---- 模块介绍1
开发语言·c++·websocket