目录
一、位图
1.1 位图的概念
1.2 位图的使用
1.3 位图的实现
1.4 位图的应用
二、布隆过滤器
2.1 布隆过滤器
2.2 布隆过滤器的实现
2.3 布隆过滤器练习题
一、位图
1.1 位图的概念
所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在。
1.2 位图的使用
首先我们来看一道题目:
给定40亿个不重复的无符号整数,没有进行排序。现在给一个无符号整形,如何快速判断一个数是否存在这40亿个数中。
现在有三种方法:
1.遍历,时间复杂度O(N)
2.排序后使用二分查找,时间复杂度为:排序(O(N logN)) + 二分查找(O(logN))
3.位图
如果我们使用位图解决该的问题,我们只需要开辟一个40亿个 bit 的空间(如果直接存放40亿的整数约占16G,开辟40亿bit约占512MB).
使用直接定址法进行映射,如果该位置是0,则表示该数据不存在,如果是1表示该数据存在。
如下图:
1.3 位图的实现
接下来是位图的接口展示:
template<size_t N>
class bit_set
{
public:
//默认构造
bit_set()
{}
//将映射的地方改为1
void set(size_t x)
{}
//删除数据
void reset(size_t x)
{}
//判断x在不在
bool test(size_t x)
{}
private:
vector<char> _bits;
};
我们可以设置一个非模板参数来控制开辟空间的大小,在构造函数中进行空间的开辟。
bit_set()
{
_bits.resize(N / 8 + 1, 0);
}
接下来就是 set 的编写了,目的就是将映射的地址改为1即可,我们使用/8求出该值在第几个char上,再进行模8求出在第几位上,再进行进行位移+或的方式进行即可:
//将映射的地方改为1
void set(size_t x)
{
//1.除8再模8
size_t i = x / 8; //求在第几个char处
size_t j = x % 8; //求在第几位上
_bits[i] |= (1 << j);
}
reset表示删除该数,我们直接将该bit位上的数据置为0即可,我们找到该将1左移到该位置上,然后使用取反操作,这样除了第j位的都是1,再进行与操作,即可完成数据的删除。
void reset(size_t x) //删除这个数据
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] &= ~(1 << j); //左移取反再 与
}
test接口就是将传入的数据的映射位直接返回即可。
bool test(size_t x)//判断x在不在
{
size_t i = x / 8;
size_t j = x % 8;
return _bits[i] & (1 << j);
}
写完之后我们来测试一下:
注意,这里其实不用关注当前是小端存储还是大端存储,因为我们的存储规则和查询规则是一致的,其中我们的位操作(例如左移,本质上是从低地址往高地址进行位移,而不是方向的位移)。
1.4 位图的应用
- 给定100亿个int,1G内存,设计算法找到只出现一次的整数。
第一题:
首先,1G内存大约有80亿的bit位,而100亿个int,int 最多能表示大约42亿9千万个数,也就是说100亿的数据一半以上都是重复的;我们只用43亿个bit位就可以解决该问题,所以这里使用1G空间完全可以解决该问题。
这是一个KV统计搜索模型,我们可以使用两个位图来解决,用两个位图中对应位置的值来表示这个整数的出现情况:
0次 ---> 00
1次 ---> 01
2次及以上---> 10
其实在STL库中就有位图容器,我们可以直接进行使用:
接下来就是set的实现,首先要检测两张位图中该数据的存在情况,然后根据其状态做出处理:
void set(size_t x)
{
bool inset1 = _bs1.test(x); //检测当前数据存在情况
bool inset2 = _bs2.test(x); //检测当前数据存在情况
// 00 -> 01 表示出现一次
if (inset1 == false && inset2 == false)
{
_bs2.set(x);
}
// 01 -> 10 表示出现一次
else if (inset1 == false && inset2 == true)
{
_bs1.set(x);
_bs2.reset(x);
}
}
test的实现就是位图1中该位置是0并且位图2该位置是1则为真,反之则为假:
bool test(size_t x)
{
return (_bs1.test(x) == false && _bs2.test(x) == true);
}
接下来我们可以写一个打印当前位图存放了所有数据的Print函数:
void Print_once_num()
{
cout << "Print_once_num:" << endl;
for (size_t i = 0; i < N; i++)
{
if (_bs1.test(i) == false && _bs2.test(i) == true)
{
cout << i << " ";
}
}
cout << endl;
}
然后我们写一段代码简单测试一下我们的双位图结构:
位图快并且节省空间,但是其局限在于只能处理整形,接下来我们就来学习布隆过滤器来处理字符串。
二、布隆过滤器
2.1 布隆过滤器
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的一种紧凑型的、比较巧妙的概率型数据结构,特点是高效的插入和查询,可以用来告诉你"某样东西一定不存在或可能存在",它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升效率,也可以节省大量的内存空间。
如上图,x、y、z都映射了3处,但是发现 x 和 z 以及 y 和 z 有相同的映射处,这就说明布隆过滤器是存在不准确的情况。
再观察W,w不是过滤器中的值,进行检测映射后发现一个位置为0,则能表示w不在过滤器中。这便能得出结论。
误判情况:
存在:不准确,有可能是其它数据也映射到了此处。
不存在:准确,表示该值并没有把其应该映射的位置进行修改。
布隆过滤器的存在的误判是被允许的,因为在很多场景需要快速地进行判断。
- 比如游戏中的起网名,服务器不可能将你的游戏 ID 拿到数据库中进行查询,而是直接将你的游戏 ID 在过滤器中进行查询,如果过滤器查询结果是 ID 已存在,系统则提示你 ID 被占用。即使这个ID在数据库中并不存在,但是这样的操作节省了服务器的运行压力。
- 再比如网络失信名单,将身份证号在失信名单过滤器中进行查询,如果查询结果显示为失信人员,则再由服务器将身份证在数据库中进行二次查询;而如果显示非失信人员时,直接返回结果即可。
所以,布隆过滤器是非常适合字符串的快速查询,即使存在缺陷,但是我们可以采取多次映射的方式,即使用不同的字符串哈希算法,来降低误判的几率。
理论而言:一个值映射的位越多或表的长度越长,误判概率越低。但是也不能映射太多,不然会导致布隆过滤器优势丧失。
这有一篇相关的证明博客:详解布隆过滤器的原理,使用场景和注意事项
根据上面博客的中的内容,使用越多的字符串哈希函数其冲突率会逐渐降低。
接下来我们分析我们应该如何设计m和k,即过滤器长度和哈希函数的个数
所以,接下来的布隆过滤器的实现,比如我们要标记N个数,则应开辟4.2*N以上的空间(方便计算取5)
2.2 布隆过滤器的实现
布隆过滤器的底层使用的位图来进行记录数据,这次模拟实现使用3套哈希函数,所以要设置5个模板参数(1.数据个数;2.数据类型;3.哈希函数1;4哈希函数2;5.哈希函数3)
1.哈希函数
注意:这次是使用字符串类型进行测试,所以哈希函数都是字符串的哈希函数;如果想让过滤器支持自定义类型直接编写对应的哈希函数即可。
各种字符串哈希函数:各种字符串Hash函数
这里直接使用几种常见的字符串哈希函数进行用于传参即可,如下:
struct HashString1
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val = val * 131 + ch;
}
return val;
}
};
struct HashString2
{
size_t operator()(const string& key)
{
size_t hash = 5381;
for (auto ch : key)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
struct HashString3
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (size_t i = 0; i < key.size(); i++)
{
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ key[i] ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ key[i] ^ (hash >> 5)));
}
}
return hash;
}
};
2.标记数据
过滤器的标记则是使用传入的哈希函数算出映射位置,然后调用位图得 set 进行标记即可。
void Set(const K& key)
{
//将哈希函数映射处进行标记
size_t hash1 = Hash1()(key) % (_ratio * N);
size_t hash2 = Hash2()(key) % (_ratio * N);
size_t hash3 = Hash3()(key) % (_ratio * N);
_bits.set(hash3);
_bits.set(hash1);
_bits.set(hash2);
}
3.查询数据
查询数据其实就是找对应的映射位置,如果3个映射位置有一个为0,则表示数据不存在,并且该结果准确,如果三个都为1,则表示该数据可能存在,这是布隆过滤器不可避免的问题。
实现方式是根据哈希函数求出对应的3个映射位置,然后使用位图的 test,如果有一处为0则返回false,反之返回true;
bool Test()
{
//检测对应的3处标记为位
size_t hash1 = Hash1()(key) % (_ratio * N);
size_t hash2 = Hash2()(key) % (_ratio * N);
size_t hash3 = Hash3()(key) % (_ratio * N);
//3处都不为零返回真,1处为假则返回假
if (_bits.test(hash1) && _bits.test(hash2) && _bits.test(hash3))
return true;
return false;
}
4. 效果测试
测试思路:插入一组字符串arr1,然后让arr2中的字符串进行查询,观察查询结果。
void TestBloomFilter1()
{
string arr[] = { "苹果","西瓜","菠萝","草莓","梨子","葡萄"};
BloomFilter<100, string, HashString1, HashString2, HashString3> bf;
for (auto str : arr)
{
bf.Set(str);
}
cout << "Test:" << endl;
string arr2[] = { "梨子","苹果","草莓","李子","西瓜2"," ","字符","排序"};
for (auto str : arr2)
{
cout << str << ":";
if (bf.Test(str)) cout << "存在" << endl;
else cout << "不存在" << endl;
}
}
5. 误判率的检测
接下来是一段测试误判率的代码
void TestBloomFilter2()
{
srand(time(0));
const size_t N = 100000;
BloomFilter<100000, string, HashString1, HashString2, HashString3> bf;
cout << sizeof(bf) << endl;
std::vector<std::string> v1;
std::string url = "https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html";
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)
{
std::string url = "http://www.cnblogs.com/-clq/archive/2021/05/31/2528153.html";
url += std::to_string(99999999 + 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)
{
string url = "zhihu.com";
url += std::to_string(rand() + i);
v3.push_back(url);
}
size_t n3 = 0;
for (auto& str : v3)
{
if (bf.Test(str))
{
++n3;
}
}
cout << "不相似字符串误判率:" << (double)n3 / (double)N << endl;
}
2.3 布隆过滤器练习题
第一题
- 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法。
首先,query表示的是请求,比如网络请求,SQL语句,本质就是字符串。
其次,近似算法的意思是允许该算法存在一些误判,而精确算法的要求是绝对准确。
近似算法:
使用布隆过滤器:
答:将 A 文件中的数据放到布隆过滤器中,然后遍历 B 文件中的数据与布隆过滤器中的数据进行比较,如果是存在则是交集,不存在则不是交集。
但是这种方法虽然实现简单,但是存在误判和重复项过多两个问题。
精确算法:
接下来介绍一种思想:哈希切分。
- 假设每个query占30byte,100亿query则是3000亿byte,则约为300G(1G约10亿byte)。
- 然后我们将每一个 query 使用哈希函数转为整形,将该整形进行取模放入对应的 i 文件中,则相同的 query 就被放到了相同编号的小文件。
- 让 Ai 与 Bi 的文件放入内存一一进行对比,如果对比结果相同,则是交集。
第二题
- 如果扩展BloomFilter使得它支持删除元素的操作。
布隆过滤器不支持删除操作。因为删除一个标记位可能会影响其它的数据在改为的映射关系。如果想要实现删除操作,可以使用引用计数的思路来实现BloomFilter的删除操作,但是如果使用引用计数的方式支持删除,空间消耗会更多,会导致BloomFilter的优势消失。
第三题
- 给一个超过100G大小的 log file,log中存着 IP 地址,设计算法找到出现次数最多的 IP 地址?那如果找到 top K 的 IP 呢?
解决方式:
- 读取每个ip,i=Hash(ip)%500,即 ip 进行第 i 个文件。
- 依次使用map<string,int>,对每个小文件统计次数,即映射的 ip 最多的文件。
- 取出出现次数最多的文件,然后建立K个值为<ip,count>的小堆,即可求出出现次数最多的K个ip。