目录
位图(STL-BitSet)
位图的应用场景1
位图BitSet也是一种通过哈希的思想所实现的容器,位图的应用场景为:判断某个数据是否在海量数据中存在(注意必须是海量数据,否则使用位图就没有太大意义,位图对比其他容器就失去了优势)。
1. 面试题:有40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。【腾讯】
常见的思路是:把一个个数据存进哈希表或者红黑树,然后在哈希表或者红黑树中找目标数据,如果是哈希表,则查找一个数据的时间复杂度是O(1),如果是红黑树,则在40亿个数据中查找一个数据只需要32次,查找的效率都是很不错的。如果找到了,则该数据在这40亿个数中,反之则不在。
但这种思路就有一个问题,内存存不下40亿个无符号整形数据(单个4字节)。因为2*10=1024,1024*1024*1024 ≈ 2^30(字节)≈ 10亿(字节)=1G,所以40亿个无符号整形数据所占的内存约等于16G,光存40亿个数就需要16G了,而且因为红黑树中每个节点还要颜色color成员、left和right指针成员;哈希表中每个节点还需要next指针,所以不管用哈希表还是红黑树来存40亿个数,最终所需内存还需要在16G的基础上翻几倍,这肯定是严重超过了正常电脑的最大内存的,因此该思路是行不通的。
正确的思路:通过位图解决,请继续往下看。
上面给出的问题为判断目标数据是否在给定的40亿个整形数据中,因为结果只能是在或者不在,也就刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,比如二进制比特位为1时则代表存在,为0时代表不存在。如下图就是一个位图,共开了24个位,也就是开了3个字节,只需要3字节就可以表示数字0到数字23中哪些数字存在,哪些数字不存在。
从逻辑角度上位图如何开空间呢?
问题:结合上文,现在要存40亿个无符号整形数字,那么位图需要开多少个位呢?
答案:说一下,即使是在10个无符号整形数字中找1个数字,如果我们不知道10个数字中的最大值,则也要开42亿9千万个位,因为位图是运用了哈希的思想,虽然只有10个数字,但每个数字的值可能都超过了10,比如数字为10亿,此时数据就需要映射到第10亿个位上,让第10亿个位上的值变成1。从这里就能获得一个启示,因为位图的底层思想是哈希映射,所以当不知道位图需要存储的若干数据中的实际最大值时,则位图要开多少个位是由位图需要存储的数据类型的理论最大值决定的。在本例中位图要存40亿个无符号整形数字,因为不知道40亿个数字中的实际最大值是多少,所以位图要开多少个位是由位图需要存储的数据类型的理论最大值决定,此时需要存储的数据的类型是无符号整形,该类型的值的范围是0到42亿9千万,理论最大值是42亿9千万,因此位图需要开42亿9千万个位,也就是开512MB的内存。(说一下,当场景变成在10个无符号整形数字中找1个数字,并且我们还知道10个数字中的最大值,比如最大值是111,此时是不需要开42亿9千万个位的,只需要开111个位即可)同理,如果有100亿个无符号整形数字,如果不知道100亿个数字中的实际最大值是多少,位图也要开42亿9千万个比特位,为什么不开100亿个位呢?因为无符号整形数字理论上最大只能是42亿9千万,不可能有整数需要映射到位图的位于第43亿后的比特位上,这100亿个整数一定是有重复值的。
最终结论:结合上一段的理论,位图要存40亿个无符号整形数字需要开42亿9千万个位,也就是开512MB的内存,正常电脑的内存都是非常够的,然后因为位图是哈希结构,查找的时间复杂度为O(1),查找的效率非常高,所以通过位图的思路就很好的解决了上文中的面试题。
从代码角度上位图如何开空间呢?
问题:结合上文我们知道了通过位图这种容器在40亿个不重复的无符号整数中找一个整数需要位图开42亿9千万个位,但C++是不支持按位来开空间的,现在该怎么办呢?
答案:在代码角度上,咱们可以开以char或者int为单位的vector数组。拿char来说,一个char是8个位,如果位图需要开300个位,则位图vector<char>v通过v.resize( 300/8 +1 )即可,这表示resize开辟了38个char的空间,而38个char所包含的位是多于300个的,符合要求(说一下,如果300/8能整除,则正常来说是可以不用+1的,但+1也无所谓,因为也就浪费了1字节,影响不大)。
位图的模拟实现
基础框架
基础框架如下。(注意下面默认构造的代码是结合上面位图如何开空间的理论实现的)
#pragma once
#include<iostream>
#include<vector>
using namespace std;
namespace mine
{
//N是非类型模板参数
template<size_t N>
class bit_set
{
public:
bit_set()
{
_v.resize(N / 8 + 1, 0);//注意必须初始化成0
}
private:
vector<char>_v;
};
}
set函数
用于插入目标数据,即将数据所映射的比特位设置成1。这样之后在查找该数据时,发现该数据对应的比特位上的值为1则表示该数据在位图中存在。
思路很简单,比如要插入的数据为20,20/8=2,则说明数据20需要映射到vector中下标为2的元素中,也就是第3个元素中(注意每个元素也就是一个char);然后20%8=4,则说明数据20需要映射到该char的 “下标” 为4的比特位上,也就是映射到该char的第5个比特位上,找到该比特位后,将该比特位上的值变成1即可完成插入。(注意在单个字节中,每个位之间是没有高地址和低地址之分的,因为计算机地址的最小单位就是字节,每个位是没有被编址的;但不管是在字节与字节之间,还是在单个字节中,都是有高位和低位一说的,一般来说我们在思考时,默认都认为高位在左边,低位在右边(当然你也可以反过来思考,这不重要),重要的是一定是把从低位到高位的第一个位称为第一个比特位,因为当前默认是高位在左边,低位在右边,所以第5个比特位就是从右往左数的第5个位。
注意高位这个概念和高地址这个概念没有任何关系,低位和低地址也同理。
单个字节的高位的方向和多个字节的高位的方向一定是一致的,比如在单个字节中,如果靠左边的比特位是高位,那么在多个字节中,靠左边的字节肯定也是高位字节;反之如果在单个字节中,如果靠右边的比特位是高位,那么在多个字节中,靠右边的字节肯定也是高位字节)
思路确定后,就要思考从代码层面上如何执行思路。比如根据上面的思路可知在位图中插入数据20就是要将vector中的第3个char的第5个比特位上的值变成1,则从代码角度上也就是让第3个char去和【整形1经过左移(5-1)位后的值】进行或运算即可,流程图如下所示。编写代码时不要忘了是如何得到结论【将第3个char的第5个比特位上的值变成1】的,3是通过(20/8)+1得到,5是通过(20%8)+1得到。
有人可能会说【干嘛要进行或运算,直接将vector中的第3个char的第5个比特位上的值变成1不就行了嘛?】这里我想说的是:单个比特位不像一个变量,是无法直接被修改值的。
set函数的代码如下。注意set和reset函数中需要改变vector中char的值,因此都是与等(&=)。
#pragma once
#include<iostream>
#include<vector>
using namespace std;
namespace mine
{
//N是非类型模板参数
template<size_t N>
class bit_set
{
public:
bit_set()
{
_v.resize(N / 8 + 1, 0);//注意必须初始化成0
}
void set(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_v[i] |= (1 << j);
}
private:
vector<char>_v;
};
}
说一下,set插入重复值是不会让位图发生任意变化的。为什么呢?举个例子,比如调用set函数第二次及以上次插入整数2时,2映射的比特位x上的值一定已经是1了,按照set函数的逻辑会让这个值为1的比特位x再去和值为1的比特位相或,或运算完毕后,2映射的比特位上的值一定依然是1(因为1|1=1),也就是说在重复插入2前和重复插入2后,位图中的数据没有发生任何变化,所以调用set函数插入重复值是不会让位图发生任意变化的,所以bitset位图还天然支持去重。
reset函数
用于删除目标数据,即将数据所映射的比特位设置成0。这样之后在查找该数据时,发现该数据对应的比特位上的值为0则表示该数据在位图中不存在。
思路很简单,比如要删除的数据为20,20/8=2,则说明要删除的数据20是映射到了vector中下标为2的元素中,也就是第3个元素中(注意每个元素也就是一个char);然后20%8=4,则说明要删除的数据20是映射到了该char的 “下标” 为4的比特位上,也就是映射到该char的第5个比特位上。找到该比特位后,将该比特位上的值变成0即可完成删除。(注意在单个字节中,每个位之间是没有高地址和低地址之分的,因为计算机地址的最小单位就是字节,每个位是没有被编址的;但不管是在字节与字节之间,还是在单个字节中,都是有高位和低位一说的,一般来说我们在思考时,默认都认为高位在左边,低位在右边(当然你也可以反过来思考,这不重要),重要的是一定是把从低位到高位的第一个位称为第一个比特位,因为当前默认是高位在左边,低位在右边,所以第5个比特位就是从右往左数的第5个位。
注意高位这个概念和高地址这个概念没有任何关系,低位和低地址也同理。
单个字节的高位的方向和多个字节的高位的方向一定是一致的,比如在单个字节中,如果靠左边的比特位是高位,那么在多个字节中,靠左边的字节肯定也是高位字节;反之如果在单个字节中,如果靠右边的比特位是高位,那么在多个字节中,靠右边的字节肯定也是高位字节)
思路确定后,就要思考从代码层面上如何执行思路。比如根据上面的思路可知在位图中删除数据20就是要将vector中的第3个char的第5个比特位上的值变成0,则从代码角度上也就是让第3个char去和【整形1经过左移(5-1)位、再按位取反后得到的值】进行与运算即可,流程图如下图所示。编写代码时不要忘了是如何得到结论【将第3个char的第5个比特位上的值变成0】的,3是通过(20/8)+1得到,5是通过(20%8)+1得到。
有人可能会说【干嘛要进行与运算,直接将vector中的第3个char的第5个比特位上的值变成0不就行了嘛?】这里我想说的是:单个比特位不像一个变量,是无法直接被修改值的。
reset函数的代码如下。注意set和reset函数中需要改变vector中char的值,因此都是与等(&=)。
#pragma once
#include<iostream>
#include<vector>
using namespace std;
namespace mine
{
//N是非类型模板参数
template<size_t N>
class bit_set
{
public:
bit_set()
{
_v.resize(N / 8 + 1, 0);//注意必须初始化成0
}
void reset(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_v[i] &= ~(1 << j);
}
private:
vector<char>_v;
};
}
说一下,reset删除重复值是不会让位图发生任意变化的。为什么呢?举个例子,比如调用reset函数第二次及以上次删除整数2时,2映射的比特位x上的值一定已经是0了,按照reset函数的逻辑会让这个值为0的比特位x再去和值为0的比特位相与,与运算完毕后,2映射的比特位上的值一定依然是0(因为0|0=0),也就是说在重复删除2前和重复删除2后,位图中的数据没有发生任何变化,所以调用reset函数删除重复值是不会让位图发生任意变化的。
test函数
说一下,STL中的bitset容器真有一个test成员函数,用于检测(或者说是查找)某个数据是否在位图中存在。
思路很简单,比如要查找(或者说检测)的数据为20,20/8=2,则说明要查找的数据20是映射到了vector中下标为2的元素中,也就是第3个元素中(注意每个元素也就是一个char);然后20%8=4,则说明要查找的数据20是映射到了该char的 “下标” 为4的比特位上,也就是映射到该char的第5个比特位上。找到该比特位后,如果发现比特位上的值为1,则找到了(即检测成功)并返回true;如果为0,则没找到(即检测失败)并返回false。(注意在单个字节中,每个位之间是没有高地址和低地址之分的,因为计算机地址的最小单位就是字节,每个位是没有被编址的;但不管是在字节与字节之间,还是在单个字节中,都是有高位和低位一说的,一般来说我们在思考时,默认都认为高位在左边,低位在右边(当然你也可以反过来思考,这不重要),重要的是一定是把从低位到高位的第一个位称为第一个比特位,因为当前默认是高位在左边,低位在右边,所以第5个比特位就是从右往左数的第5个位。
注意高位这个概念和高地址这个概念没有任何关系,低位和低地址也同理。
单个字节的高位的方向和多个字节的高位的方向一定是一致的,比如在单个字节中,如果靠左边的比特位是高位,那么在多个字节中,靠左边的字节肯定也是高位字节;反之如果在单个字节中,如果靠右边的比特位是高位,那么在多个字节中,靠右边的字节肯定也是高位字节)
思路确定后,就要思考从代码层面上如何执行思路。比如根据上面的思路可知在位图中查找数据20就是根据vector中的第3个char的第5个比特位上的值是1还是0来判断查找是否成功并返回true或者false,则从代码角度上也就是让第3个char去和【整形1经过左移(5-1)位后的值】进行与运算即可,如果计算结果不是0,则return true表示查找(检测成功);反之则return false表示查找失败。流程图如下图所示。编写代码时不要忘了是如何得到结论【根据vector中的第3个char的第5个比特位上的值是1还是0来判断查找是否成功】的,3是通过(20/8)+1得到,5是通过(20%8)+1得到。
有人可能会说【干嘛要进行与运算,直接查看vector中的第3个char的第5个比特位上的值是1还是0不就行了嘛?】这里我想说的是:单个比特位不像一个变量,是无法直接被拿到并观测的。
test函数的代码如下。注意test函数中不改变vector中char的值,因此只是相与(&),而set和reset函数中需要改变vector中char的值,因此都是与等(&=)。
#pragma once
#include<iostream>
#include<vector>
using namespace std;
namespace mine
{
//N是非类型模板参数
template<size_t N>
class bit_set
{
public:
bit_set()
{
_v.resize(N / 8 + 1, 0);//注意必须初始化成0
}
bool test(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
return _v[i] & (1 << j);
}
private:
vector<char>_v;
};
}
位图的整体代码
文件BitSet.h的代码如下。
#pragma once
#include<iostream>
#include<vector>
using namespace std;
namespace mine
{
//N是非类型模板参数
template<size_t N>
class bit_set
{
public:
bit_set()
{
_v.resize(N / 8 + 1, 0);//注意必须初始化成0
}
void set(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_v[i] |= (1 << j);
}
void reset(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_v[i] &= ~(1 << j);
}
bool test(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
return _v[i] & (1 << j);
}
private:
vector<char>_v;
};
}
最终测试
可以在调试中观察每个char中的值,下图中的char的值都是16进制的数字,比如0x38、0x10等。
上图的测试代码如下。
#include<iostream>
using namespace std;
#include"BitSet.h"
void main()
{
mine::bit_set<100>bs;
bs.set(11);
bs.set(12);
bs.set(13);
bs.set(20);
cout << bs.test(11) << endl;
cout << bs.test(12) << endl;
cout << bs.test(13) << endl;
cout << bs.test(20) << endl;
bs.reset(11);
bs.reset(12);
bs.reset(13);
bs.reset(20);
cout << bs.test(11) << endl;
cout << bs.test(12) << endl;
cout << bs.test(13) << endl;
cout << bs.test(20) << endl;
}
位图的其他应用场景
应用场景2
应用场景2:有100亿个无符号整数,设计算法找到只出现一次的所有整数。
(如果这里是应用场景2或者3或者4、因为上文场景1和位图的模拟实现部分中包含一些知识点,所以建议先阅读完这些前置内容,否则往下阅读会很吃力)
在上文的应用场景1中,我们只需要判断一个数据是否在海量数据中存在,那么就只有“在”和“不在”两种状态需要表示,我们可以用一个比特位1和0来表示这两种状态。现在要在100亿个整数中找到只出现一次的整数(整数最多只有42亿9千万个,所以势必会有重复值),容易想到的思路就是去统计每个整数出现的次数,这就变成了KV模型,但这里不能使用pair这种数据结构,因为pair不管作为哈希表还是红黑树又或者是其他容器类的成员对象,既然想要pair统计每个整数出现的次数,那么势必要生成42亿9千万个pair<int,int>,内存存不下这么多个pair(毕竟上文中说过光存40亿个数就需要16G了),所以本种思路不可行。
正确的思路:既然只需要找到只出现一次的整数,那么最少只需要表示3种状态,为【只出现1次,没有出现过,出现过2次及以上】,咱们可以依然使用位图,用两个比特位来表示这3种状态,比如01表示只出现1次,00表示没有出现过,10表示出现过2次及以上,如下图1的vector<char>v2,咱们可以让一个char(共8个比特位)只映射4个数据,即每两个比特位映射1个数据,比如char1只映射数据0到3,char2只映射数据4到7等等。当然【用2个比特位映射1个数据的方式】去映射42亿9千万个数据所花的内存就会是【用1个比特位映射1个数据的方式】去映射42亿9千万个数据所花的内存的2倍(因为此时的位图需要42亿9千万*2个位,即位图的非类型模板参数N的值是42亿9千万*2),在上文中说过,【用1个比特位映射1个数据的方式】去映射42亿9千万个数据所花的内存是512mb,那么2倍也就是1G,小意思而已。
说一下,上一段的思路是没问题的,但如果拿这样的思路去解决应用场景2的问题的话,会需要把上文中模拟实现的容器位图bitset的代码作修改,因为上文模拟实现的bitset是用1个比特位映射1个数据,而现在的思路是用2个比特位映射1个数据,所以就需要在set、reset、test函数中把用于计算目标数据应在第几个char的第几个位置的代码从x/8和x%8变成x/4和x%4,除此之外还有一些逻辑需要修改,比较麻烦。那有没有什么办法可以在不修改上文中模拟实现的容器位图bitset的代码的情况下,还能用上一段的思路来解决应用场景2的问题呢?答案是有的,如下图2所示,在不修改上文中模拟下实现的bitset的代码的情况下,即位图是用1个比特位映射1个数据的情况下,咱们可以选择用两个位图来表示01、00、10,比如数据0应映射到位图的第一个char的第一个位置,那么当第一个位图的第一个char的第一个位置是0,第二个位图的第一个char的第一个位置是1,那么将两个位图的结果组合就表示01,则就可以表示数据0只出现了一次。
图1如下。
图2如下。
结合上面的思路,咱们可以编写一个可以解决应用场景2的临时容器类,假设该类叫two_bit_set,代码如下。
two_bit_set的set成员函数用于插入数据,第一次插入整数1会让two_bit_set的两个成员对象bitset映射整数1的对应位置的组合从00变成01,01表示整数1只出现了1次;第二次插入整数1则会从01变成10,10表示整数1出现了2次及以上;后序再插入整数1则什么也不会发生。
two_bit_set的reset成员函数用于删除数据,不再赘述。
two_bit_set的print_once_num成员函数用于打印只出现了一次的整数,逻辑也很简单,就是for循环让size_t类的 i 从整数0遍历到整数N(N是two_bit_set类和bit_set类的非类型模板参数,表示位图的比特位个数,比如在应用场景2中因为不知道100亿个整数中的实际最大值,所以根据上文的说法,N只能为整数的理论最大值,也就是42亿9千万了),如果发现two_bit_set的两个成员对象bitset映射整数 i 的对应位置的组合是01,说明整数 i 只出现了1次,则打印 i 即可。
#pragma once
#include<iostream>
#include<vector>
using namespace std;
namespace mine
{
//N是非类型模板参数
template<size_t N>
class bit_set
{
public:
bit_set()
{
_v.resize(N / 8 + 1, 0);//注意必须初始化成0
}
void set(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_v[i] |= (1 << j);
}
void reset(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_v[i] &= ~(1 << j);
}
bool test(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
return _v[i] & (1 << j);
}
private:
vector<char>_v;
};
//N是非类型模板参数
template<size_t N>
class two_bit_set
{
public:
void set(size_t x)
{
//如果组合是00,则变成01
if (_bs1.test(x) == false && _bs2.test(x) == false)
{
_bs2.set(x);
}
//如果组合是01,则变成10
else if (_bs1.test(x) == false && _bs2.test(x) == true)
{
_bs1.set(x);
_bs2.reset(x);
}
//如果组合是10,则不用处理
else
return;
}
void reset(size_t x)
{
//如果组合是00,则不用处理
if (_bs1.test(x) == false && _bs2.test(x) == false)
{
return;
}
//如果组合是01,则变成00
else if (_bs1.test(x) == false && _bs2.test(x) == true)
{
_bs2.reset(x);
}
//如果组合是10,则变成01
else
{
_bs1.reset(x);
_bs2.set(x);
}
}
//函数用于打印只出现了一次的整数
void print_once_num()
{
for (size_t i = 0; i < N; i++)
{
//如果组合是01,则说明只出现了一次,打印即可
if (_bs1.test(i) == false && _bs2.test(i) == true)
{
cout << i << ' ';
}
}
cout << endl;
}
private:
bit_set<N>_bs1;
bit_set<N>_bs2;
};
}
回看应用场景2:有100亿个整数,设计算法找到只出现一次的整数。
现在编写完two_bit_set后,剩下的思路就很简单了,咱们把100亿个整数都调用two_bit_set的set函数插入到two_bit_set容器中,然后调用two_bit_set的print_once_num函数即可找到只出现一次的整数。
测试如下,可以看到数组a中刚好只有4、5、6、7是没有重复的整数,符合咱们的预期,测试成功。说一下,因为100亿个整数需要从文件里读取,咱们这里没有这个文件,所以直接搞了一个数组替代,如果是检测100亿个整数,则一个个从文件里读,读出一个就立刻往two_bit_set中set插入一个,最后调用two_bit_set的print_once_num函数即可找到在100亿个整数中只出现一次的整数。
上图的测试代码如下。
#include<iostream>
using namespace std;
#include"BitSet.h"
void test1()
{
mine::two_bit_set<100> t;
int a[] = { 1,1,2,3,4,5,6,7,8,8,9,9,0,0,2,2,3 };
for (auto e : a)
{
t.set(e);
}
t.print_once_num();
}
void main()
{
test1();
}
应用场景3
应用场景3:给两个文件,分别有100亿个无符号整数,我们只有1G的内存,如何找到两个文件的交集。
(如果这里是应用场景2或者3或者4、因为上文场景1和位图的模拟实现部分中包含一些知识点,所以建议先阅读完这些前置内容,否则往下阅读会很吃力)
首先要知道交集是不算重复值的,比如文件A有数字1、1、2、2,文件B有数字1、1、2、3,那么两个文件的交集应该是1、2,而不是1、1、2,所以每个文件首先要自己内部去重。这个问题不必担心,我们模拟实现的位图bitset天然就支持去重,比如文件A中的数据全部set插进bitset1中、文件B中的数据全部set插进bitset2中后,bitset1中的数据就已经没有重复值了、bitset2中的数据也已经没有重复值了。为什么呢?举个例子,比如调用set函数第二次及以上次插入整数2时,2映射的比特位x上的值一定已经是1了,按照set函数的逻辑会让这个值为1的比特位x再去和值为1的比特位相或,或运算完毕后,2映射的比特位上的值一定依然是1(因为1|1=1),也就是说在重复插入2前和重复插入2后,位图中的数据没有发生任何变化,所以调用set函数插入重复值是不会有任何影响的,所以bitset位图也就天然支持去重了。
然后思路就简单了:把第一个文件中的一个个整数读出来后插入到位图1中,然后把第二个文件中的一个个整数读出来后插入到位图2中,插入完毕后,最后for循环让size_t类的 i 从0开始遍历到N(N是bitset的非类型模板参数,表示位图的比特位个数,比如在应用场景3中因为不知道100亿个整数中的实际最大值,所以根据上文的说法,N只能为整数的理论最大值,也就是42亿9千万了),如果整数 i 映射到位图1的比特位上的值和位图2的比特位上的值都是1,则说明整数 i 就是两个文件的交集。注意位图1开了42亿9千万个比特位,位图2也开了42亿9千万个比特位,一个位图是512mb,两个也就刚好是1G,符合题目要求。
测试代码如下,可以看到数组a1和a2中刚好只有整数1、2、3是交集,符合咱们的预期,测试成功。说一下,因为100亿个整数需要从文件里读取,咱们这里没有文件,所以直接搞了2个数组替代文件1和文件2,如果是检测100亿个整数,则一个个从文件里读,读出一个就立刻往bitset中set插入一个,剩下的操作都是一样的。
上图的测试代码如下。
#include<iostream>
using namespace std;
#include"BitSet.h"
void test2()
{
mine::bit_set<100> t1;
mine::bit_set<100> t2;
int a1[] = { 1,1,2,3,4 };
int a2[] = { 3,2,1,0,5 };
for (auto e : a1)
t1.set(e);
for (auto e : a2)
t2.set(e);
for (size_t i = 0; i < 100; i++)
{
if (t1.test(i) == true && t2.test(i) == true)
cout << i << ' ';
}
cout << endl;
}
void main()
{
test2();
}
应用场景4
应用场景4:一个文件中有100亿个无符号整数,我们只有1G的内存,设计算法找出出现次数不超过2的所有整数。
(如果这里是应用场景2或者3或者4、因为上文场景1和位图的模拟实现部分中包含一些知识点,所以建议先阅读完这些前置内容,否则往下阅读会很吃力)
应用场景4是应用场景2的变形题,因此应用场景4的大思路和应用场景2的大思路完全一致(这里不再赘述,如果遗忘了请回顾上文中应用场景2的部分),只不过应用场景4需要表示的状态比应用场景3多了一种,从3种(即00、01、10)变成了4种(即00、01、10、11),状态11表示该整数出现了3次及以上。在应用场景2中的两个位图的组合足以表示4种状态,但因为在场景2中不需要11这种状态,所以没有在two_bit_set类内部编写这部分逻辑的代码,但在场景4这里需要用到11这种状态,所以在应用场景4这里需要对two_bit_set类稍作修改,咱们把修改后的two_bit_set类命名为new_two_bit_set类。
哪些地方需要修改呢?(如果对修改的内容有兴趣,建议对比new_two_bit_set和two_bit_set的代码进行阅读)
1、在set函数中只需要增加【如果组合是10,则变成11】的逻辑。
2、在reset函数中只需要增加【如果组合是11,则变成10】的逻辑。
3、把print_once_num函数的名字改成print,然后把逻辑从【如果组合是01,则说明只出现了一次,打印即可】改成【如果组合不是11,则说明整数 i 出现次数小于等于2次,打印即可】
结合上面理论,new_two_bit_set类的代码如下。
#pragma once
#include<iostream>
#include<vector>
using namespace std;
namespace mine
{
//N是非类型模板参数
template<size_t N>
class bit_set
{
public:
bit_set()
{
_v.resize(N / 8 + 1, 0);//注意必须初始化成0
}
void set(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_v[i] |= (1 << j);
}
void reset(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_v[i] &= ~(1 << j);
}
bool test(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
return _v[i] & (1 << j);
}
private:
vector<char>_v;
};
//本类是为了解决文中所说的应用场景4而存在的
//N是非类型模板参数
template<size_t N>
class new_two_bit_set
{
public:
void set(size_t x)
{
//如果组合是00,则变成01
if (_bs1.test(x) == false && _bs2.test(x) == false)
{
_bs2.set(x);
}
//如果组合是01,则变成10
else if (_bs1.test(x) == false && _bs2.test(x) == true)
{
_bs1.set(x);
_bs2.reset(x);
}
//如果组合是10,则变成11
else if (_bs1.test(x) == true && _bs2.test(x) == false)
{
_bs2.set(x);
}
//如果组合是11,则不用处理
else
return;
}
void reset(size_t x)
{
//如果组合是00,则不用处理
if (_bs1.test(x) == false && _bs2.test(x) == false)
{
return;
}
//如果组合是01,则变成00
else if (_bs1.test(x) == false && _bs2.test(x) == true)
{
_bs2.reset(x);
}
//如果组合是10,则变成01
else if(_bs1.test(x) == true && _bs2.test(x) == false)
{
_bs1.reset(x);
_bs2.set(x);
}
//如果组合是11,则变成10
else
_bs2.reset(x);
}
//函数用于打印出现次数不超过2的所有整数。
void print()
{
for (size_t i = 0; i < N; i++)
{
//如果组合不是11,则说明整数 i 出现次数小于等于2次,打印即可
if (!(_bs1.test(i) == true && _bs2.test(i) == true))
{
cout << i << ' ';
}
}
cout << endl;
}
private:
bit_set<N>_bs1;
bit_set<N>_bs2;
};
}
现在编写完new_two_bit_set后,剩下的思路就很简单了,咱们把100亿个无符号整数都调用new_two_bit_set的set函数插入到new_two_bit_set容器中,然后调用new_two_bit_set的print函数即可找到出现次数不超过2的所有整数。
测试代码如下。可以看到数组a中刚好只有1、3是出现次数超过2的整数,所以从0到99中只有1、3没有被打印出来,符合咱们的预期,测试成功。说一下,因为100亿个整数需要从文件里读取,咱们这里没有这个文件,所以直接搞了一个数组替代,如果是检测100亿个整数,则一个个从文件里读,读出一个就立刻往new_two_bit_set中set插入一个,最后调用new_two_bit_set的print函数即可找到出现次数不超过2的所有整数。
位图的缺陷和优点
缺陷
位图只能处理整数,并且整数还必须是无符号整数,所以负数是处理不了的。为什么呢?因为一旦位图中能存储负数,比如有负数-2,那么-2就只能位于vector<char>的第1个char的第3个比特位中,即把2的位置给抢占了,那么当往位图中插入数据2时此时就是一个无解的情况,没有什么好的办法去插入整数2。
所以位图(bitset)通常用于:1、存储非负整数。2、表示某些元素的存在或状态。
优点
1、非常快,毕竟往位图中增删查数据都只需要x/8和x%8后经过一次位运算即可。(这一点可以根据上文中模拟实现的bitset的代码看出)
2、节省空间,毕竟一个比特位就能映射一个无符号整形数据,即间接相当于一个比特位就可以存一个无符号整形数据。
3、位图对比接下来要讲解的布隆过滤器还有一个优势:位图使用的是直接定址法,一定不存在哈希冲突,在查找某个数据在不在时也就绝对不会发生误判。
使用STL的位图时的栈溢出问题
这里说一下,STL中的位图很坑,很容易误用。
上文咱们模拟实现的位图的底层是一个vector<char>,这样即使在栈上定义一个需要海量个比特位的位图,假如为100w个比特位(那么位图就需要开100w/8=125000字节的空间,也就是vector<char>要开125000个char的空间),这100w个比特位所花的空间也是在vector类内部new出来的在堆上开辟的空间,因此不会大量占用栈上的空间,也就不容易发生栈溢出导致程序崩溃;
但注意STL中的位图的底层不是vector<char>,而是类似于一个静态数组char c【N/8】,N是位图所需的比特位总数,那么当在栈上定义一个需要海量个比特位的位图时,假如为100w个比特位,就需要在栈上开辟100w/8=125000字节的空间,栈上的内存本来就小,是不一定能开出来这么多内存的,所以使用STL的位图bitset时,如果需要定义一个需要海量个比特位的位图是很容易栈溢出导致程序崩溃的。
怎么解决这个问题呢?如果真需要定义一个需要海量个比特位的位图,那就不要在栈上开空间,而是在堆上开空间,比如把定义位图的方式从bitset<100w> bs变成bitset<100w>* p = new bitset<100w>()。
布隆过滤器
布隆过滤器的提出
我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 答案是用服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那些已经存在的记录。也就是说现在要看某内容是否在历史记录中,如果在,则该内容已经被阅读过了,则pass掉;如果该内容不在历史记录中,那就推荐它。现在的问题是如何快速查看某内容是否在历史记录中呢?
答案:1. 如果用哈希表存储用户记录,的确能快速查看某内容是否在历史记录中,比如在记录历史记录的哈希表中查找某内容,如果没找到,就推荐该内容,反之过滤掉该内容;同理也可以用红黑树存储用户记录。但这些方案有一个共同的缺点:浪费空间。2. 如果用存粹的位图存储用户记录,则根据上文中模拟实现的位图可以发现不一定能成功存储用户记录,因为位图只能处理无符号的整形,如果内容编号是字符串,就无法处理了。可以发现以上的思路都不太好,在这样的环境下,有人在位图的基础上进行改装,于是就产生了布隆过滤器,所以答案就是可以通过布隆过滤器来快速查看某内容是否在历史记录中(说一下,从这句话也能看出布隆过滤器本质上就是通过封装位图bitset这种容器实现的,即布隆过滤器的底层容器就是位图)。
布隆过滤器的误判以及相关知识点
为什么布隆过滤器会出现误判、以及出现误判时的应对方案
上一段中说有人在位图的基础上进行改装,于是就产生了布隆过滤器,那是如何在位图的基础上进行改装的呢?
既然位图只支持存储无符号整形,那当数据是字符串string类型时,我就想办法让字符串string类型转化成无符号整形(通过字符串哈希算法,注意这里不是哈希函数,哈希函数是用于计算哈希地址的,不要混淆了),再让位图去存储(或者说映射)该无符号整形即可,通过这样间接达到一个让位图存储string类型对象的效果。当然这样又会出现一个问题,因为字符串的数量从理论上是无限个,而无符号整形的数量最多只有42亿9千万个,所以一定会有不同的字符串映射到位图的同一个比特位上,即一定会发生哈希冲突,这会造成一个问题,就是在布隆过滤器中查找某内容时,我们只能确保【该内容不存在】这个结论是一定正确的,但无法确保【该内容存在】这个结论一定是正确的。这也很好理解,举个例子,如果内容A和内容B理应映射到的位图上的比特位x是同一个,那么当此时只插入了内容A、没有插入内容B,该比特位x上的值依然是1,那么当查找内容B时,发现该比特位x上的值是1,就会判定内容B在位图中存在,这就误判了,所以前面才说无法确保【某内容存在】这个结论一定是正确的。再举个新的例子,现在在位图中插入了许多内容,但查找某内容时,发现该内容理应映射到的位图上的比特位的值是0,说明位图中从未插入过该内容,因为如果该内容存在那么该内容对应的比特位都应该已经被设置为1了,所以也就能确保【该内容不存在】这个结论是一定正确的了。
综上所述,布隆过滤器存储数据后有一个问题,就是在查找某数据时,如果找到了该数据,不一定是真的找到了,有可能是误判;但如果没找到某数据,那一定是真的没找到。而根据上一段的理论,误判是一定不可避免的,因此我们在优化时,只能尽量降低误判的概率,而不能杜绝误判。如何降低误判的概率呢?
我们可以让每个数据多映射几个比特位,什么意思呢,如下图所示。
当这样每个数据都映射到3个比特位后,我们在布隆过滤器中查找数据A时,判断数据A不存在的依据就变成了:只要映射的3个比特位上有一个比特位的值为0,则数据A一定不存在(这个依据是一定准确的)。判断数据A存在的依据就变成了:只有映射的3个比特位上的值都是1,则数据A才存在(这个依据不一定准确,但大概率准确,因为假如数据A不存在时,只有其他若干数据映射的比特位的位置刚好和数据A映射的3个比特位的位置完全相同,此时才会因为其他若干数据存在而误判数据A存在,这种可能性是很低的,所以可以看到对比每个数据只映射一个比特位的情景时,在当前每个数据都映射3个比特位的情景下,发生误判的概率是大大降低的)
如何控制误判率
问题:说一下,在上面几段中咱们选择让布隆过滤器中的每个数据都映射到位图中的3个比特位上(也就是说选择的哈希函数的数量是3个)以降低(或者说控制)误判率,但在实际中控制误判率时,每个数据映射的比特位不一定是3个(也就是说选择的哈希函数的数量不一定是3个),那到底是多少个比较好呢?
答案:先直接给答案,答案的原因在下文中。如上图,k为哈希函数个数,m为布隆过滤器长度(长度即布隆过滤器的底层容器位图bitset有多少个比特位),n为插入布隆过滤器的元素个数,ln2约等于0.7。如果已知m和n,就能知道需要多少个哈希函数了。
那如何控制误判率呢?
1、很显然,长度过小的布隆过滤器很快所有的 bit 位均为 1,那么查询任何值都会返回“可能存在”,就起不到过滤的目的了。所以布隆过滤器的长度会直接影响误报率,布隆过滤器越长其误报率越小。
2、另外,哈希函数的个数也需要权衡,个数越多则布隆过滤器上的所有比特位的值被设置成1的速度越快,这会让查询任何值都会返回“可能存在”,起不到过滤的目的,误报率变高;但是如果太少的话,根据上文中的理论,那我们的误报率也会变高。
综上所述可以看出选择哈希函数的个数时,为了保持误判率处于比较低的状态,是需要依据布隆过滤器的长度的(长度即布隆过滤器的底层容器位图bitset有多少个比特位),它们是息息相关的。
那为了保持误判率处于比较低的状态,应该如何选择哈希函数的个数和布隆过滤器的长度呢,有人通过计算后得出了以下关系式:
其中k为哈希函数个数,m为布隆过滤器长度,n为插入布隆过滤器的元素个数,p为误判率。按照公式保持各项元素之间的关系后,误判率的值就会保持在一个较低的区间。
我们这里可以大概估算一下,根据上图的下半部分的公式,如果使用3个哈希函数,即k的值为3,ln2的值我们取0.7,那么m和n的关系大概是m = 4 × n,也就是布隆过滤器的长度(长度即布隆过滤器的底层容器位图bitset有多少个比特位)应该是插入元素个数的4倍。
布隆过滤器的模拟实现
基础框架
代码如下。
因为插入布隆过滤器的元素可以是任意类型的数据,所以提供了模板参数T,只要调用者能够提供对应的哈希函数将该T类型的数据转换成size_t类型即可。
这里在模拟实现布隆过滤器时,笔者选择了使用3个哈希函数来控制误判率(说一下,这里以及上文讲解误判率的部分中所说的3个哈希函数严格意义上不能叫作3个哈希函数,而应叫3个字符串哈希算法,因为下文中在布隆过滤器中set插入每个数据时,实际的做法是每个数据都会通过3个字符串哈希算法转化出3个不同的size_t类型的值,然后3个不同的size_t类型的值会根据同一个哈希函数计算出哈希地址,因为size_t类型的值不同,所以计算出的3个哈希地址当然大概率也是不同的;如果叫3个哈希函数,那下文中的做法应为只提供一个字符串哈希算法,只转化出一个size_t类型的值,然后这一个值根据3个哈希函数计算出3个哈希地址,因为哈希函数是不同的,所以计算出的哈希地址也大概率是不同的。虽然前后两者的做法达成的效果一样,但做法本身是不一样的,下文中是按照前者的做法做的,而没有按照后者的做法做,所以这里说“笔者选择了使用3个哈希函数来控制误判率”这句话是不严格的)。
根据上文控制误判率部分的理论,咱们如果选择使用3个哈希函数来控制误判率,那么假如m为布隆过滤器长度,n为插入布隆过滤器的元素个数,m和n的关系大概是m = 4 × n,也就是布隆过滤器的长度应该是插入元素个数的4倍。同时因为上一段中说了,我们这里选择使用3个哈希函数来控制误判率,所以跟着理论走,我们需要在基础框架中让ratio=4,给位图成员对象_bs设置的比特位的个数是ratio*N(非类型模板参数N表示需要插入布隆过滤器的元素的总数),也就是给布隆过滤器的长度设置的是ratio*N。(BitSet.h的代码就是上文中模拟实现的位图的整体代码)
#pragma once
#include<iostream>
using namespace std;
#include"BitSet.h"//位图bit_set是咱们自己模拟实现出的
namespace mine
{
//布隆过滤器的非类型模板参数N表示需要插入的元素的总数,而不是位图的比特位的总数,不要混淆了
template<class T, size_t N, class Hash1, class Hash2, class Hash3>
class bloom_filter
{
public:
private:
const static size_t ratio = 4;
bit_set<ratio*N> _bs;//位图bit_set是咱们自己模拟实现出的; 当选择使用3个哈希函数时,ratio*N也就是4N,是符合根据文中的公式算出的元素之间的比例的。
};
}
set函数
用于在布隆过滤器中插入目标数据,即将目标数据所映射到的位图(位图是布隆过滤器的底层容器)上的3个比特位设置成1。这样之后在布隆过滤器中查找该数据时,发现该数据对应的3个比特位上的值都为1则表示该数据在布隆过滤器中可能存在;3个比特位中只要有1个比特位为0就说明该数据在布隆过滤器中一定不存在。
#pragma once
#include<iostream>
using namespace std;
#include"BitSet.h"//位图bit_set是咱们自己模拟实现出的
namespace mine
{
//布隆过滤器的非类型模板参数N表示需要插入的元素的总数,而不是位图的比特位的总数,不要混淆了
template<class T, size_t N, class Hash1, class Hash2, class Hash3>
class bloom_filter
{
public:
void set(const T& x)
{
size_t hashaddr1 = Hash1()(x) % (ratio * N);
_bs.set(hashaddr1);
size_t hashaddr2 = Hash2()(x) % (ratio * N);
_bs.set(hashaddr2);
size_t hashaddr3 = Hash3()(x) % (ratio * N);
_bs.set(hashaddr3);
}
private:
const static size_t ratio = 4;
bit_set<ratio*N> _bs;//位图bit_set是咱们自己模拟实现出的; 当选择使用3个哈希函数时,ratio*N也就是4N,是符合根据文中的公式算出的元素之间的比例的。
};
}
test函数
用于检测(或者说是查找)某个数据是否在布隆过滤器中存在。思路很简单,检测该数据映射到的位图(位图是布隆过滤器的底层容器)上的3个比特位上的值是1还是0,如果3个比特位都是1,则该数据可能存在,让test函数return true即可(可能该数据不存在,此时return true就是误判);3个比特位中只要有1个比特位为0就说明该数据在布隆过滤器中一定不存在,让test函数return false即可(return false不可能是误判)。
解释一下像这种代码是什么意思:size_t hashaddr1 = Hash1()(x) % (ratio * N);。x是个T类型的对象,Hash1()是临时的匿名对象,然后通过该对象调用operator()函数将T类型的对象x转化成size_t类型的对象,这就是Hash1()(x)的含义;之前Hash1()(x)生成了一个size_t类型的值,现在通过该值去%(ration*N),(ration*N)是位图上比特位的总数,所以size_t类型的值去%(ration*N)就可以将x映射在位图的合法范围内的某个比特位上。
#pragma once
#include<iostream>
using namespace std;
#include"BitSet.h"//位图bit_set是咱们自己模拟实现出的
namespace mine
{
//布隆过滤器的非类型模板参数N表示需要插入的元素的总数,而不是位图的比特位的总数,不要混淆了
template<class T, size_t N, class Hash1, class Hash2, class Hash3>
class bloom_filter
{
public:
bool test(const T& x)
{
size_t hashaddr1 = Hash1()(x) % (ratio * N);
bool a = _bs.test(hashaddr1);
size_t hashaddr2 = Hash2()(x) % (ratio * N);
bool b = _bs.test(hashaddr2);
size_t hashaddr3 = Hash3()(x) % (ratio * N);
bool c = _bs.test(hashaddr3);
if (a && b && c)
return true;//可能存在误判
else
return false;//一定不会误判
}
private:
const static size_t ratio = 4;
bit_set<ratio*N> _bs;//位图bit_set是咱们自己模拟实现出的; 当选择使用3个哈希函数时,ratio*N也就是4N,是符合根据文中的公式算出的元素之间的比例的。
};
}
布隆过滤器的删除
布隆过滤器一般不支持删除操作,原因如下:
因为布隆过滤器判断一个元素存在时可能存在误判,因此无法保证要删除的元素确实在布隆过滤器当中,此时将位图中对应的比特位清0会影响其他元素。此外,就算要删除的元素确实在布隆过滤器当中,也可能该元素映射的多个比特位当中有些比特位是与其他元素共用的,此时将这些比特位清0也会影响其他元素。
如何让布隆过滤器支持删除?
要让布隆过滤器支持删除,必须要做到以下两点:
1、保证要删除的元素在布隆过滤器当中。比如刚才的呢称例子当中,如果通过调用Test函数得知要删除的昵称可能存在布隆过滤器当中后,可以进一步遍历存储昵称的文件,确认该昵称是否真正存在。
2、保证删除后不会影响到其他元素。可以让位图中的每若干个连续的比特位表示一个位置,其中第一个位表示有无元素映射到这,剩下的位表示计数值(假如剩下的比特位的个数是x,那么最大可以计数2^x),当插入元素映射到该比特位时将该比特位的计数值++,当删除元素时将该元素对应比特位的计数值--,只有计数变成0时才能真正把这若干个连续的比特位全变成0。虽然这样的方式的确能支持布隆过滤器的删除,但这样就会导致空间消耗变多,布隆过滤器的优势(即节省空间)就削弱了,得不偿失,所以一般是不会提供删除的接口的。
布隆过滤器最终还是没有提供删除的接口,因为使用布隆过滤器本来就是要节省空间和提高效率的。在删除时需要遍历文件或磁盘中确认待删除元素确实存在,而文件IO和磁盘IO的速度相对内存来说是很慢的,并且为位图中的每个比特位额外设置一个计数器,就需要多用原位图几倍的存储空间,这个代价也是不小的。
布隆过滤器的测试代码
因为插入布隆过滤器的元素可以是任意类型的数据,所以还提供了模板参数T给布隆过滤器类,但不管布隆过滤器存储什么类型,在使用或者测试布隆过滤器时都需要提供对应的哈希函数将该T类型的数据转换成size_t类型。
现在咱们选择测试布隆过滤器存储string类型的效果,所以首先要提供3个字符串哈希算法(用于将string类的值转化成size_t类的值),如下代码所示,这三种字符串哈希算法在多种场景下产生哈希冲突的概率是最小的。这些算法中除了BKDR方法在<<哈希的介绍以及哈希表的模拟实现>>一文中讲过,其他代码都是直接拷贝的网上的,算法原理笔者也没有深入理解过,读者你也不需要理解,如果不想用下面的字符串哈希算法,你也可以在网上找,但注意一定要找产生哈希冲突的概率比较小的字符串哈希算法。
#pragma once
#include<iostream>
using namespace std;
#include"BitSet.h"//位图bit_set是咱们自己模拟实现出的
namespace mine
{
struct hashfunc_size_t
{
size_t operator()(const size_t& x)
{
return (size_t)x;
}
};
//BKDR字符串哈希方法
struct hashfunc_string1
{
size_t operator()(const string& s)
{
size_t val = 0;
for (string::const_iterator it = s.begin(); it != s.end(); it++)
{
val *= 131;
val += (*it);
}
return val;
}
};
//AP字符串哈希方法
struct hashfunc_string1
{
size_t operator()(const string& s)
{
size_t val = 0;
for (size_t i = 0; i < s.size(); i++)
{
if ((i & 1) == 0)
val ^= ((val << 7) ^ s[i] ^ (val >> 3));
else
val ^= (~((val << 11) ^ s[i] ^ (val >> 5)));
}
return val;
}
};
//DJB字符串哈希方法
struct hashfunc_string1
{
size_t operator()(const string& s)
{
if (s.empty())
return 0;
size_t val = 5381;
for (auto ch : s)
val += (val << 5) + ch;
return val;
}
};
}
有了3个字符串哈希算法后,就可以开始测试布隆过滤器存储string类型的效果了,测试代码如下图所示,说明一下代码干了什么:首先将字符串数组a中的string全部插入到布隆过滤器中,然后检测数组a中的string是否都存在于布隆过滤器中,此时当然是存在的,所以test函数都返回true,cout打印出来也就是一片的1。此后,咱们创建另一个字符串数组b,让数组b中的string的值和数组a中的值差不多相等,但实际不相等(这样才能让字符串哈希函数更有可能产生哈希冲突,即更可能转化出的size_t类型的值相等,才能更可能映射相同位置的比特位,才能制造出误判的情景),然后检测数组b中的值是否在布隆过滤器中存在,因为此前没有往布隆过滤器中插入数组b的string,所以我们知道一定是不存在的,按理来说test函数都会返回false,cout打印出来就是一片的0,但观察打印结果发现在一片0中有一个1,说明发生了误判,这个误判是在我们的意料之内的,布隆过滤器检测成功,是没有出现问题的。
上图的代码如下。
#include<iostream>
using namespace std;
#include"BloomFilter.h"
void main()
{
mine::bloom_filter<string, 10, mine::hashfunc_string1, mine::hashfunc_string2, mine::hashfunc_string3> bf;
string a[] = { "苹果","梨子","香蕉","牛奶","米哈游","腾讯","百度" };
for (string& e : a)
{
bf.set(e);
}
for (string& e : a)
{
cout << bf.test(e) << endl;
}
cout << "————" << endl;
string b[] = { "苹果111","梨子2222","香蕉xxx","牛奶1231","米哈游423","腾讯1111","百度577" };
for (string& e : b)
{
cout << bf.test(e) << endl;
}
}
布隆过滤器的整体代码
以下是整个BloomFilter.h文件的代码。(BitSet.h的代码就是上文中模拟实现的位图的整体代码)
#pragma once
#include<iostream>
using namespace std;
#include"BitSet.h"//位图bit_set是咱们自己模拟实现出的
namespace mine
{
struct hashfunc_size_t
{
size_t operator()(const size_t& x)
{
return (size_t)x;
}
};
//BKDR字符串哈希方法
struct hashfunc_string1
{
size_t operator()(const string& s)
{
size_t val = 0;
for (string::const_iterator it = s.begin(); it != s.end(); it++)
{
val *= 131;
val += (*it);
}
return val;
}
};
//AP字符串哈希方法
struct hashfunc_string2
{
size_t operator()(const string& s)
{
size_t val = 0;
for (size_t i = 0; i < s.size(); i++)
{
if ((i & 1) == 0)
val ^= ((val << 7) ^ s[i] ^ (val >> 3));
else
val ^= (~((val << 11) ^ s[i] ^ (val >> 5)));
}
return val;
}
};
//DJB字符串哈希方法
struct hashfunc_string3
{
size_t operator()(const string& s)
{
if (s.empty())
return 0;
size_t val = 5381;
for (auto ch : s)
val += (val << 5) + ch;
return val;
}
};
//布隆过滤器的非类型模板参数N表示需要插入的元素的总数,而不是位图的比特位的总数,不要混淆了
template<class T, size_t N, class Hash1, class Hash2, class Hash3>
class bloom_filter
{
public:
void set(const T& x)
{
size_t hashaddr1 = Hash1()(x) % (ratio * N);
_bs.set(hashaddr1);
size_t hashaddr2 = Hash2()(x) % (ratio * N);
_bs.set(hashaddr2);
size_t hashaddr3 = Hash3()(x) % (ratio * N);
_bs.set(hashaddr3);
}
bool test(const T& x)
{
size_t hashaddr1 = Hash1()(x) % (ratio * N);
bool a = _bs.test(hashaddr1);
size_t hashaddr2 = Hash2()(x) % (ratio * N);
bool b = _bs.test(hashaddr2);
size_t hashaddr3 = Hash3()(x) % (ratio * N);
bool c = _bs.test(hashaddr3);
if (a && b && c)
return true;//可能存在误判
else
return false;//一定不会误判
}
private:
const static size_t ratio = 4;
bit_set<ratio*N> _bs;//位图bit_set是咱们自己模拟实现出的; 当选择使用3个哈希函数时,ratio*N也就是4N,是符合根据文中的公式算出的元素之间的比例的。
};
}
测试布隆过滤器的误判率
思路为:先设置N个基础字符串,然后设置N个【和基准字符串相似的字符串】。在布隆过滤器中插入N个基准字符串后(此时没有在布隆过滤器中插入“和基准字符串相似的字符串”),然后在布隆过滤器中查找N个【和基准字符串相似的字符串】是否存在,此时是有可能发生误判的,所以设置一个cnt1用于记录发生了多少次误判,最后让cnt1/N,得出的值就是在布隆过滤器中查找N个【和基准字符串相似的字符串】是否存在的误判率了。
然后设置N个【和基准字符串不相似的字符串】。之前(也就是在上一段中)已经在布隆过滤器中插入了N个基准字符串了(但此时没有在布隆过滤器中插入“和基准字符串不相似的字符串”),然后在布隆过滤器中查找N个【和基准字符串不相似的字符串】是否存在,此时也是有可能发生误判的,所以设置一个cnt2用于记录发生了多少次误判,最后让cnt2/N,得出的值就是在布隆过滤器中查找N个【和基准字符串不相似的字符串】是否存在的误判率了。
结合上面的思路,代码如下,下面的代码所设置的N的值是10w。
#include<iostream>
using namespace std;
#include"BloomFilter.h"
#include <string>
void test1()
{
srand(time(0));
//表示布隆过滤器中需要插入的元素总数是N个,现在N是10w
const size_t N = 100000;
mine::bloom_filter<string, N, mine::hashfunc_string1, mine::hashfunc_string2, mine::hashfunc_string3> bf;
vector<string> v1;
string url = "https://www.youkuaiyun.com/?spm=1011.2124.3001.4476";
//基准字符串
for (size_t i = 0; i < N; ++i)
{
v1.push_back(url + std::to_string(1234 + i));
}
for (auto& str : v1)
{
bf.set(str);
}
//和基准字符串相似的字符串
std::vector<std::string> v2;
for (size_t i = 0; i < N; ++i)
{
v2.push_back(string("asd")+ url + std::to_string(rand() + i));
}
//在布隆过滤器中插入N个基准字符串后(此时没有在布隆过滤器中插入和基准字符串相似的字符串),在布隆过滤器中查找N个【和基准字符串相似的字符串】是否存在时是有可能发生误判的,cnt1就用于记录发生了多少次误判
size_t cnt1 = 0;
for (auto& str : v2)
{
if (bf.test(str))
{
++cnt1;
}
}
cout << "布隆过滤器对【和基准字符串相似的字符串】的误判率为:" << (double)cnt1 / (double)N << endl;
//和基准字符串不相似的字符串
std::vector<std::string> v3;
string url2 = "zhihu.com";
for (size_t i = 0; i < N; ++i)
{
v3.push_back(url2 + std::to_string(rand() + i));
}
//在布隆过滤器中插入N个基准字符串后(此时没有在布隆过滤器中插入和基准字符串不相似的字符串),在布隆过滤器中查找N个【和基准字符串不相似的字符串】是否存在时也是有可能发生误判的,cnt2就用于记录发生了多少次误判
size_t cnt2 = 0;
for (auto& str : v3)
{
if (bf.test(str))
{
++cnt2;
}
}
cout << "布隆过滤器对【和基准字符串不相似的字符串】的误判率为:" << (double)cnt2 / (double)N << endl;
}
void main()
{
test1();
}
上面代码的测试结果如下图。根据打印结果可以发现,如果是在布隆过滤器中查找【和基础字符串相似的字符串】,则误判率相对较高;如果是在布隆过滤器中查找【和基础字符串不相似的字符串】,则误判率相对较低。但总体而言,不管相不相似,误判率都是很低的,是在我们可以接受的范围内。
在上文讲解如何控制误判率的部分中说过【布隆过滤器越长其误报率越小】,现在我们就可以验证这一点,如下图1是布隆过滤器的整体代码,我们把ratio变大,比如可以从4变成10,那么布隆过滤器类的位图成员_bs的比特位的总数就会变多,也就是布隆过滤器的长度会变长,此时再执行上图的代码,测试结果如下图2所示,可以发现误判率对比上图进一步降低了近乎10倍。当然,虽然此时误判率是很低了,但存储相同数量的元素时所花的空间相比之前却是变大了很多的,注意我们一定不要本末导致,不要忘记使用布隆过滤器的初衷之一是因为它很节省空间,所以这里是不能无脑让ratio变大的,最好还是根据【上文讲解如何控制误判率的部分中的公式】来控制ratio的值。
图1如下。
图2如下。
布隆过滤器的应用场景
应用场景1
布隆过滤器作为某种名单时能够提高效率。举个例子,一般是数据库作为某种名单,所以每次查看某内容是否在名单中时都需要去数据库中找,但如果数据库在远端服务器上或者在本地磁盘上,这就都太慢了,因此这时我们就可以把数据库的名单中的内容全部存进方便本地访问的布隆过滤器中(如果内容是字符串类型,则转化成无符号整形形成内容的编号后再映射进布隆过滤器中) ,同时因为布隆过滤器所需空间不大,所以可以在内存中创建,而因为内存的访问效率是很高的,所以本段开头处才说布隆过滤器作为某种名单时能够提高效率,所以之后查看某内容是否在名单中时就不用去数据库中查,而是直接在布隆过滤器中查。
注意如果在布隆过滤器中查找某内容时发现该内容在名单中,那么是有可能发生误判的,如果是很重要的不能出问题的名单,比如黑名单这种性质比较严重的场景不能发生误判,那么此时数据库就可以作为一道保险,比如如果查找某内容是否在黑名单中时布隆过滤器反馈出的结果是在,为了避免布隆过滤器发生误判,此时就再去数据库中查找,看该内容到底是否在黑名单中;如果在布隆过滤器中查找某内容时发现该内容不在黑名单中,那么此时不会发生误判,该内容一定不在黑名单中,也就可以不向数据库中查找。
而在有些场景下,是允许布隆过滤器发生误判的,比如在注册页面中提示昵称是否被占用时,会把所有昵称转化成无符号整形形成对应的号码后全映射到布隆过滤器中,这样一来,布隆过滤器中就包含了所有昵称,即使此时注册时提示昵称已被占用可能是误判,也没有关系,毕竟用户也不知道是否发生了误判,只要不会高频率的发生误判就行。
应用场景2(包含哈希切分的思想)
应用场景2:给两个文件,分别有100亿个query(翻译为查询,这里表示字符串的意思),我们只有1G的内存,如何找到两个文件交集?分别给出精确算法和近似算法。
(先说一下,精确算法和布隆过滤器没有半毛钱关系,但近似算法是通过布隆过滤器实现的。布隆过滤器在查找数据时一定是有可能发生误判的,只是可能性较低,即使把布隆过滤器的空间开得很大,开了海量个比特位,也只能再次降低误判率,而不能杜绝误判的发生,所以这里的精确算法是不可能通过布隆过滤器实现的,只能通过布隆过滤器实现近似算法)
精确算法:
(结合下图思考)这里我们将这两个文件分别叫做A文件和B文件,首先需要估算一下这里一个文件的大小,假设平均每个string为30字节,1G大约10亿字节,那么100亿个string就是300G,也就是说每个文件都是300G,而我们只有1G左右的内存,所以内存是无法存下哪怕一个文件的,所以我们可以考虑将A和B文件切分成600个小文件,每个小文件0.5G(为什么不是1G呢?这是为了下文中set存储整个小文件做铺垫,set除了存储string所需的内存外,创建set本身肯定还是有其他空间消耗的,比如创建红黑树的节点也要花空间,所以如果每个小文件有1G的string,1G的set是存不下1G的小文件的,所以当小文件只有0.5G的string时,set存储0.5G的string后,set所花的内存就近似于1G了),比如此时我们可以将A文件切分成A0~A599共600个小文件,将B文件切分成了B0~B599共600个小文件,从代码层面上说就是fopen 600次,然后将返回的文件指针存进一个指针数组中。( 这种思想被称为哈希切分)
在代码层面上如何进行文件A和文件B的切分呢?依次遍历A文件当中的每个string,每个string都先通过字符串哈希算法将string转化成size_t类型的值,然后该size_t类型的值通过哈希函数计算出一个整型 i (0 ≤ i ≤ 599),i 表示哈希地址,然后将这个string写入到小文件A[ i ]当中。切分B文件也采用同样的操作,注意切分A文件和B文件时采用的字符串哈希算法必须是同一个,采用的哈希函数也必须是同一个,因为只有切分A文件和B文件时采用的是同一个哈希函数和同一个字符串哈希算法,A文件与B文件中相同的string计算出的 i 值才会是相同的,然后才会分别进入到A[ i ]和B[ i ]文件中。
(结合下图思考)最后分别找出A0与B0的交集(每找到一个交集string,就立刻将该string写进交集文件中)、A1与B1的交集、。。。、A599与B599的交集,经过这些步骤后,300G的A文件和300G的B文件中string的交集就被找出来并写入交集文件中了。
在代码层面上如何找到A[ i ]小文件和B[ i ]小文件的交集呢?以找到A[ 0 ]小文件和B[ 0 ]小文件的交集为例,经过切分后理论上每个小文件的平均大小是0.5G,而现在我们拥有的内存是1G,所以我们可以将A[ 0 ]或者B[ 0 ]中的一个,假设是A[ 0 ]加载到内存,并放到一个set容器中,再遍历B[ 0 ]小文件当中的string,依次判断每个string是否在set容器中,如果在则是交集,将该string写入交集文件中;不在则不是交集。
虽然我们把300G的文件A和文件B切分成了600份,并默认认为每个小文件都有0.5G,但实际上文件A和文件B中的string在根据上文中的切分方式进行哈希映射时不一定会映射的这么均衡,所以每个被切分的小文件实际上占据多少空间是未知的,所以当哈希切分不是平均切分时,有可能切出来的小文件中有一些小文件的大小是大于0.5G的,此时如果与之对应的另一个小文件可以加载到内存,则可以选择将另一个小文件中的string加载到内存,因为我们只需要将两个小文件中的一个加载到内存并将该文件的所有string放到一个set容器中就行,最后再遍历没有被加载进内存的小文件中的string,依次判断每个string是否在set容器中,如果在则是交集,将该string写入交集文件中;不在则不是交集。
但如果两个小文件的大小都大于0.5G,那我们可以考虑将这两个小文件再进行一次切分,将其切成更小的文件,方法与之前切分A文件和B文件的方法类似。
近似算法:
先把第一个文件中的100亿个string全部set插入到1G的布隆过滤器中,然后在布隆过滤器中查找第二个文件中的每个string,每找到1个,就写入到交集文件,像这样遍历完第二个文件后,两个文件的交集也就得到了。当然这只是近似算法,布隆过滤器是可能存在误判的,并且交集中可能会有重复值。
说一下,1G只够开80亿个比特位,现在光string都有100亿个,同时布隆过滤器为了保持误判率处于比较低的水平还要每个string映射3个比特位,所以如果100亿个string中重复率很高,那么布隆过滤器不会爆,在布隆过滤器中插完100亿个string后,其每个比特位上的值都大概率不是1,那么此时在布隆过滤器中查找某内容的误判率还是比较低的,也就能通过布隆过滤器近似地找到两个文件的交集,从而解决掉应用场景2的问题;
但如果100亿个string中重复率很低,那么布隆过滤器中插入完100亿个string后,其每个比特位上的值一定是1,则此时在布隆过滤器中查找任何数据都会返回一个“存在”,那么布隆过滤器也就完成不了过滤的任务,也就失去了意义,此时正确的做法就得先进行哈希切分。
如何切分呢?和上面精确算法不同,因为上面所用的容器是set<string>,set的内存有多少G就只能存多少G的string,又因为题目的要求,最多只能跟set给1G的内存,所以上面把每个小文件分成了1G;而现在的近似算法中,如果把1G的内存给布隆过滤器,1G就是80亿个比特位,为了让误判率处于比较低的状态,根据上文讲解误判率的部分得出的结论【布隆过滤器的长度(长度即布隆过滤器的底层容器位图bitset有多少个比特位)应该是插入元素个数的4倍】可知,布隆过滤器应存储20亿个元素(即string),所以我们要给文件A和文件B的每个小文件都分20亿个string,也就是把文件A和文件B分别分成5个小文件,给每个小文件都分成60G,比如此时我们可以将A文件切分成A0~A4共5个小文件,将B文件切分成了B0~B4共5个小文件,如下图所示。( 这种思想被称为哈希切分)
在代码层面上如何进行文件A和文件B的切分呢?依次遍历A文件当中的每个string,每个string都先通过字符串哈希算法将string转化成size_t类型的值,然后该size_t类型的值通过哈希函数计算出一个整型 i (0 ≤ i ≤ 4),i 表示哈希地址,然后将这个string写入到小文件A[ i ]当中。切分B文件也采用同样的操作,注意切分A文件和B文件时采用的字符串哈希算法必须是同一个、采用的哈希函数也必须是同一个,因为只有切分A文件和B文件时采用的是同一个哈希函数和同一个字符串哈希算法,A文件与B文件中相同的string计算出的 i 值才会是相同的,然后才会分别进入到A[ i ]和B[ i ]文件中。
(结合下图思考)最后分别找出A0与B0的交集(每找到一个交集string,就立刻将该string写进交集文件中)、A1与B1的交集、。。。、A4与B4的交集,经过这些步骤后,300G的A文件和300G的B文件中string的交集就被找出来并写入交集文件中了。
在代码层面上如何找到A[ i ]小文件和B[ i ]小文件的交集呢?以找到A[ 0 ]小文件和B[ 0 ]小文件的交集为例。经过切分后理论上每个小文件的平均大小是60G,而现在我们拥有的内存是1G,但没关系,我们不是直接存这60G的string(也就是20亿个string),而只是把60G的string映射到80亿个比特位上,也就是映射到1G的比特位上,内存是够的。我们可以将A[ 0 ]或者B[ 0 ]中的一个,假设是A[ 0 ]中的string全映射到1G的布隆过滤器中,再遍历B[ 0 ]小文件当中的string,依次判断每个string是否在布隆过滤器中,如果在则是交集(可能误判,但根据上文中测出的误判率可知误判率是很低的),将该string写入交集文件中;不在则不是交集(不会误判)。通过这样的方式,最后也就精确地找到了两个文件的交集,从而解决掉应用场景2的问题。
虽然我们把300G的文件A和文件B切分成了5份,并默认认为每个小文件都有60G,但实际上文件A和文件B中的string在根据上文中的切分方式进行哈希映射时不一定会映射的这么均衡,所以每个被切分的小文件实际上占据多少空间是未知的,所以当哈希切分不是平均切分时,有可能切出来的小文件中有一些小文件的大小是大于60G的,也就是存储的string超过了20亿个,此时如果将超过20亿个string存进布隆过滤器,则会使布隆过滤器的误判率变高,所以如果与之对应的另一个小文件中的string的总数是小于等于20亿个,则将该文件中的所有string映射进布隆过滤器中,因为我们只需要将两个小文件中的一个全映射到布隆过滤器中就行,最后再遍历没有被映射到布隆过滤器中的小文件中的string,依次判断每个string是否在布隆过滤器中,如果在则是交集(可能发生误判,但根据上文测试误判率的部分可知误判率是很低的),将该string写入交集文件中;不在则不是交集。
但如果两个小文件的大小都超过60G,也就是文件中的string都超过了20亿个,则此时不管将哪个小文件中的string全部映射进布隆过滤器中都会增加布隆过滤器的误判率,所以我们不要这么做,而是可以考虑将这两个小文件再进行一次切分,将其切成更小的文件,方法与之前切分A文件和B文件的方法类似。
应用场景3(包含哈希切分的思想)
应用场景3:给一个超过100G大小的文件,文件中存的全是IP地址(即string类对象),换句话说也就是文件中有100G的string,我们只有1G的内存,设计算法找到出现次数最多的IP地址?如何找到top K的IP?
设计算法找出次数最多的IP地址
看到统计次数,常见的思路就是借助map<string,int>。现在最多只能跟map给1G的内存,所以是不可能存储100G大小的string的,所以需要利用哈希切割的思想将100G的文件分成若干个小文件,如何切分呢?假设只是从前到后等份切割,将文件切割n份(切割的份数依据所给的内存大小),第一份中假设IP地址为a出现次数最多,第二份中b出现的次数最多,这样比较出来,假设a比b大,那得出的结果就是IP地址为a的出现次数多,可是?假设第一份中也有地址为b的IP呢?这样分割出来的文件,不能保证相同IP地址被分到同一个文件当中,这样算出的结果就肯定不正确了。
这里我们应该想到使用哈希的思想,我们可以分出n个小文件(切割的份数依据所给的内存大小),通过字符串哈希算法将点分十进制的IP地址转化为整数,然后通过哈希函数求出IP地址所对应文件的编号,这样将所有IP地址分派到不同编号的文件中,因为所有IP地址使用的是同一个字符串哈希算法和同一个哈希函数,所以此时相同的IP地址一定映射在了同一个文件当中,然后依次遍历每个文件,统计出每个IP地址出现的次数,这样就可以找到出现次数最多的IP地址。
说一下,虽然我们只能给map 1G的内存,并且某些小文件的大小非常可能会超过1G,但在有些情况下这里是没有影响的,比如如果小文件中有很多重复的string,则map存这些string的时候,不用存整个小文件的string进内存,只要将用于计数的pair的second成员的值+1即可,这根本就不用多花1个字节的内存,是没有空间消耗的,所以1G的map在这种情况下能统计出超过1G的小文件中的string出现的次数;但如果小文件中没有很多重复的string,比如这些映射到同一个小文件中的string只是比较相似,而不是相同,则map存这些string的时候,需要存整个小文件的string进内存,但小文件的中的string超过了1G,1G的map连1G的string都存不下(因为map自身是有空间消耗的),更别提存超过1G的string了,所以此时就需要再对该小文件进行切分,切分的方法和之前的类似。
如何找到top K的IP?
top K问题的解决办法我们很容易想到用堆,首先,搞清楚是要出现次数最多的K个IP还是出现次数最少的K个IP。如果是最多的,则创建一个由K个数据形成小堆,如果最少的,则创建一个由K个数据形成大堆,假设这里我们要的是出现次数最多的K个IP。我们可以借助第一问的思路求出每个IP地址出现的次数,但因为内存只有1G,存不下由【每个IP】和【IP出现的次数】组成的键值对,所以可以每求出一个IP地址出现的次数后就将该由【IP】和【IP出现的次数】组成的组合写入文件X中,等到所有IP和该IP出现的次数都被统计完并写入文件X后,咱们就可以从文件X中取前K个由【IP】和【IP出现的次数】组成的组合,并在内存中将这K个组合用pair<string,int>存储,然后创建一个只能容纳K个键值对的小堆,在向堆中插入元素时,以IP地址出现的次数作为关键码,先向堆中插入这K个pair<string,int>,然后再每次从文件X中依次读取少量个位于前K个以后的由【IP】和【IP出现的次数】组成的组合,尝试将读取到的少量个组合依次插入到堆中,在插入时,先于堆顶元素进行比较,如果小于堆顶元素,不做处理;如果大于,则将堆顶元素直接换成正在插入的元素,然后向下调整一次,以此将该元素插入堆中,当遍历完文件X中的所有IP地址后,堆中保存的元素就是出现次数最多的K个。