一、哈希桶的改进
1.链表与树结构的结合
有时候,在极端场景下,我们的哈希桶会出现某一个桶太长了,而其他的桶却没有结点,即如下图所示
在这种情况下,我们有没有什么办法可以进行优化呢?其实是有的,当某个桶太长的时候,我们可以将这个链表转化为一颗红黑树进行存储,这样的话就会极大的优化效率
那么像这种结构我们该如何定义呢?
如下所示,我们的哈希表每一个结点存储的是结构体,这个结构体有两个变量一个是联合体类型,一个是判断当前是树结构还是链表结构,这个联合体是由两个指针构成,这样可以更好的节约空间。
当链表需要转化为树的时候,只需要将链表结点依次插入一个树中即可,就可以释放掉链表了,最后将树挂上去。
union Type { HashNode* head; TreeNode* root; } struct HashDate { Type ptr; bool isTree = false; //方式一:用布尔值判断是树结构还是链表结构 //或使用下面的方式 size_t bucketSize;//方式二:用结点的长度来判断是树结构还是链表结构,比如说如果长度超过8就转化为树结构,小于则退化为链表 } vector<HashDate> _table;
2.扩容使用质数
对于这一点,其实现在并没有充足的科学依据,但是确实有人提出过这一点
那就是哈希桶的数量使用质数的话会减少冲突
但是在vs2022中并没有使用质数
如下是linux,即g++下面的,可以看到使用了质数
那么在g++中是如何实现的使用素数扩容的呢?其实是直接使用了如下所示的素数表,然后去扩容的
size_t GetNextPrime(size_t prime) { static const int __stl_num_primes = 28; static const unsigned long __stl_prime_list[__stl_num_primes] = { 53, 97, 193, 389, 769, 1543, 3079, 6151, 12289, 24593, 49157, 98317, 196613, 393241, 786433, 1572869, 3145739, 6291469, 12582917, 25165843, 50331653, 100663319, 201326611, 402653189, 805306457, 1610612741, 3221225473, 4294967291 }; size_t i = 0; for (; i < PRIMECOUNT; ++i) { if (primeList[i] > prime) return primeList[i]; } return primeList[i]; }
然后我们将这两处进行修改即可
二、位图
1.位图的概念
我们先来看这样一道题
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。【腾讯】
这是腾讯的一道面试题
我们能想到的思路有哪些呢?
- 直接暴力遍历,但是时间复杂度是O(N)
- 排序+二分,时间复杂度是logN
- 使用set,时间复杂度是logN
如上是我们最容易想到的办法,但是这些办法合理吗?可能实现吗?
其实肯定是不可以的,因为这数据量太大了,40亿的数据,相当于160亿字节
而我们知道10亿字节约为1G内存,那么这要用16G内存啊。我们一般的电脑根本跑不动的,更何况还有操作系统还要用分内存给其他软件呢。
使用set系列就更不可能了,因为就单论红黑树,一个结点,就额外需要三个指针,还有一个用来判断颜色的变量。需要消耗的内存一下子变为了80G,这几乎没有几个电脑带的动的,成本太高了
那么我们究竟该如何解决呢?
其实我们陷入了一个思维误区,误以为必须得把这40亿数据存起来才可以,其实我们可以不用存起来的。因为我们只需要判断这个数在不在就可以了。
而标记一个数在不在我们只需要一个比特位就可以搞定了。
那么如何可以只使用一个比特位呢?那我们就是使用哈希了。
我们可以直接开2的32次方个比特位的空间,每一个比特位我们都可以像数组一样给他们一个下标,这个下标就是代表了这个数,这个下标对应的比特位如果是0就代表这个数不在,如果是1就代表在就可以了。
那么为什么必须是2的32次方呢?因为题目要求的是无符号整数,它的范围最大刚好就是2的32次方
这就相当于直接定址法,这样的话我们只需要500MB就可以解决问题了。
2.位图的实现
由于没有一个数据类型只占一个比特位,所以我们只能使用其他的来模拟一个。比如下图就是使用int类型来进行模拟的,一个int代表着32个比特位
不过上面的图其实还存在一些问题,因为对应一个变量而言,而的最右边的位才是第0位,所以上图我们应该在做一些修改
这个就像在内存中的小端机器一样,也是类似于这样的存储方式,如下是当存储一个1的时候,内存就是如下的形式
根据上面的思路,位图应该是这样的框架
然后,我们先完善第一个功能置位,即将某个位置为1
如下所示,这里需要注意的是,小端机器只是机器底层内存的样子,我们不需要关注底层内存的情况,我们只需要关系对于int类型找到它表面上的第j个位就可以了。至于计算机内存底层是如何实现的,那不是我们要关心的事情
void set(size_t x) { int i = x / 32; int j = x % 32; _a[i] |= (1 << j); }
然后就是将某一位置为0了,使用位运算就可以轻松解决了
void reset(size_t x) { int i = x / 32; int j = x % 32; _a[i] &= ~(1 << j); }
如下是测试某一位是0还是1
bool test(size_t x) { int i = x / 32; int j = x % 32; return _a[i] & (1 << j); }
最后我们加上非类型模板参数,最终的位图是这样的
namespace Sim { template<size_t N> class bitset { public: bitset() { _a.resize(N / 32 + 1); } void set(size_t x) { int i = x / 32; int j = x % 32; _a[i] |= (1 << j); } void reset(size_t x) { int i = x / 32; int j = x % 32; _a[i] &= ~(1 << j); } bool test(size_t x) { int i = x / 32; int j = x % 32; return _a[i] & (1 << j); } private: vector<int> _a; }; }
使用如下测试用例
void test1() { Sim::bitset<1000> bs; bs.set(1); bs.set(500); bs.set(1000); cout << bs.test(1) << endl; cout << bs.test(500) << endl; cout << bs.test(1000) << endl; cout << bs.test(2) << endl; cout << endl; bs.set(2); bs.reset(1); cout << bs.test(1) << endl; cout << bs.test(500) << endl; cout << bs.test(1000) << endl; cout << bs.test(2) << endl; }
现在有了位图,那么我们现在回过头来看一下这道题
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。【腾讯】
现在我们可以使用位图来解决这个问题了,使用位图仅仅只需要500M,注意下面代码中,由于位图开的是范围,所以我们需要开到无符号整数的最大值,我们可以使用很多种方法找到,比如UINT32_MAX就代表无符号整数最大值,-1也是可以的,因为内存中代表全1,也可以使用16进制数都是很方便的找到无符号最大值。但是要注意千万不可以直接使用INT_MAX直接乘以2,这是错误的,还需要+1的,比如对于char,有符号最大值是127,无符号最大值是255。还需要注意的是如果是使用-1的话,一定要将系统设置为32位的。因为size_t在32位和64位是不一样的
我们只需要用这500MB的内存就可以将海量数据依次放入位图中,然后我们就可以很方便的进行检验了
实际上在库里面也有位图
它的操作最常用的就是下面的这些,其实主要还是我们实现的那三个,[]运算符重载使用的并不是很多
3.位图的其他应用
- 给定100亿个整数,设计算法找到只出现一次的整数?
对于这道题,我们的想法还是使用位图,但是可以直接使用位图吗,好像不太行,我们似乎需要将我们原来的位图改造一下。因为只出现一次这句话,就代表了我们至少需要两个比特位来存储信息,00代表没有,01代表只存储一次,10代表存储一次以上
这样的话,我们一开将位图的数据全部设置为0,当遇到一个数据,对应的位置改为01,如果又遇到重复的数据,改为10。如果还遇到,那就不变就可以了。这样的话就完美的解决了这个场景。
但是上面的问题有一点很不爽,如果我们不想手写类似于位图容器的话,那该如何处理呢?毕竟库里面的位图就只用一个比特位。我们想用库里面的位图该如何使用呢?
我们可以使用两个位图容器去搞定,一个位图对应的只存储一个位置即可,将这两个位图给封装为一个新的容器就可以了
template<size_t N> class twobitset { public: void set(size_t x) { if (!_bs1.test(x) && !_bs2.test(x)) { _bs2.set(x); } else if (!_bs1.test(x) && _bs2.test(x)) { _bs1.set(x); _bs2.reset(x); } } bool is_once(size_t x) { return !_bs1.test(x) && _bs2.test(x); } private: bitset<N> _bs1; bitset<N> _bs2; };
然后我们使用如下测试用例
void test3() { int a1[] = { 1,2,3,3,4,4,4,4,4,2,3,6,3,1,5,5,8,9 }; Sim::twobitset<10> tbs; for (auto e : a1) { tbs.set(e); } for (auto e : a1) { if (tbs.is_once(e)) { cout << e << " "; } } cout << endl; }
这个位图的方法对于寻找单身狗的题目也会有奇效
- 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
对于这个题,我们一开始的想法可能就是,先将一个文件里面的数据使用一个位图中,然后用另外一个文件进行比较判断在不在。不过这个的问题就在于,会出现重复的数据。所以我们需要去重,那我们如果使用set去去重的话,那如何不重复的数据量太多。显然内存不够用。
所以我们的办法是使用两个位图,用两个文件分别映射到两个位图中,然后与一下。还是1的位置就是交集了
比如下面的代码就可以求出交集
void test4() { int a1[] = { 1,2,3,3,4,4,4,4,4,2,3,6,3,1,5,5,8,9 }; int a2[] = { 8,4,8,4,1,1,1,1 }; Sim::bitset<10> bs1; Sim::bitset<10> bs2; for (auto e : a1) { bs1.set(e); } for (auto e : a2) { bs2.set(e); } for (int i = 0; i < 10; i++) { if (bs1.test(i) && bs2.test(i)) { cout << i << " "; } } cout << endl; }
- 位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
这道题其实和问题一十分类似,可以使用两个位图去解决
00代表没有数据,01代表只出现一次,10代表出现了2次,11代表出现了两次以上
不过我们可能会在意的是int可能是负数,其实没关系,因为我们都会将他变为size_t的,最后打印的时候在强转会int就可以了
三、布隆过滤器
1.布隆过滤器的提出
上面的数据都是针对于整型的
那么当我们的数据为字符串类型呢?我们能否进行映射呢?其实也是可以的,只需要使用字符串转整型算法即可
这样的话,未来想判断这个字符串在不在就可以直接观测位图的这个位置来判断在不在
但是上面还是存在一些问题的,那就是可能有其他字符串映射的值和它一样
这个时候就存在误判了
也就是说,可能存在冲突导致误判,如果一个字符串在的话,那么它有可能是误判放进来的,但是如果一个不在的话,那么一定是精确的
那么这时候我们会发现,这个误判似乎无法消除,但是我们可以去降低这个误判率
而布隆所提出的方法就是,进行多个映射
这样的话只要有一个没有映射上去,那么就说明这个值不存在,只有对应的几个位都为1,才能说这个字符串存在,当然这也有可能会误判,但是这样使用多个映射以后,误判率降低了。只要把位图开的大一点,控制一个合理的类似于负载因子的东西,就可以极大的降低误判率。但是这个切记不可以太密集了,所以要求位图的范围要大
像上面的这种东西,我们也称为布隆过滤器
对于布隆过滤器,它的使用场景很多,比如说下面的场景
首先就是对于不需要特别精确的场景,比如说快速判断一个昵称是否被人注册过。这个时候,我们可以将数据库的全部数据放入一个布隆过滤器。我们控制好误判率当有人输入了一个昵称,虽然这个昵称其实没有被注册过,但是我们提示这个昵称注册过了,这个其实是没有任何问题的当这个昵称被注册过了,那么一定会精确的提醒注册过了
即便如果必须要精确的话,我们也可以使用布隆过滤器先检查一遍,如果某个昵称在的话,直接过滤掉即可,即显示该用户已被注册。如果不在的话,那么我们在去数据库检索一遍,返回数据库的数据。
这样的话就可以极大的降低数据库查询负载压力,从而提高效率
2.布隆过滤器的实现
如下所示是一个简单的布隆过滤器的实现,对于这个布隆过滤器,我们只考虑置位和检测这两个函数就足够了。因为如果使用复位的话可能会影响其他位。如果非要强制支持复位,那么就需要计数了,也就是说每个位还需要一个计数器,删除一个数据,就代表着这个字符串所映射的三个位的计数器都减一就可以了。而这个计数器我们一般而言最少也得需要一个char变量,这就需要消耗八个比特位了,因为我们一般都是几十亿个数据时候才使用布隆过滤器的。为了支持一个删除要多消耗八倍的空间,属实划不来。
我们本来就是为了节省空间才使用的位图,而这个删除却要额外消耗八倍的空间,这就违背了我们一开始的原则
#pragma once namespace Sim { struct BKDRHash { size_t operator()(const string& s) { size_t hash = 0; for (auto ch : s) { hash = hash * 131 + ch; } return hash; } }; struct APHash { size_t operator()(const string& s) { size_t hash = 0; for (size_t i = 0; i < s.size(); i++) { char ch = s[i]; if ((i & 1) == 0) { hash ^= ((hash << 7) ^ ch ^ (hash >> 3)); } else { hash ^= (~((hash << 11) ^ ch ^ (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 N, class K = string, class Hash1 = BKDRHash, class Hash2 = APHash, class Hash3 = DJBHash> class BloomFilter { public: void set(const K& key) { size_t hash1 = Hash1()(key) % N; _bs.set(hash1); size_t hash2 = Hash2()(key) % N; _bs.set(hash2); size_t hash3 = Hash3()(key) % N; _bs.set(hash3); } bool test(const K& key) { size_t hash1 = Hash1()(key) % N; if (!_bs.test(hash1)) { return false; } size_t hash2 = Hash2()(key) % N; if (!_bs.test(hash2)) { return false; } size_t hash3 = Hash3()(key) % N; if (!_bs.test(hash3)) { return false; } return true; } private: std::bitset<N> _bs; }; };
我们使用如下代码来进行测试
void test5() { Sim::BloomFilter<1000> bf; bf.set("孙悟空"); bf.set("猪八戒"); cout << bf.test("孙悟空") << endl; cout << bf.test("猪八戒") << endl; cout << bf.test("沙悟净") << endl; }
我们可以进一步检测一下每个哈希函数算出位置
只需要在set函数中添加打印下标即可
可见此时还没有出现冲突,当如果长度为10的时候,可见容器产生冲突,但是还好,因为有三个位可以作为判断依据,一个位冲突还有其他位来帮忙检测
对于已经存在的,一定会精确的判断它存在,但是对于不存在的,有可能会产生误判,比如当宽度为5的时候,沙悟净产生了误判
在这里,哈希函数的个数也需要权衡,个数越多则布隆过滤器 bit 位置位 1 的速度越快,且布隆过滤器的效率越低;但是如果太少的话,那我们的误报率会变高。也有人计算出出了了哈希函数的个数和数组的长度与误判率的关系的图片
k 为哈希函数个数,m 为布隆过滤器长度,n 为插入的元素个数,p 为误报率
如下就是其他人计算出来的k和m最适合的值
相关文章链接如下:布隆过滤器
我们可以用下面这段代码来测试误判率
void test6() { srand(time(0)); const size_t N = 100000; Sim::BloomFilter<N * 5> bf; std::vector<std::string> v1; std::string url = "https://blog.csdn.net/jhdhdhehej?spm=1010.2135.3001.5343"; for (size_t i = 0; i < N; ++i) { v1.push_back(url + std::to_string(i)); } for (auto& str : v1) { bf.set(str); } // v2跟v1是相似字符串集(前缀一样),但是不一样 std::vector<std::string> v2; for (size_t i = 0; i < N; ++i) { std::string urlstr = url; urlstr += std::to_string(9999999 + i); v2.push_back(urlstr); } 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) { string url = "zhihu.com"; url += std::to_string(i + rand()); v3.push_back(url); } size_t n3 = 0; for (auto& str : v3) { if (bf.test(str)) { ++n3; } } cout << "不相似字符串误判率:" << (double)n3 / (double)N << endl; }
测试结果如下所示
当然我们可以控制M的大小来使得误判率降低
当M为10倍的N的时候,误判率进一步下降
3.布隆过滤器的应用
- 给两个文件,分别有100亿个query(查询),我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法
首先对于近似算法就很简单,直接使用布隆过滤器,把其中一个文件放入布隆过滤器中,另外一个判断在不在。在就是交集,不在就不是交集。不过会存在误判
然后是对于精确算法,这就比较麻烦了,我们需要使用一个哈希切分
我们假设一共query是30byte,那么100亿query就是3000亿byte,相当于300G内存
所谓的哈希切分就是:将A和B分别切成很多个小文件,用哈希函数去计算出对应的下标,然后将该数据放入对应的小文件。
用图来描述就是这样的
最终他们就会被切分为如下所示,然后我们直接去找交集即可,因为这里我们会发现,如果是相同的数据一定会落在下标相同的小文件中。而且在切分的时候这里的内存消耗几乎没有,因为切分的策略是将大文件的数据一个一个的读入内存然后写入新的小文件中。
找交集的时候,Ai读出全部读出来放入到一个set中,然后依次读取Bi中的query,判断在不在,如果在就说明是交集。这样就可以遭到Ai和Bi的交集了,但是平均切分是300MB,然而我们这里并不是平均切分,而是哈希切分,如果一旦冲突太多了,会导致某个Ai文件太大,超过1G内存,此时又该如何处理呢?
在这里我们分为两种情况,比如说Ai有5G
- 4G都是相同的query,1G是冲突的(那么这时候我们可以放入set,正常执行没有任何问题)
- 大多数都是冲突的 (那么这时候我们只能进行二次切分了)
我们最终的解决方案是这样的
- 先把Ai所有的query都放入set,如果set的insert报错抛异常(bad_alloc),那么说明大多数是冲突的,我们在换一个哈希函数,采用二次切分
- 如果没有报错抛异常,那么就说明大多数是相同的。按照正常流程找交集即可
- 给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?出现次数最多的K个IP地址?
我们的思路与上一道题很相似,使用哈希切分,那么相同ip一定进入了同一个小文件,用map分别统计每个小文件中出现ip次数即可。然后我们可以使用一个堆,类似于TOP-K问题,每个小文件结束以后将前K个放入其中即可。
以及像前面的这个题目,同样可以使用哈希切分来解决,只不过使用哈希切分的话有点麻烦, 不如使用位图来的方便
给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?