1.哈希的应用
1.1位图
1.1.1 位图概念
如果有40亿个不重复的无符号整数,没排过序。给出一个无符号整数,如何快速判断一个数是否在这40亿个数中?
思路一:先进行排序,再将排序过后的数进行二分查找,排序的时间复杂度是(N*logN),二分查找的时间复杂度是(logN)
思路二:放进set或者unordered_set,再进行查找
上面两种思路似乎看上去都是可行的,但是我们知道40亿个无符号整数总共占16GB,对内存的消耗是巨大的。
思路三:利用位图解决内存消耗过大问题
数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。比如下面这个数组:
随即引出位图的概念:所谓位图,就是用每一个比特位位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。
1.1.2 位图的实现
template<size_t N> class BitSet { public: BitSet() { _bits.resize(N / 32 + 1, 0); //这里一定要注意多开一位,因为除以32是向下取整了,那么我们需要再多给一个位置 } void Set(size_t x) { assert(x < N); size_t i = x / 32;// 算出x映射的位在第i个整数 size_t j = x % 32;// 算出x映射的位在这个整数的第j个位 _bits[i] |= (1 << j); //注意这里一定要加或(或操作数中有一个1则结果为1),用1进行左移然后或等赋值给这一位数 } void Reset(size_t x) { assert(x < N); size_t i = x / 32;// 算出x映射的位在第i个整数 size_t j = x % 32;// 算出x映射的位在这个整数的第j个位 _bits[i] &= (~(1 << j)); //这里先进行移位然后取反是为了把我们需要置位的位置弄成0,然后其他位置都为1,方便后续的与操作 //注意这里的&(这里采用与操作是为了不影响操作数中原本的1,并还要把对应位置的1置为0) } bool Test(size_t x) { assert(x < N); size_t i = x / 32;// 算出x映射的位在第i个整数 size_t j = x % 32;// 算出x映射的位在这个整数的第j个位 //两个为1的数相与后结果为1,说明这个数存在 return _bits[i] & (1 << j); // 如果第j位是1,结果是非0,非0就是真 // 如果第j为是0,结果是0,0就是假 } private: vector<int> _bits; };
1.1.3 位图的应用
位图的常见应用场景:
快速查找某个数据是否在一个集合中;排序;求两个集合的交集,并集;操作系统中磁盘块标记等
1. 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?方案一:依次读取第一个文件中的所有数据标记映射到一个位图中,再依次读取另一个文件中的所有整数,判断在不在这个位图中,在就是交集,不在就不是
方案二:依次读取第一个文件中所有的数据标记映射到位图1,再依次读取第二个文件中所有的数据标记映射到位图2.再对两个位图进行与操作(依次与位图中整数即可),与完过后还是1的位映射的整数就是交集.
2. 给定100亿个整数,设计算法找到只出现一次的整数?
这个问题相较于上一个题判断在不在的问题就复杂一些,因为我们采用一个二进制比特位只能判断在或者不在的情景,这里需要我们找出只出现一次的整数,那么我们就可以采用两个比特位来映射一个整数的方式:
标记一个整数的几种状态:
出现0次:标记为00
出现1次,标记为01
出现2次及以上:标记为10
void Set(size_t x) { BitSet<-1> _bit1;//都开到42亿整数个位 BitSet<-1> _bit2; //00->01 if (!_bit1.Test(x) && !_bit2.Test(x)) { _bit2.Set(x); } //01->10 else if(!_bit1.Test(x) && _bit2.Test(x)) { _bit1.Set(x); _bit2.Reset(x); } else if (_bit1.Test(x) && !_bit2.Test(x)) { //这里可以不做任何处理 } else { assert(false); } }
3. 位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数?这个问题和上面的就很类似,不过需要统计的是出现两次的而已,思路都是一样的
标记一个整数的几种状态:
出现0次:标记为00
出现1次,标记为01
出现2次:标记为10
出现3次及以上:标记位11
void Set(size_t x) { BitSet<-1> _bit1;//都开到42亿整数个位 BitSet<-1> _bit2; //00->01 if (!_bit1.Test(x) && !_bit2.Test(x)) { _bit2.Set(x); } //01->10 else if(!_bit1.Test(x) && _bit2.Test(x)) { _bit1.Set(x); _bit2.Reset(x); } //10->11 else if (_bit1.Test(x) && !_bit2.Test(x)) { _bit2.Set(x); } //11的情况 else { //不做处理 } }
总结:根据上面的位图应用不难看出位图的本质就是直接定址法哈希,每个整数映射一个比特位。
分析位图的优缺点:
优点:大量节省空间的消耗,搜索查找速度很快
缺点:只能处理整型,适用场景较为单一
那么下面就引申出位图的变形延申版——布隆过滤器
1.2 布隆过滤器
1.2.1 布隆过滤器提出
我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那些已经存在的记录。 如何快速查找呢?
方式1. 用哈希表存储用户记录,缺点:浪费空间
方式2. 用位图存储用户记录,缺点:不能处理哈希冲突
方式3. 将哈希与位图结合,即布隆过滤器
1.2.2布隆过滤器概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”(注意理解这句话非常重要后面会详细说明),它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
如下图:
1.2.3 布隆过滤器的插入
![]()
void Set(const K& key) { //将同一个字符串采用不同的哈希函数转换成三个不同的整形数 size_t i1 = Hash1()(key) % N; size_t i2 = Hash2()(key) % N; size_t i3 = Hash3()(key) % N; //cout << i1 << " " << i2 << " " << i3 << endl; _bitset.Set(i1); _bitset.Set(i2); _bitset.Set(i3);//分别将算出来的整数对应到位图中并置1 }
1.2.4 布隆过滤器的查找
布隆过滤器的思想是将一个元素用多个哈希函数映射到一个位图中,因此被映射到的位置的比特位一定为1。所以可以按照以下方式进行查找:分别计算每个哈希值对应的比特位置存储的是否为零,只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中。
bool Test(const K& key) { //依次判断每一位,只要存在一位不在就返回错,在是准确的 size_t i1 = Hash1()(key) % N; if (_bitset.Test(i1) == false) { return false; } size_t i2 = Hash2()(key) % N; if (_bitset.Test(i2) == false) { return false; } size_t i3 = Hash3()(key) % N; if (_bitset.Test(i3) == false) { return false; } // 这里3个位都在,有可能是其他key占了,在是不准确的,存在误判 return true; }
注意:布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可能存在,因为有些哈希函数存在一定的误判。
比如:在布隆过滤器中查找"alibaba"时,假设3个哈希函数计算的哈希值为:1、3、7,刚好和其他元素的比特位重叠,此时布隆过滤器告诉该元素存在,但是该元素是不存在的,所以判断元素在库中是不准确的,但是如果布隆过滤器告诉该元素不存在,那么一定不存在,因为相同字符串经过哈希函数过后算出来的整数都是一样的,所以判断元素不在库中是非常准确的.1.2.5 布隆过滤器删除
布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。
比如:删除上图中"tencent"元素,如果直接将该元素所对应的二进制比特位置0,“baidu”元素也被删除了,因为这两个元素在多个哈希函数计算出的比特位上刚好有重叠。
一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作。
缺陷:
1. 无法确认元素是否真正在布隆过滤器中
2. 存在计数回绕显然删除操作用在布隆过滤器是十分不可靠的,尤其是在数据量很大的工程中.
1.2.6 布隆过滤器的模拟实现和测试代码
struct HashBKDR//哈希函数1 { size_t operator()(const std::string& s) { size_t value = 0; for (auto ch : s) { value += ch; value *= 131; } return value; } }; struct HashAP//哈希函数2 { size_t operator()(const std::string& s) { register size_t hash = 0; size_t ch; for (long i = 0; i < s.size(); i++) { ch = s[i]; if ((i & 1) == 0) { hash ^= ((hash << 7) ^ ch ^ (hash >> 3)); } else { hash ^= (~((hash << 11) ^ ch ^ (hash >> 5))); } } return hash; } }; struct HashDJB//哈希函数3 { size_t operator()(const std::string& s) { register size_t hash = 5381; for (auto ch : s) { hash += (hash << 5) + ch; } return hash; } }; template<size_t N, class K = std::string, class Hash1 = HashBKDR, class Hash2 = HashAP, class Hash3 = HashDJB> class BloomFilter { public: void Set(const K& key) { //将同一个字符串采用不同的哈希函数转换成三个不同的整形数 size_t i1 = Hash1()(key) % N; size_t i2 = Hash2()(key) % N; size_t i3 = Hash3()(key) % N; //cout << i1 << " " << i2 << " " << i3 << endl; _bitset.Set(i1); _bitset.Set(i2); _bitset.Set(i3);//分别将算出来的整数对应到位图中并置1 } bool Test(const K& key) { //依次判断每一位,只要存在一位不在就返回错,在是准确的 size_t i1 = Hash1()(key) % N; if (_bitset.Test(i1) == false) { return false; } size_t i2 = Hash2()(key) % N; if (_bitset.Test(i2) == false) { return false; } size_t i3 = Hash3()(key) % N; if (_bitset.Test(i3) == false) { return false; } // 这里3个位都在,有可能是其他key占了,在是不准确的,存在误判 return true; } private: BitSet<N> _bitset; }; void TestBloomFilter() { /*BloomFilter<100> bf; bf.Set("张三"); bf.Set("李四"); bf.Set("牛魔王"); bf.Set("红孩儿"); cout << bf.Test("张三") << endl; cout << bf.Test("李四") << endl; cout << bf.Test("牛魔王") << endl; cout << bf.Test("红孩儿") << endl; cout << bf.Test("孙悟空") << endl;*/ BloomFilter<8000> bf;//随着N的增加误判率会明显降低 size_t N = 1000; //先插入一组字符串 std::vector<std::string> v1; for (size_t i = 0; i < N; ++i) { std::string url = "https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html"; url += std::to_string(1234 + i); v1.push_back(url); } for (auto& str : v1) { bf.Set(str); } //测试相似字符串是否在位图中存在的概率 std::vector<std::string> v2; for (size_t i = 0; i < N; ++i) { std::string url = "https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html"; url += std::to_string(6789 + i); v2.push_back(url);//将字符串放到一个数组中 } 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) { std::string url = "https://zhuanlan.zhihu.com/p/43263751"; url += std::to_string(6789 + i); v3.push_back(url); } size_t n3 = 0; for (auto& str : v3) { if (bf.Test(str)) { ++n3; } } cout << "不相似字符串误判率:" << (double)n3 / (double)N << endl; }
1.2.7 布隆过滤器优点
1. 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关
2. 哈希函数相互之间没有关系,方便硬件并行运算
3. 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
4. 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势5. 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
6. 使用同一组散列函数的布隆过滤器可以进行交、并、差运算1.2.8 布隆过滤器缺陷
1. 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
2. 不能获取元素本身
3. 一般情况下不能从布隆过滤器中删除元素
4. 如果采用计数方式删除,可能会存在计数回绕问题1.2.9实际操作中针对布隆过滤器的改良:
实际中一般不会只用布隆过滤器就判断是否存在的问题,这样会非常影响客户的体验,布隆过滤器用来提高工作效率是个非常不错的选择
1.2.10 布隆过滤器的应用场景
1. 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?
2.海量数据处理
哈希切割:当一个文件非常大需要我们处理时,我们可以将一个大问题利用哈希算法进行切割,从而使庞大的问题变成了计算机容易处理的子问题。下面引出一个问题来看看哈希切割的细节
1.给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址? 与上题条件相同,如何找到top K的IP?