【C++】位图+布隆过滤器
1.位图
概念
所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的或是否被标记。
1.二进制位表示 :
位图中的每一位(bit)代表一个元素的状态。通常,1 表示元素存在或被标记,0 表示元素不存在或未被标记。
2.空间高效性 :
每个元素仅占用 1 比特的空间,相比其他数据结构存储数据最小也只是char类型占1字节8比特位,可见位图在存储空间上极为高效.
问题引入
面试题:
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。
分析:
1G
=1024MB
=1024x1024KB
=1024x 1024x1024Byte
=2^30Byte=10亿+Byte
40亿整数等于160亿Byte,约等于16个G左右
set等数据结构和排序方法不适用
因为内存占用过大,插入查找效率不高
运用位图解决:
位图用1位表示每个可能的整数是否存在。无符号整数范围是0到4,294,967,295个可能的值,位图需要约512MB内存(4,294,967,296位 ÷ 8 = 512MB),远小于 set 的内存需求,内存空间只需原来的1/8。
位图的查找操作是O(1)时间复杂度,通过简单的位运算即可判断一个数是否存在。
1.2位图的实现
复习位运算
基本操作:
1.与运算(AND) :符号为 &
用于比较两个二进制数的每一位。只有当两个数的对应位都是1时,结果位才是1,否则为0。
2.或运算(OR) :符号为 |
比较两个二进制数的每一位。只要两个数的对应位中有一个是1,结果位就是1,否则为0。
3.异或运算(XOR) :符号为 ^
比较两个二进制数的每一位。只有当两个数的对应位不同时,结果位才是1,否则为0。
4.取反运算(NOT) :符号为 ~
对一个二进制数的每一位取反。即把1变为0,0变为1。
5.左移运算(Left Shift) :符号为 <<
将一个二进制数的各位向左移动指定的位数。左边溢出的位被丢弃,右边空出的位用0填充。
6.右移运算(Right Shift) :符号为 >>
将一个二进制数的各位向右移动指定的位数。右边溢出的位被丢弃,左边空出的位用0填充(对于无符号数)或用符号位填充(对于有符号数)。
注意:左移是往高地址,右移往低地址,不是指方向
位运算的应用:
数据压缩 :通过位运算可以将多个布尔值或小整数打包到一个较大的数据类型中,从而节省空间。
标记状态 :使用位图来标记状态,如文件系统的磁盘块使用情况、内存管理中的页面使用情况等。
高效计算 :位运算在某些数学计算中非常高效,比如乘以或除以2的幂次时,可以用左移或右移运算代替乘法或除法。
并发编程 :在原子操作中,位运算可以用于实现锁和标志位等。
位运算的优势:
性能高效 :位运算在计算机底层硬件层面直接操作二进制位,通常比其他算术运算更快。
空间节省 :可以用较少的空间存储大量的二进制状态信息,如位图。
实现
- bitset类模板
namespace ee
{template<size_t N>class bitset{public:bitset(){_a.resize(N / 32 + 1);}private:vector<int> _a;};
}
1.N 是一个非类型模板参数,是无符号整型,表示位集合中位的数量。
2.构造函数中,N / 32 计算出需要的完整整数数量。如果 N 不是32的倍数,则 N / 32 会向下取整,因此需要加1来确保有足够的空间存储所有位。最坏的情况是刚好整除,多了32个比特位
3.用vector _a;来存储位集合,每个集合可存储8个比特位
注意:位图开空间看的是数据的范围而不是个数,对于连续稠密的数据处理来说空间利用率很高效,处理稀疏数据可以采取哈希表或布隆过滤器等其他数据结构
- 设置位状态
void set(size_t x)
{size_t i = x / 32;size_t j = x % 32;_a[i] |= (1 << j);
}
1.整除操作计算出x所在数组元素中索引位置i
2.取余操作计算出在数组元素中的偏移量j
3.更新位状态,将所有位左移j位,右边补0,进行或运算,确保j位置设为1
- 重置位状态
void reset(size_t x)
{size_t i = x / 32;size_t j = x % 32;_a[i] &=( ~(1 << j));
}
1.作用:用于将位集合中指定位置的位设置为 0,表示该位置被清除或处于未激活状态。
2.~(1 << j) :生成一个掩码,其中只有第 j 位为 0,其余位为 1。然后将 _a[i] 的第 j 位设置为 0,其余位保持不变。
- 检查位状态
bool test(size_t x)
{size_t i = x / 32;size_t j = x % 32;return _a[i] &(1 << j);
}
1.作用:用于检查位集合中指定位置的位是否为 1
2.1 << j :生成一个掩码,其中只有第 j 位为 1,其余位为 0。_a[i] & (1 << j) :通过按位与运算,如果 _a[i] 的第 j 位为 1,则结果非零(true);否则结果为零(false)。
处理负数
int main()
{int a1[] = { -1,2,3,3,4,4,4,4,4,2,3,6,3,1,5,5,8,9 };int a2[] = { -1,8,4,8,4,1,1,1,1 };ee::bitset<-1> bs1;ee::bitset<-1> bs2;// 去重 for (auto e : a1){bs1.set(e);}// 去重for (auto e : a2){bs2.set(e);}size_t x = -1;cout << x << endl;//寻找交集for (size_t i = 0; i < -1; i++){if (bs1.test(i) && bs2.test(i)){cout << i << " ";}}cout << endl;
}
用-1去开空间,会自动转为无符号整型即32位下每一位都是1,取值范围大概是42亿9千万。for循环中用i的值小于-1作为条件去遍历,同样会转成无符号整型。
注意:
32位系统下无符号整型是unsigned int占四字节大小,64位系统下是unsigned long long int占8字节大小,64位下占用空间太大系统可能开不出来
1.3位图的应用
3.1排序+去重
给定100亿个整数,涉及算法找到只出现一次的整数
思路:
两个位的标识总共4种情况,可以采取00表示不存在,01表示只存在一次,10表示存在多次的情况。找到只出现一次的整数就是找到01状态标识的位
- 类模板twobitset
template<size_t N>
class twobitset
{
public:
public:void set(size_t x){//将位状态00标记为01if (!_bs1.test(x) && !_bs2.test(x)){_bs2.set(x);}//01->10else if (!_bs1.test(x) && _bs2.test(x)){_bs1.set(x);_bs2.reset(x);}//10代表两次以上,不用变了}bool is_once(size_t x){//返回标记状态为01的位return !_bs1.test(x) && _bs2.test(x);}
private:bitset<N> _bs1;bitset<N> _bs2;
};
``- 测试代码
```cpp
int main()
{int a[] = { 1,2,3,3,4,4,4,4,4,2,3,6,3,1,5,5,8,9 };ee::twobitset<10> tbs;//入数据for (auto e : a){tbs.set(e);}//遍历查找位状态位01的数据for (auto e : a){if (tbs.is_once(e)){cout << e << " ";}}cout << endl;return 0;
}
同理,若要查找次数不超过2,或者超过2的所有整数,可以通过标记位状态实现,总共00,01,10,11四种状态选择。任选三种,其中只需要标记两次状态,第三种状态默认不变,可参考上文例题。然后通过判断位状态函数返回的bool值确定即可。
3.2求两个集合的交集与并集
给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
错误思路:
一个文件所有值映射到一个位图,用另一个文件判断在不在出来的交集,需要再次去重
正确思路:
用twobitset类模板,将两个文件分别映射到两个位图,对应位置与一下,要是都为1,那么这个值就是交集
- 测试代码
int main()
{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 };ee::bitset<10> bs1;ee::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;
}
2.布隆过滤器
概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
工作原理
插入操作:
当插入一个元素时,元素通过多个哈希函数映射到位数组的多个位置,这些位置被设置为1。
查询操作:
当查询一个元素时,元素同样通过多个哈希函数映射到位数组的多个位置。如果所有这些位置都是1,则判断该元素存在于集合中;否则,判断该元素不存在于集合中。
性能分析
该图是误判率p在n和m一定关系下随k变化,可以看出k越多误判率确实越低,但并不是越多越好
布隆过滤器的长度(m):
布隆过滤器的长度指的是位数组的大小。较大的位数组可以降低误判率,但会增加空间使用。
哈希函数个数(k):
每个插入的元素都会被 k 个不同的哈希函数映射到位数组中。合适的哈希函数个数可以有效平衡误判率和空间效率。但过多的哈希函数会增加计算开销。
插入元素个数(n):
插入的元素越多,位数组中被设置为1的位就越多,误判率也会随之增加。
误判率(p):
误判发生在查询一个未插入的元素时,所有哈希函数映射的位刚好都是1。这种误判的概率与位数组的填充率密切相关。
在实际中通常用m和n来计算出k,然后m和n保持一定线性关系增长即可,上述公式位理想最优情况下的计算
2.1实现
哈希函数
根据k的计算公式选择几个哈希函数
struct BKDRHash
{size_t operator()(const string& str){size_t hash = 0;for (auto ch : str){hash = hash * 131 + ch;}//cout <<"BKDRHash:" << hash << endl;return hash;}
};struct APHash
{size_t operator()(const string& str){size_t hash = 0;for (size_t i = 0; i < str.size(); i++){size_t ch = str[i];if ((i & 1) == 0){hash ^= ((hash << 7) ^ ch ^ (hash >> 3));}else{hash ^= (~((hash << 11) ^ ch ^ (hash >> 5)));}}//cout << "APHash:" << hash << endl;return hash;}
};struct DJBHash
{size_t operator()(const string& str){size_t hash = 5381;for (auto ch : str){hash += (hash << 5) + ch;}//cout << "DJBHash:" << hash << endl;return hash;}
};
基本模板
template<size_t N,class K=string,class Hash1= BKDRHash,class Hash2 = APHash,class Hash3 = DJBHash>class BloomFilter
{
public:
private:ee::bitset<N> _bs;
};
设置位状态
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);
}
计算每个哈希函数映射的哈希值,然后调用set函数设置位状态。
Hash1()(key)中Hash1()创建了一个匿名对象(临时对象),调用该匿名对象的 operator() 方法,计算 key 的哈希值。
检查位状态
bool Test(const K& key)
{size_t hash1 = Hash1()(key) % N;if (_bs.test(hash1) == false)return false;size_t hash2 = Hash2()(key) % N;if (_bs.test(hash2) == false)return false;size_t hash3 = Hash3()(key) % N;if (_bs.test(hash3) == false)return false;return true;
}
每个数据映射的位中只要有一个状位态不为1就是不在。也可能导致误判,多个不同的元素可能通过哈希函数映射到相同的位置,导致这些位置被设置为1。
关于删除
布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。即该位的标记改变,可能影响其他元素在该位的映射
一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作。
但其实这样是不值得的,因为布隆过滤器相比其他数据结构的最大优势就是插入和访问效率高,时间复杂度均为 O(k),其中 k 是哈希函数的个数。它的空间效率也极高,适用于需要处理大规模数据的场景。如果为了增加一个删除操作和丧失其最大优势有点得不偿失,一般不会用它来进行删除
- 测试代码
void TestBloomFilter()
{BloomFilter<11> bf;bf.Set("孙悟空");bf.Set("猪八戒");bf.Set("牛魔王");bf.Set("二郎神");cout << bf.Test("孙悟空") << endl;cout << bf.Test("猪八戒") << endl;cout << bf.Test("沙悟净") << endl;cout << bf.Test("二郎神") << endl;
}
2.2性能测试
void TestBloomFilter2()
{srand(time(0));const size_t N = 100000;BloomFilter<N * 4> bf;std::vector<std::string> v1;//std::string url = "https://www.cnblogs.com/-clq/archive ";std::string url = "猪八戒";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";string url = "孙悟空";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;
}
作用是测试布隆过滤器在处理相似字符串和不相似字符串时的误判率。
相似字符串误判率:由于相似字符串的前缀相同,可能更容易导致哈希冲突,因此误判率可能较高。
不相似字符串误判率:由于字符串完全不同,误判率可能较低。
同时在开的空间越大的情况下误判率越低
2.3总结
布隆过滤器优点:
- 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关
- 哈希函数相互之间没有关系,方便硬件并行运算
- 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
- 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
- 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
- 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
布隆过滤器缺陷:
- 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
- 不能获取元素本身
- 一般情况下不能从布隆过滤器中删除元素
- 如果采用计数方式删除,可能会存在计数回绕问题
2.4海量数据题思路
哈希切割
- 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法
近似算法思路;
将一个文件放到布隆过滤器,用另一个去查找在不在,会存在误判情况
精确算法思路:哈希切割
- 给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?如何找到top K的IP?
1.进行哈希切分,相同ip一定进入了相同的小文件,进行切分后内存占用不大,用map去分别统计每个小文件中出现的次数即可
2.进行哈希切分,放入堆中,读出前k个最大的键小堆,反之建大堆