所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在。
首先我们来看一道题目:
给定40亿个不重复的无符号整数,没有进行排序。现在给一个无符号整形,如何快速判断一个数是否存在这40亿个数中。
现在有三种方法:
遍历,时间复杂度O(N)
排序后使用二分查找,时间复杂度为:排序(O(N logN)) + 二分查找(O(logN))
位图
如果我们使用位图解决该的问题,我们只需要开辟一个40亿个 bit 的空间(如果直接存放40亿的整数约占16G,开辟40亿bit约占512MB).
使用直接定址法进行映射,如果该位置是0,则表示该数据不存在,如果是1表示该数据存在。
如下图:
接下来是位图的接口展示:
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);
}
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的一种紧凑型的、比较巧妙的概率型数据结构,特点是高效的插入和查询,可以用来告诉你"某样东西一定不存在或可能存在",它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升效率,也可以节省大量的内存空间。
如上图,x、y、z都映射了3处,但是发现 x 和 z 以及 y 和 z 有相同的映射处,这就说明布隆过滤器是存在不准确的情况。
再观察W,w不是过滤器中的值,进行检测映射后发现一个位置为0,则能表示w不在过滤器中。这便能得出结论。
误判情况:
存在:不准确,有可能是其它数据也映射到了此处。
不存在:准确,表示该值并没有把其应该映射的位置进行修改。
布隆过滤器的存在的误判是被允许的,因为在很多场景需要快速地进行判断。
所以,布隆过滤器是非常适合字符串的快速查询,即使存在缺陷,但是我们可以采取多次映射的方式,即使用不同的字符串哈希算法,来降低误判的几率。
理论而言:一个值映射的位越多或表的长度越长,误判概率越低。但是也不能映射太多,不然会导致布隆过滤器优势丧失。
这有一篇相关的证明博客:详解布隆过滤器的原理,使用场景和注意事项
根据上面博客的中的内容,使用越多的字符串哈希函数其冲突率会逐渐降低。
接下来我们分析我们应该如何设计m和k,即过滤器长度和哈希函数的个数
所以,接下来的布隆过滤器的实现,比如我们要标记N个数,则应开辟4.2*N以上的空间(方便计算取5)
布隆过滤器的底层使用的位图来进行记录数据,这次模拟实现使用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;
}
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;
}
海量数据处理是指基于海量数据的存储和处理,正因为数据量太大,所以导致要么无法在短时间内迅速处理,要么无法一次性装入内存。
题目一:给定100亿个整数,设计算法找到只出现一次的整数。
我们标记整数时可以将其分为三种状态:
一个位只能表示两种状态,而要表示三种状态我们至少需要用两个位,因此我们可以开辟两个位图,这两个位图的对应位置分别表示该位置整数的第一个位和第二个位。
我们可以将这三种状态分别定义为00、01、10,此时当我们读取到重复的整数时,就可以让其对应的两个位按照00→01→10的顺序进行变化,最后状态是01的整数就是只出现一次的整数。
为了方便演示,下面我们直接从vector中读取若干整数进行模拟处理:
#include <iostream>
#include <vector>
#include <assert.h>
#include <bitset>
using namespace std;
int main()
{
//此处应该从文件中读取100亿个整数
vector<int> v{ 12, 33, 4, 2, 7, 3, 32, 3, 3, 12, 21 };
//在堆上申请空间
bitset<4294967295>* bs1 = new bitset<4294967295>;
bitset<4294967295>* bs2 = new bitset<4294967295>;
for (auto e : v)
{
if (!bs1->test(e) && !bs2->test(e)) //00->01
{
bs2->set(e);
}
else if (!bs1->test(e) && bs2->test(e)) //01->10
{
bs1->set(e);
bs2->reset(e);
}
else if (bs1->test(e) && !bs2->test(e)) //10->10
{
//不做处理
}
else //11(理论上不会出现该情况)
{
assert(false);
}
}
for (size_t i = 0; i < 4294967295; i++)
{
if (!bs1->test(i) && bs2->test(i)) //01
cout << i << endl;
}
return 0;
}
需要注意以下几点:
题目二:给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件的交集?
方案一:(一个位图需要512M内存)
方案二:(两个位图刚好需要1G内存,满足要求)
说明一下: 对于32位的整型,无论待处理的整数个数是多少,开辟的位图都必须有 2 32 个比特位,也就是512M,因为我们要保证每一个整数都能够映射到位图当中,因此这里位图的空间消耗是固定的。
题目三:一个文件有100亿个整数,1G内存,设计算法找到出现次数不超过2次的所有整数。
该题目和题目一的方法是一样的,在该题目中我们标记整数时可以将其分为四种状态:
一个整数要表示四种状态也是只需要两个位就够了,此时当我们读取到重复的整数时,就可以让其对应的两个位按照00→01→10→11的顺序进行变化,最后状态是01或10的整数就是出现次数不超过2次的整数。
#include <iostream>
#include <vector>
#include <bitset>
using namespace std;
int main()
{
vector<int> v{ 12, 33, 4, 2, 7, 3, 32, 3, 3, 12, 21 };
//在堆上申请空间
bitset<4294967295>* bs1 = new bitset<4294967295>;
bitset<4294967295>* bs2 = new bitset<4294967295>;
for (auto e : v)
{
if (!bs1->test(e) && !bs2->test(e)) //00->01
{
bs2->set(e);
}
else if (!bs1->test(e) && bs2->test(e)) //01->10
{
bs1->set(e);
bs2->reset(e);
}
else if (bs1->test(e) && !bs2->test(e)) //10->11
{
bs2->set(e);
}
else //11->11
{
//不做处理
}
}
for (size_t i = 0; i < 4294967295; i++)
{
if ((!bs1->test(i) && bs2->test(i)) || (bs1->test(i) && !bs2->test(i))) //01或10
cout << i << endl;
}
return 0;
}
题目四: 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件的交集?给出近似算法。
题目要求给出近视算法,也就是允许存在一些误判,那么我们就可以用布隆过滤器。
题目五: 如何扩展BloomFilte使得它支持删除元素的操作?
布隆过滤器一般不支持删除操作,原因如下:
如果要让布隆过滤器支持删除,就必须要做到以下两点:
题目六: 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件的交集?给出精确算法。
还是刚才那道题目,但现在要求给出精确算法,那么就不能使用布隆过滤器了,此时需要用到哈希切分。
在切分时需要选择一个哈希函数进行哈希切分,以切分A文件为例,切分时依次遍历A文件当中的每个query,通过哈希函数将每个query转换成一个整型 i (0 ≤ i ≤ 399),然后将这个query写入到小文件Ai当中。对于B文件也是同样的道理,但切分A文件和B文件时必须采用的是同一个哈希函数。
由于切分A文件和B文件时采用的是同一个哈希函数,因此A文件与B文件中相同的query计算出的 i 值都是相同的,最终就会分别进入到Ai和Bi文件中,这也是哈希切分的意义。
因此我们就只需要分别找出A0与B0的交集、A1与B1的交集、…、A399与B399的交集,最终将这些交集和起来就是A文件和B文件的交集。
那各个小文件之间又应该如何找交集呢?
本质这里在进行哈希切分时,就是将这些小文件看作一个个的哈希桶,将大文件中的query通过哈希函数映射到这些哈希桶中,如果是相同的query,则会产生哈希冲突进入到同一个小文件中。
题目七: 给一个超过100G大小的log file,log中存着IP地址,设计算法找到出现次数最多的IP地址?如何找到topK的IP?
该题目同样需要用到哈希切分,切分步骤如下:
经过哈希切分后得到的这些小文件,理论上就能够加载到内存当中了,如果个别小文件仍然太大那可以对其再进行一次哈希切分,总之让最后切分出来的小文件能够加载到内存。
本文到此结束, 码文不易, 还请多多支持哦! ! !