【数据结构】从位图到布隆过滤器
位图的引入
在学习位图之前,我想先和大家谈谈我们之前学习过的搜索元素的方式都有哪些,首先肯定是大家学习完基本语法就学会了的暴力查找,通过遍历整个区间来搜索某个元素;然后呢,大家可能还学习过二分查找,对于排过序的数组,使用二分查找的时间复杂度是O(logN);再然后,可能还学习过搜索树,二叉树在平衡的前提下查找/插入/删除的时间复杂度是O(logN),但极端情况下(二叉树严重不平衡),这些操作的时间复杂度会退化到O(N),而AVL树和红黑树通过旋转保证二叉树的平衡性,将这些操作的时间复杂度稳定在O(logN);再然后,大家可能接触过哈希表,通过以空间换时间的方式,用元素的关键字和位置建立映射,查找元素只需要通过关键字得到下标就能够直接访问,在不发生哈希冲突时,查找元素的时间复杂度是O(1)。
前面我们提到的这些方式都能够搜索元素,并且除了暴力查找,都挺优秀的,但是有一个缺点,那就是查找时占用内存空间太大了,请大家思考一个问题:
给定40亿个不重复的无符号整数,在没排过序的情况下,如何能够快速判断一个数是否存在于这些数中?
让我们计算一下,40亿个不重复的无符号整数,需要的空间就是差不多4*1024*1024*1024个int(4字节),也就是4*4*1024*1024*1024字节=16G,这么大的内存空间,使用前面提到的这些方式都不好使了!
但其实我们可以发现一件事,前面的这些方式都是用4个字节(32个比特位)来存放一个整数,但是我们其实最关心的是这个数存在还是不存在,只需要1和0就行了,一个比特位就能够判断出这个数存不存在。那这个数的大小怎么表示呢?我们就用这个比特位的位置来表示它的大小。
所以说我们一个比特位就能完成先前用32个比特位才能完成的判断,那么判断40亿个不重复的整数就只需要0.5G的内存空间就行了!
现在我们给出位图的定义:
所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。
位图的实现
我们前面说了,位图就是用比特位来表示数的存在状态,那么具体是怎么实现的呢?口说无凭,接下来让我们自己设计一个位图!
为了用很多个比特位来表示很多个数,我们就可以用int数组来承载比特位,假设一个数是n,那么通过n/32能得出这个比特位在第几个整型中,通过n%32能得出这个比特位在这个整型的第几个比特位中(除以32和取模32是因为int的大小是32位)
至于操作,根据我们前面的描述,位图最需要提供的是:
- 将某个比特位的值置为1
- 将某个比特位的值置为0
- 判断某个比特位的值是0还是1
接下来上代码!
#include <iostream>
#include <vector>
template<size_t N>
class bit_set
{
public:
bit_set()
{
_bits.resize(N/32 + 1, 0);
}
void set(size_t n) // 将位图的第n个比特位设置为1
{
int i = n / 32; // 这个数在第几个整型
int j = n % 32; // 这个数在该整型的第几个比特位
_bits[i] |= (1 << j); // 与除了j位置为1其它位置都为0的数按位或
}
void reset(size_t n) // 将位图的第n个比特位设置为0
{
int i = n / 32;
int j = n % 32;
_bits[i] &= ~(1 << j); // 与除了j位置为0其它位置都为1的数按位与
}
bool test(size_t n) // 判断位图对应位是否为1
{
int i = n / 32;
int j = n % 32;
return _bits[i] & (1 << j); // 与除了j位置为1其它位置都为0的数按位与
}
private:
std::vector<int> _bits;
};
在注释中解释了代码的含义,由于篇幅有限,我就不作详细讲解了,大家可以自己找几个数算算就懂啦。
位图的延展应用
1. 给定100亿个整数,找出其中只出现一次的整数?
要找出只出现一次的整数,也就是说我们现在需要三种状态:没出现过、出现一次、出现两次及以上(00, 01, 10),我们可以设计一个包含两个位图的数据结构,当一个整数不存在时,两个位图的对应比特位都设置为0;当一个整数出现一次时,第一个位图对应位设置为0,另一个位图对应位设置为1;当出现一次以上时,第一个位图对应位设置为1,另一个位图对应位设置为0。
2. 给定两个文件,分别有100亿个整数,只有1G的内存,如何找到文件交集?
两个文件的整数各自映射到一个位图中,两个位图都为1的位置就是交集的值。
布隆过滤器的引入
请大家思考这样一个场景,我们设计了一个网络爬虫,每天需要处理10亿条URL的去重,该用什么数据结构解决呢?大家的第一反应可能是哈希表,但通过前面我们的学习,想必大家也认识到了,使用传统哈希表的话,内存压根存不下这么多东西,而去重操作实际上不需要存放数据本身,我们仍然只需要判断是否存在,把存在的数据去除即可,这正好非常适合用位图解决!
但是位图的位置仍然是使用哈希函数计算的,不可避免地会出现哈希冲突,也就是说,我们的判断可能是不准确的,难道就只能这么放弃了吗!当然不是,一个叫布隆的大佬觉得,既然使用哈希会发生哈希冲突,那么使用多个哈希把一个元素映射到好几个位置,不就降低了冲突的概率了吗,而且我们的位图是用来判断元素是否存在于集合中的,发生哈希冲突意味着当位图认为元素存在时,元素不一定存在(这个位置可能和其它存在的元素的哈希值冲突),但是当位图认为元素不存在时,这个元素一定不存在呀!这不是正好可以用来过滤不存在的数据吗 !
这就是Burton Howard Bloom在1970年提出的布隆过滤器的思想,这是一种概率型的数据结构,可以告诉我们某个元素一定不存在和可能存在。
布隆过滤器的实现
使用初始为0,位数为m的比特位图+和k个独立的哈希函数实现,当需要插入元素时,使用几个不同的哈希函数计算出几个哈希值,相当于将该元素映射到位图的这几个比特位;当进行查找元素时,布隆过滤器判断这个元素不存在时,这个元素肯定不存在于集合中;但是判断这个元素存在时,由于可能出现哈希冲突,这个元素也可能不存在于集合中。
接下来上代码!
#include "bitset.hpp" // 我们刚刚自己写的位图,当然库中也有(好像有bug)
#include <string>
using namespace std;
struct BKDRHash
{
size_t operator()(const string& key)
{
// BKDR
size_t hash = 0;
for (auto e : key)
{
hash *= 31;
hash += e;
}
return hash;
}
};
struct APHash
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (size_t i = 0; i < key.size(); i++)
{
char ch = key[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& key)
{
size_t hash = 5381;
for(auto ch : key)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
template<size_t N,
class K = string,
class HashFunc1 = BKDRHash,
class HashFunc2 = APHash,
class HashFunc3 = DJBHash>
class BloomFilter
{
public:
void set(const K& key)
{
size_t pos1 = HashFunc1()(key) % N + 1;
size_t pos2 = HashFunc2()(key) % N + 1;
size_t pos3 = HashFunc3()(key) % N + 1;
_bs.set(pos1);
_bs.set(pos2);
_bs.set(pos3);
}
bool test(const K& key)
{
size_t pos1 = HashFunc1()(key) % N + 1;
size_t pos2 = HashFunc2()(key) % N + 1;
size_t pos3 = HashFunc3()(key) % N + 1;
if(_bs.test(pos1) == false) return false;
if(_bs.test(pos2) == false) return false;
if(_bs.test(pos3) == false) return false;
return true;
}
private:
bit_set<N> _bs;
};
布隆过滤器的注意事项
布隆过滤器的局限性?
布隆过滤器只能用于判断元素是否存在,而不能获取元素
传统的布隆过滤器只能插入元素,而不能删除元素(但可以通过引用计数实现)
布隆过滤器的价值?
布隆过滤器可以快速准确地判断元素不存在的情况,并且占用的空间远小于哈希表,因此可以像过滤器一样筛选掉不存在的元素,而即使过滤器会误判元素存在,由于可以通过调整位图大小和哈希函数的数量以降低误判率,因此只要用户有需求,完全可以过滤掉绝大多数不存在的元素,接下来只需要对剩下的一小部分元素判断是否存在即可,这样判断的开销就大大降低了!
布隆过滤器的应用场景?
由于布隆过滤器可以快速准确地判断元素不存在的情况,并且占用的空间远小于哈希表,所以可以用于筛选/过滤信息
缓存穿透防护:快速过滤不存在的数据请求(如Redis缓存未命中时,可以不直接访问数据库,而是再用布隆过滤器过滤一遍)
网络爬虫:使用少量空间快速准确地取出重复URL,避免重复抓取