目录
一、位图
1.1 位图的概念
1.2 位图的实现
1.3 位图的应用(面试题)
二、布隆过滤器
2.1 布隆过滤器的引入
2.2 布隆过滤器概念
2.3 布隆过滤器的插入和查找
2.4 布隆过滤器的实现
2.5 布隆过滤器的优点和缺陷
2.6 布隆过滤器的应用(面试题)
一、位图
1.1 位图的概念
在C++中,位图(Bitmap)是一种数组,其中数组的每个元素可以表示多个布尔值。用于高效地存储和查询海量数据,其中元素的每个布尔值只占用一个位(bit)的空间。通常是用来判断某个数据存不存在的。
例如在32位系统中,一个unsigned int类型的变量可以表示32个布尔值。通过位操作,我们可以检查、设置或清除特定的位,从而实现对大量布尔值的快速访问和修改。
面试题
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在
这40亿个数中。【腾讯】方法:
1. 遍历,时间复杂度O(N)
2. 排序(O(NlogN)),利用二分查找: logN
3. 位图解决方法1和2中都需要将数据保存在数组中,40亿个整数需要16G内存,内存开辟不了这么大的连续空间。
数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一
个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0
代表不存在。比如:40亿个不重复的无符号整数的范围在0 ~ 2^32,有42亿多种可能性。一个字符型8个比特位,每个比特位来存储一个数字是否存在的状态,需要(2^32) / 8 = 2^29字节,即0.5G的内存。
1.2 位图的实现
C++标准库中的位图在线文档说明 #include <bitset>
在C++中,位图的实现通常使用unsigned int数组或者std::vector<unsigned int>。下面是一个简单的位图实现:
// N是需要多少比特位
template<size_t N>
class myBitSet
{
public:
myBitSet()
{
_bs.resize((N >> 5) + 1, 0);//一个size_t 4个字节,32个比特位,右移五位相当于除以32,+1是防止有余数
//注意:右移运算符>>优先级高于+,需要在右移时加上括号
}
void set(size_t x)//对第x个比特位置为1
{
size_t i = x / 32;//确定在哪一个size_t
size_t j = x % 32;//确定在哪一个比特位上
_bs[i] |= (1 << j);
}
void reset(size_t x)//第x个比特位置清0
{
size_t i = x / 32;//确定在哪一个size_t
size_t j = x % 32;//确定在哪一个比特位上
_bs[i] &= ~(1 << j);
}
bool test(size_t x)//返回第x位的状态
{
size_t i = x / 32;//确定在哪一个size_t
size_t j = x % 32;//确定在哪一个比特位上
return _bs[i] & (1 << j);
}
private:
vector<size_t> _bs;
};
1.3 位图的应用(面试题)
1. 快速查找某个数据是否在一个集合中
2. 排序 + 去重
3. 求两个集合的交集、并集等
4. 操作系统中磁盘块标记
1. 给定100亿个整数,设计算法找到只出现一次的整数?
100一个整数,它的范围也是只在0 ~ 2^32 之间,可以设计两个位图,每一次映射都调整位图里面的数字。例如出现0次设为00,出现1次设为01,二次及以上都为10。
template<size_t N> class twobitset { public: void set(size_t x) { //00->01 //01->10 //10->不变 if (_bs1.test(x) == false && _bs2.test(x) == false)//出现0次 { _bs2.set(x);//新增设为01 } else if (_bs1.test(x) == false && _bs2.test(x) == true)//出现1次 { _bs1.set(x); _bs2.reset(x);//新增设为10 } //出现2次及以上 } void PrintOnce() { for (size_t i = 0; i < N; i++) { if (_bs1.test(i) == false && _bs2.test(i) == true) { cout << i << endl; } } cout << endl; } private: myBitSet<N> _bs1; myBitSet<N> _bs2; };
2. 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
各自映射到一个位图,若一个值在两个位图都存在,则是交集
3. 位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数。
方法同问题1,创建两个位图,出现0次00,出现1次01,出现2次10,出现3次及以上 11。
二、布隆过滤器
2.1 布隆过滤器的引入
之前的搜索方法中,传统的数据结构和方法存在一定的局限性:
- 暴力查找:数据量大了,效率就低。O(n)
- 排序+二分查找:排序有代价、数组不方便增删。虽然查找的时间复杂度为 O(log n),但排序的时间复杂度为 O(n log n)
- 搜索树 ->AVL树+红黑树:随着数据量的增加,树的高度也会增加,导致性能下降。
- 哈希:当发生哈希碰撞时,性能会下降。此外,哈希表的空间消耗相对较高。
- 以上数据结构,空间消耗很高。如何应对数量很大的数据?
- 位图及变形:位图只能处理整数,对于其他类型的数据则不适用。
如果要应对其他类型数据的问题,如何快速查找?
- 用哈希表存储用户记录,缺点:浪费空间
- 用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理了。
- 将哈希与位图结合,即布隆过滤器。
2.2 布隆过滤器概念
布隆过滤器(Bloom Filter)是一种由布隆在1970年提出的概率型数据结构,它通过使用多个哈希函数将元素映射到一个 bit 数组中来实现高效的数据查询和插入。布隆过滤器的核心优势在于它的空间效率:它使用相对较少的内存来表示一个可能非常大的集合。可以用来告诉你 “某样东西 一定不存在 或者 可能存在”。
布隆过滤器的特点包括:
- 高效插入和查询:插入和查询操作都非常快速,因为它们只涉及到哈希计算和位操作。
- 概率性:布隆过滤器可能会返回误报(即一个元素被错误地判断为存在于集合中),但不会返回漏报(即如果一个元素被判断为不存在,那么它一定不存在)。
- 固定大小:布隆过滤器的位数组大小在创建时确定,不会随着元素的添加而改变。
- 不可删除:一旦元素被插入布隆过滤器,就无法删除,因为删除可能会导致其他元素的位被错误地设置为0。(传统布隆过滤器不可删除)
举个例子:原本在位图或哈希表/桶中,可能出现多个关键字映射到同一个位置,现在使用多个哈希函数,让原本映射到一个位置的结果映射到多个位置。检查关键字在不在,要把关键字映射的所有位置都检查一遍,只有所有位置的状态都为1,这个关键字才 “可能存在”,如果映射的位置有一个检测到了0,那么这个关键字肯定不存在。
详解布隆过滤器的原理,使用场景和注意事项
上面链接的讲解非常精彩,有兴趣的话可以看一下。
2.3 布隆过滤器的插入和查找
布隆过滤器是一个 bit 向量或者说 bit 数组
如果我们要映射一个值到布隆过滤器中,我们需要使用多个不同的哈希函数生成多个哈希值,并对每个生成的哈希值指向的 bit 位置为 1,例如针对值 “baidu” 和三个不同的哈希函数分别生成了哈希值 1、4、7,则上图转变为:
在再存一个值 “tencent”,如果哈希函数返回 3、4、8 的话,图继续变为:
值得注意的是,4 这个 bit 位由于两个值的哈希函数都返回了这个 bit 位,因此它被覆盖了。现在我们如果想查询 “dianping” 这个值是否存在,哈希函数返回了 1、5、8三个值,结果我们发现 5 这个 bit 位上的值为 0,说明没有任何一个值映射到这个 bit 位上,因此我们可以很确定地说 “dianping” 这个值不存在。而当我们需要查询 “baidu” 这个值是否存在的话,那么哈希函数必然会返回 1、4、7,然后我们检查发现这三个 bit 位上的值均为 1,那么我们可以说 “baidu” 存在了么?答案是不可以,只能是 “baidu” 这个值可能存在。
这是为什么呢?答案跟简单,因为随着增加的值越来越多,被置为 1 的 bit 位也会越来越多,这样某个值 “taobao” 即使没有被存储过,但是万一哈希函数返回的三个 bit 位都被其他值置位了 1 ,那么程序还是会判断 “taobao” 这个值存在。
2.4 布隆过滤器的实现
各种字符串Hash函数
选取3种平均分较高的哈希算法:BKDRHash,APHash,DJBHash。
#include "myBitSet.h"
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 myBloomFilter
{
public:
void set(const K& key)
{
size_t hash1 = HashFunc1()(key) % N;
size_t hash2 = HashFunc2()(key) % N;
size_t hash3 = HashFunc3()(key) % N;
_bs.set(hash1);
_bs.set(hash2);
_bs.set(hash3);
}
// 一般不支持删除,删除一个值可能会影响其他值
// 非要支持删除,也是可以的,用多个位标记一个值,存引用计数
// 但是这样话,空间消耗的就变大了
void reset(const K& key);
bool test(const K& key)
{
//判断不存在是准确的
size_t hash1 = HashFunc1()(key) % N;
if (_bs.test(hash1) == false)
return false;
size_t hash2 = HashFunc2()(key) % N;
if (_bs.test(hash2) == false)
return false;
size_t hash3 = HashFunc3()(key) % N;
if (_bs.test(hash3) == false)
return false;
return true;//有可能误判
}
private:
myBitSet<N> _bs;
};
2.5 布隆过滤器的优点和缺陷
布隆过滤器优点
1. 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关
2. 哈希函数相互之间没有关系,方便硬件并行运算
3. 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
4. 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
5. 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
6. 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
布隆过滤器缺陷
1. 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
2. 不能获取元素本身
3. 一般情况下不能从布隆过滤器中删除元素
4. 如果采用计数方式删除,可能会存在计数回绕问题
2.6 布隆过滤器的应用(面试题)
布隆过滤器使用场景:可以接受误判的场景
1. 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法。
query 是字符串,假设平均1个query是50byte,1G 约等于10亿byte,100亿个query就是5000亿byte ,约等于 500G,内存根本存不下。
500G的文件存放不下,可以将它们拆分成多个小文件,例如500个小文件a0~a499,每个文件大小就是1G。读取第一个文件的每个query,使用hash函数将query映射到500个文件中(计算 i = Hash(query) % 500),i 是几,query就进入第几个小文件。
两个文件中相同的query分别进入了编号相同的小文件 ai 和 bi。
两个大文件对应的编号相同的小文件ai 和 bi求交集,可以分别插入到一个setA和一个setB中,快速找到交集。
但是这样也可能有其他问题,例如某个文件可能太大,原因:
- 小文件中大多数都是某一个query,重复过多。
- 小文件有很多不同的query。
如果是情况1,文件很大有很多重复,后面重复播入都失败,可以插入到set,set可以去重。如果是情况2,不断插入set以后,内存不足,会抛异常,需要换一个哈希函数进行二次切分,再找交集。
近似算法(布隆过滤器)
1. 创建布隆过滤器:由于1G内存大约可以表示10亿字节,即约80亿bit,我们可以创建一个布隆过滤器,使用足够多的哈希函数(例如4到6个),以保持较低的误报率。2. 插入第一个文件的query:遍历第一个文件中的每个query,对每个query应用哈希函数,并将对应位设置为1。
3. 查询第二个文件的query:遍历第二个文件中的每个query,同样应用哈希函数。如果布隆过滤器中的所有对应位都是1,则认为该query可能出现在第一个文件中。
4. 输出可能交集:将所有可能出现在交集中的query输出到新文件中。
5. 误报处理:由于布隆过滤器的性质,输出文件中可能包含一些误报,即实际上并不在第一个文件中的query。可以通过调整布隆过滤器的参数来减少误报率。
精确算法(哈希分割)
1文件分割:对两个文件的query进行哈希处理,然后使用除留余数法(%5000)将query分布到5000个不同的子文件中。每个子文件的大小大约为100M,这样每个文件的总大小就是5000 * 100M = 500G,符合原始数据的大小。2.读取和比较:对于第二个文件中的每个query,同样进行哈希处理,然后将query放入对应的子文件中。接着,对于每个子文件,将其内容读取到内存中的unordered_map中,然后查找第一个文件中的对应子文件,看是否有相同的query。
3.输出交集:如果在一个子文件中找到了匹配的query,那么这个query就属于两个文件的交集。将这些query输出到新文件中。
4.内存使用:由于每个子文件是独立处理的,所以每次只需要将一个子文件的内容读取到内存中,这样内存的使用就被限制在了100M左右,符合1G内存的限制。
精确算法的关键在于将大文件分割成多个小文件,然后逐个处理,这样可以避免一次性将所有数据加载到内存中。这种方法虽然准确,但是处理时间可能会比近似算法长,因为需要多次读写磁盘。
2. 给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?
与上一题同理,i= Hash(ip) % 100,这个ip就进入Ai小文件。相同的ip一定进入了同一个小文件,我们对小文件用map统计出ip次数就是准确的。