本篇文章会对位图和布隆过滤器进行详解。同时还会给出位图和布隆过滤器相关的高频面试题与解答。希望本篇文章会对你有所帮助。
文章目录
🙋♂️ 作者:@Ggggggtm 🙋♂️
👀 专栏:C++、数据结构 👀
💥 标题:位图与布隆过滤器💥
❣️ 寄语:与其忙着诉苦,不如低头赶路,奋路前行,终将遇到一番好风景 ❣️
一、位图的引入
1、1 查找整数(腾讯面试题)
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中?
1、2 解决方法1
刚看到这个题目,第一感觉不就是查找数据是否存在吗!直接用二叉搜索树(set)或者哈希。但是我们不要忽略的所给的数据量。40亿的无符号整数大约占多少空间呢?1GB=1024MB=1024*1024KB=1024*1024*1024byte,也就是1GB大约是2^30字节(大约是10亿字节)。40亿个不重复的无符号整数就是160亿字节。只存储40亿个不重复的无符号整数所需要的空间大约就需要16GB,内存是存不下这么多数据的。更何况我们只是用了数组存储,那要是搜索树或者哈希呢?所需空间只会更大。所以这个方法是行不通的。
1、3 解决方法2
还有人想到:排序(O(NlogN)),利用二分查找: logN 。如果要进行排序,首先数据肯定是放在磁盘上的。放在磁盘上的数据我们只能采用外部排序。什么是外部排序呢?
1、3、1 外部排序
在磁盘上对数据进行排序可以使用外部排序(External Sort)算法。外部排序是一种处理大量数据的排序算法,适用于无法将所有数据同时加载到内存中的情况。
外部排序的基本思路是将待排序的数据分成若干个能够同时加载到内存中的块,并对每个块内的数据进行排序。然后使用归并排序(Merge Sort)等方法将排序好的块再合并起来,直到最终得到完整的有序数据集。
下面是一个基本的外部排序的步骤:
- 将待排序的数据分割成多个大小适合内存加载的块。
- 对每个块内的数据进行排序,可以选择合适的排序算法,如快速排序(Quick Sort)或堆排序(Heap Sort)等。
- 将排序好的块写回磁盘,并依次读取每个块的第一个元素到内存中。
- 将内存中的元素进行归并排序,选取最小的元素写入输出文件,并从所属块中读取下一个元素补充内存。
- 重复第4步,直到所有块都被读取完毕并排序完成。
- 合并的过程可能会产生新的块,如果超出内存限制,则需要划分新的块进行合并,直到最终得到完整的有序数据集。
需要注意的是,在进行外部排序时,会产生大量的磁盘读写操作,所以效率相对较低。但是,由于数据无法一次性加载到内存中进行排序,外部排序是一种有效的处理大规模数据的排序方法。
由于外部排序效率太慢的原因,这个方法似乎也不太合适。
这里我们就要引入我们今天所讲解的内容:位图。利用位图可以很好的解决此问题。
二、位图的原理与实现
2、1 位图的概念
位图是一种用来表示某个特定范围内元素的存在与否的数据结构。它通常使用一个bit数组来表示,其中每个bit位代表一个元素的状态,0表示不存在,1表示存在。通过改变数组中的bit位,我们可以快速地进行元素的插入、删除和查询操作。具体我们可结合下图理解:
如上图我们就可以很好的利用bit位来存储对应的数据了。
位图的优点在于空间利用率高。假设要表示的元素范围是n个,那么只需要n个bit位,相当于n/8个字节即可。因此,在内存使用上非常高效。位图主要适用于处理大量的布尔类型信息,例如集合运算、去重判断或者判断某个数据存不存在等。
利用位图来存储40亿个不重复的无符号整数需要开多大的空间呢?40亿字节大约是4GB,而我们所用到的一个字节可以存储8个bit位,也就是可以存储8个数据。那么总体下来大约是500MB的空间就可以!
2、2 位图的实现
2、2、1 位图的框架
首先,位图所需要开的空间是我们根据我们所传入的数据个数进行开辟的。那我们就可以搭出来一个大概的框架了。代码如下:
template<size_t N> class bitset { public: bitset() { //初始化空间 _bs.resize(N / 8 + 1,0); } void set(size_t n) { //将对应元素n所在的比特位设置为1。 } void reset(size_t n) { //将对应元素所在的比特位设置为0。 } bool test(size_t n) { //通过查看对应元素所在的比特位,判断其是否为1 } private: vector<char> _bs; };
2、2、2 位图的实现细节
通过上述的框架,我们也能大概知道位图的具体实现细节:
- 创建位图:需要确定位图的大小,以便能够表示集合的所有元素。一般使用一个数组来表示位图,数组的每个元素可以存储多个比特位。根据集合的大小,确定所需的数组大小,并进行初始化。
- 添加元素:将对应元素所在的比特位设置为1。
- 删除元素:将对应元素所在的比特位设置为0。
- 判断元素是否存在:通过查看对应元素所在的比特位,判断其是否为1。
添加元素、删除元素和判断元素首先就是应该找到该元素所在的bit位。查找的方法如下图:
当我们找到该元素后,再对其进行操作。我们要添加的话,就要把该位置的bit为设置成1。这是我们可使用按位或(‘ | ’)。只需要按或该bit位为1,且其他bit位为0的一个值就行了。具体代码如下:
void set(size_t n) { int i = n / 8; int j = n % 8; _bs[i] |= (1 << j); }
删除该元素, 就要把该位置的bit为设置成0。与按位或相反,我们需要使用按位与(‘ & ’)。具体的操作是:按或与该bit位为0,且其他bit位为1的一个值。具体代码如下:
void reset(size_t n) { int i = n / 8; int j = n % 8; _bs[i] &= ~(1 << j); }
判断该元素是否存在,我们只需要找到对应的bit位后,判断该bit位的值是否为1就行。与添加的思路与操作大致相同,我们直接看代码:
bool test(size_t n) { int i = n / 8; int j = n % 8; return _bs[i] & (1 << j); }
三、布隆过滤器
我们发现上述的位图只能处理整数。那要是有同样数据的字符串呢?很显然,上述的位图并不能很好的解决问题。这里我们引入布隆过滤器。
当然,用哈希表去存储大量的字符串也不行的,内存根本本存储不下的。那有没有什么方法让位图能够存储字符串呢?或者建立映射关系也不错!布隆过滤器就很好的使用映射这一点。
3、1 布隆过滤器的概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中(字符串哈希+位图)。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
3、2 布隆过滤器的原理
布隆过滤器的核心思想是将元素经过多个字符串哈希函数的映射得到多个哈希值,并在位向量中将对应位置的bit位设置为1。
我们知道是通过特定的 字符串哈希函数的映射到对应的bit位置,但是那会不会不同的字符串映射到了相同的位置呢?答案是有可能的!举例如下图:
我们假设上述的字符串:”HelloWorld“和”gtm“映射到了同一bit位上,会造成什么后果呢?同时又该怎么处理呢?
当我们查找特定字符串是否存在的时候,会不会出现如下情况:我们已经在字符串”HelloWorld“的bit位置设置成了1。但是我们并没有也不需要设置字符串”gtm“,同时又对字符串”gtm“检测查找,那这时我们并没有存储字符串”gtm“,但是查找的时候依然会存在。简单来说,当有两个字符串可以映射到同一位置时,查找就会有可能出错!
那怎么解决呢?没有办法根本解决。一个字符串只能通过多个字符串哈希函数映射多个值来减少不同字符串映射到同一位置上的情况。可结合下图理解:
也就是通过不同的字符串哈希函数映射到多个位置。两个不同的字符串同时映射到相同的三个位置概率很小。当我们检测查找时,需要通过三个值来判断。如果所有位置的bit位都为1,表示元素可能存在。当然这种情况也会存在误判,只不过是概率很小。
我们想一下:判断存在时,可能会有误。那要是判断不存在呢?判断不存在的情况是不会出现错误的。为什么呢?如果判断是bit位为0,那就毫无疑问就是不存在。有人说不是不同字符串映射到同一位置吗?对啊,是能映射到同一位置。但是与检测存在又有什么关系呢?bit位为0,该bit位就没有字符串映射到此位置,就是不存在。
布隆过滤器的一个重要特性是其会产生误判。由于多个元素可能映射到相同的位位置,查询一个元素时可能会得到错误的结果。误判率取决于哈希函数的数量、位数组的大小和插入的元素数量等因素。可以通过调整这些参数来控制误判率,在满足应用需求的前提下尽量减少误判。实例如下图:
3、3 布隆过滤器不支持删除
我们知道位图支持删除操作,就是reset。那布隆过滤器呢?如果不支持删除原因又是什么呢?
通过上述的布隆过滤器原理,我们知道为了减小不同字符串映射到同一位置上,我们采用了一个字符串采用多个字符串哈希函数进行映射多个值。假设出现如下情况:
字符串“C++”和“HelloWorld”其中有一个值映射到了同一bit位上。因为我们删除某一字符串是删除其所有映射的bit位。同时查找也是需要同时满足所有映射的之都为1。我们对其中任意一个字符串删除,都会影响到另一个字符串。
一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作。虽然这样可以支持删除操作,但是需要耗费大量空间。同时也会引入相应的问题:无法确认元素是否真正在布隆过滤器中和存在计数回绕。所以在实际中也并不实用。
3、4 布隆过滤器的模拟实现
根据我们上述所描述的原理,其实我们大概也清楚了布隆过滤器的实现原理就是字符串哈希+位图。我们看如下实现代码:
// 三个字符串哈希映射函数 struct HashBKDR { // BKDR size_t operator()(const string& key) { size_t val = 0; for (auto ch : key) { val *= 131; val += ch; } return val; } }; struct HashAP { // BKDR 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; } }; struct HashDJB { // BKDR size_t operator()(const string& key) { size_t hash = 5381; for (auto ch : key) { hash += (hash << 5) + ch; } return hash; } }; // N表示准备要映射N个值 template<size_t N, class K = string, class Hash1 = HashBKDR, class Hash2 = HashAP, class Hash3 = HashDJB> class BloomFilter { public: void Set(const K& key) { size_t hash1 = Hash1()(key) % (_ratio * N); _bits->set(hash1); size_t hash2 = Hash2()(key) % (_ratio * N); _bits->set(hash2); size_t hash3 = Hash3()(key) % (_ratio * N); _bits->set(hash3); } bool Test(const K& key) { size_t hash1 = Hash1()(key) % (_ratio * N); if (!_bits->test(hash1)) return false; // 准确的 size_t hash2 = Hash2()(key) % (_ratio * N); if (!_bits->test(hash2)) return false; // 准确的 size_t hash3 = Hash3()(key) % (_ratio * N); if (!_bits->test(hash3)) return false; // 准确的 return true; // 可能存在误判 } // 能否支持删除-> //void Reset(const K& key); private: const static size_t _ratio = 5; std::bitset<_ratio* N>* _bits = new std::bitset<_ratio* N>; };
字符串哈希映射函数是通过实验和数据证明的,想要了解的可以查询资料。我们看到上述代码并不复杂,重在思想。其中Set时还需要进行除余,是为了防止越界。
四、位图与布隆过滤器的总结
位图和布隆过滤器是在计算机科学中常用的数据结构,它们分别具有不同的优点和缺点。
位图的优点:
- 空间效率高:位图使用的是位运算,在表示大量数据时,相比于传统的数组或哈希表等数据结构,可以极大地减少所需的存储空间。
- 快速查询:由于位图只使用了0和1两种状态来表示数据的存在与否,所以查询某个元素是否存在非常快速,只需要进行简单的位运算即可。
- 支持高效操作:位图支持对多个位进行操作,如并、交、差等集合操作,可以高效地实现一些复杂的数据处理需求。
位图的缺点:
- 空间消耗:位图需要按位来存储数据,如果要表示的数据规模较大,会占用较多的内存空间。尤其是当数据不是稀疏分布时(位图所开辟的空间是根据数据的大小来开,而不是个数),并且数据范围很大时,位图会占用大量内存。
- 相对局限:只能处理整型。
布隆过滤器的优点:
- 空间效率高:布隆过滤器使用位数组和哈希函数来表示数据的存在与否,相比于传统的数据结构可以显著降低内存占用。
- 查询速度快:布隆过滤器在判断一个元素是否存在时,只需要经过一次哈希运算,可以快速地得出结果。
- 支持高吞吐量:布隆过滤器可以处理大规模的数据集合,并且具有较高的查询效率。
布隆过滤器的缺点:
- 容错性有限:由于布隆过滤器使用哈希函数来判断元素的存在与否,存在一定的哈希碰撞概率,这可能导致误判。即使一个元素不在布隆过滤器中,也有可能被误认为存在。
- 无法删除元素:一旦一个元素被加入到布隆过滤器中,就无法删除。因为删除一个元素需要改变数组中的值,可能影响到其他元素的判断结果。
- 只能对元素进行查找判断,不能很好的获取元素本身。
综上所述,位图和布隆过滤器在空间效率和查询速度上都具有优势,但位图更适用于静态或只读的数据集合,而布隆过滤器适用于需要高吞吐量并可以容忍一定误判的场景。
五、位图与布隆过滤器应用与拓展(高频面试题)
5、1 位图
给定100亿个整数,设计算法找到只出现一次的整数?
首先100亿个整数,常用的查找方法:哈希、搜索树、排序+二分都是不行的。文章开始有解释。但是这里是要查找只出现一次的整数。
一个位图只能表示存在或者不存在,但是并不能很好的记录次数。那要是两个位图呢?
具体解决思路如下:
确定整数范围:首先确定整数的范围,假设范围为0到2^32-1,即32位无符号整数。
创建位图:根据确定的整数范围,创建一个长度为2^32的位图。每个位都初始化为0。
遍历整数列表:遍历一百亿个整数的列表,对于每个整数:
- 检查该整数在两个位图中对应的位置上的值:
- 如果该位置的值为00,将其置为01。
- 如果该位置的值为10,则说明该整数已经出现过两次或更多次,表示重复出现。则不用处理。
再次遍历整数列表:再次遍历一百亿个整数的列表,对于每个整数:
- 检查该整数在位图中对应的位置上的值:
- 如果该位置的两个位图结合值为01,那么该整数只出现了一次,将其作为结果返回。
这样就可以很好的统计次数了。具体可结合如下代码理解:
template<size_t N> class twobitset { public: void set(size_t n) { //00 if (_bs1.test(n) == false && _bs2.test(n) == false) { //01 _bs2.set(n); } else if (_bs1.test(n) == false && _bs2.test(n) == true) //01 { //10 _bs1.set(n); _bs2.reset(n); } // 10表示两次及以上,不同过多处理 } void print_only_one() { for (int i = 0; i < N; i++) { if (_bs1.test(i) == false && _bs2.test(i) == true) { cout << i << endl; } } } private: bitset<N> _bs1; bitset<N> _bs2; };
给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
如果我们只有1GB的内存,但是需要在两个拥有100亿个整数的文件中找到交集,我们可以使用位图(Bitmap)的方法来解决。以下是使用位图的一种可能的解决方案:
创建位图:首先,创建位图,每个位图的大小都能够容纳100亿个整数的范围。假设每个整数都可以用一个bit表示,那么每个位图需要的内存大小为(10^9 * 8) bits = 1GB。
遍历第一个文件:逐个读取第一个文件中的整数,并将对应整数的位图位置为1,表示该整数存在。
遍历第二个文件并查找交集:逐个读取第二个文件中的整数,并检查对应整数在第一个位图中是否为1。如果是,则表示这个整数是两个文件的交集之一。
输出交集结果:根据需要,可以将找到的交集整数输出到文件或进行其他操作。
使用位图的方法可以显著减少内存使用量。但是需要注意的是,该方法的前提是整数的范围是已知的,并且可以用位图来表示。此外,由于需要逐个读取并处理整数,因此仍然可能需要较长时间来完成整个过程。
位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整 数。
这个问题的思路与第一个问题的思路一摸一样。只不过是多了统计2次(2次以上均用11来表示)以上的情况。具体不再过多解释。
5、2 布隆过滤器
给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法。
注意,query就不是整数了,就不能再使用位图了。近似算法可使用布隆过滤器,那精确算法呢?精确算法采用的哈希切分的方式。具体方法如下:
上图的主要思路就是:把大文件切分成多个小文件。在把两个编号相同的小文件依次加载到内存进行查找交集。注意:并不是均匀切分,因为我们也不知道query到底是什么,映射后进入那个文件。 如果映射后的两个小文件数据量仍然过大,那么就继续分割。
近似算法的思想是通过牺牲一定的准确性来换取更小的内存占用。常见的近似算法是Bloom Filter(布隆过滤器)。Bloom Filter是一种用于快速检索一个元素是否属于某个集合的概率型数据结构。具体操作如下:
- 创建一个长度为M的位数组,并初始化所有位为0。
- 使用K个不同的哈希函数。对于每个query,在K个哈希函数的作用下得到K个哈希值。
- 将位数组中对应的位置设为1。这样,第一个文件的query就被映射到了位数组上。
- 对于第二个文件的每个query,同样进行哈希操作,然后判断位数组对应的位置是否全部为1。如果是,则将该query添加到结果集合中。
近似算法的准确性取决于哈希函数的选择和位数组的大小。通过调整参数,可以控制误判率的大小,从而在所需内存较小的情况下找到交集。但由于牺牲了准确性,可能会有一些误判。
给一个超过100G大小的log fifile, log中存着IP地址, 设计算法找到出现次数最多的IP地址?
与上题条件相同,如何找到top K的IP?
这个题的思路与上一题的思路大致相同。都是采用了哈希切分的方法。切分成多个小文件,再一次加载到内存中。