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 位图的模拟实现



#pragma once
#include <vector>namespace bit
{template<size_t N>class bit_set{public:bit_set(){_bs.resize(N / 32 + 1);}//x映射的位标记成1void set(size_t x){size_t i = x / 32;size_t j = x % 32;_bs[i] |= (1 << j);}//x映射的位标记成0void 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;};
}
#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亿九千万的空间呢?有以下三种方式:
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亿九千万的,因为库里面的位图是用静态数组来实现的,而静态数组的空间是在栈上开辟的,空间大小是不够的。想要解决这个问题的话,我们可以把它转移到堆上去(像下面这样)。
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标记的值即可。
#pragma once
#include <vector>namespace bit
{template<size_t N>class bit_set{public:bit_set(){_bs.resize(N / 32 + 1);}//x映射的位标记成1void set(size_t x){size_t i = x / 32;size_t j = x % 32;_bs[i] |= (1 << j);}//x映射的位标记成0void 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 布隆过滤器的模拟实现
#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次数。

