位图的概念
位图的引出
在谈什么是位图之前我们先来看一道"非常简单的题":有40亿个无符号的整型数据,现在给定一个目标数字,判断这个数字是否在这40亿数据中。题目看起来确实非常简单,有的同学说直接遍历一遍不就ok了吗?还有的同学给出了更高效的查找方式就是将这些数字排序然后进行二分查找。但是,这是有问题的,问题并不在于你搜索这个数字的效率问题,而是你在遍历也好排序也罢,这些数字在内存中放的下么?
一个整型int就是4个字节,10亿个int差不多已经需要4G的内存了,40亿个int就是16G。所以这里方法行不通的根本原因实际上是内存不够,但是我们今天的讲的位图却能很好的帮助我们处理这个问题。
位图模型
既然根本原因是这些数据用int放不下,那么是否有更小的东西标记这些数字呢?没错,有的同学想到了,char只占一个字节或许能表示一个数字,但是随着数字位数的增多,依旧不可能使用一个字符表示一个数字,这就意味着小于4G内存还是不能解决这个问题。
其实说到这里,我们的问题就转化为如何使用更小的内存单元来标记一个数字,而在程序中我们最小的访问单位的bit位,所以现在我们一起来看使用比特位如何标记(映射)这些数据。
现在我们发现,4个字节本来只能存储一个int,而现在使用位图我们就存了(映射)32个数字,意味着16G/32约等于500m左右我们就能映射这些数据,那么这些数据是怎么映射到位图种的呢?接着看。
位图的实现
设计位图
为了方便,我们将位图用一个数组表示,让vector帮我们开辟一段连续的空间,我们只负责将数据设置或者移除就行。
class BitMap
{
public:
BitMap(size_t range)
{
//右移5位相当于除以32,加1是因为小于32的数字如果与32相除则得到0
_bitTable.resize((range >> 5) + 1);
}
private:
vector<int> _bitTable;
};
位图元素的设置
void BitMap::SetBit(size_t x)
{
size_t index = x >> 5;
size_t num = x % 32;
_bitTable[index] |= (1 << num);
}
来看看为什么需要size_t index = x >> 5和size_t num = x % 32两步操作:我们看看要映射5和32这俩个数
5表示放在第1个整型空间的第5位上,32则表示放在第2个整型空间第一位上。而**bitTable[index] |= (1 << num)**能保证把第num位上的数字设置为1,其余数字保持不变。
位图元素的移除
void RemoveBit(size_t x)
{
size_t index = x >> 5;
size_t num = x % 32;
_bitTable[index] &= ~(1 << num);
}
比较简单,需要知道的是**~(1 << num)**表示出了num位为0,其余位都为1.
位图元素的查找
bool TestBit(size_t x)
{
size_t index = x >> 5;
size_t num = x % 32;
return _bitTable[index] & (1 << num);
}
扩展
现在将问题修改为让你寻找出40亿个数据中出现过两次的数据,此时我们就需要使用两位来标记同一个数据了。
N位位图的实现如下:
#include <iostream>
#include <vector>
using namespace std;
class NBitMap {
private:
vector<int> _bitTable;
public:
NBitMap(int range) {
// 右移4位相当于除以16,加1是因为小于16的数字如果与16相除得0
_bitTable.resize((range >> 4) + 1);
}
void SetBit(int x);
void RemoveBit(int x);
bool TestBit(int x);
};
void NBitMap::SetBit(int x) {
int index = x >> 4;
int num = x % 16;
num = num * 2;
bool first = _bitTable[index] & (1 << num);
bool second = _bitTable[index] & (1 << (num + 1));
if (!(first && second)) {
if (_bitTable[index] >> num)
_bitTable[index] |= (1 << (num + 1));
else
_bitTable[index] += (1 << num);
}
}
void NBitMap::RemoveBit(int x) {
int index = x >> 4;
int num = x % 16;
num = num * 2;
bool first = _bitTable[index] & (1 << num);
bool second = _bitTable[index] & (1 << (num + 1));
if (second) {
_bitTable[index] &= ~(1 << (num + 1));
} else {
if (first) {
_bitTable[index] &= ~(1 << num);
}
}
//~(1 << num)表示除了num位为0,其余位都为1
}
bool NBitMap::TestBit(int x) {
int index = x >> 4;
int num = x % 16;
num = num * 2;
return (_bitTable[index] & (1 << num) || _bitTable[index] & (1 << (num + 1)));
}
int main() {
NBitMap* a = new NBitMap(1000);
a->SetBit(56);
a->SetBit(56);
cout << a->TestBit(56) << endl;
a->RemoveBit(56);
cout << a->TestBit(56) << endl;
a->RemoveBit(56);
cout << a->TestBit(56) << endl;
}
以上是转载于:https://blog.csdn.net/lucky52529/article/details/90172264
如果用散列表存储这 1 千万的数据,数据是 32 位的整型数,也就是需要 4 个字节的存储空间,那总共至少需要 40MB 的存储空间。如果我们通过位图的话,数字范围在 1 到 1 亿之间,只需要 1 亿个二进制位,也就是 12MB 左右的存储空间就够了。
关于位图,我们就讲完了,是不是挺简单的?不过,这里我们有个假设,就是数字所在的范围不是很大。如果数字的范围很大,比如刚刚那个问题,数字范围不是 1 到 1 亿,而是 1 到 10 亿,那位图的大小就是 10 亿个二进制位,也就是 120MB 的大小,消耗的内存空间,不降反增。这个时候,布隆过滤器就要出场了。
布隆过滤器
布隆过滤器就是为了解决刚刚这个问题,对位图这种数据结构的一种改进。
还是刚刚那个例子,数据个数是 1 千万,数据的范围是 1 到 10 亿。布隆过滤器的做法是,我们仍然使用一个 1 亿个二进制大小的位图,然后通过哈希函数,对数字进行处理,让它落在这 1 到 1 亿范围内。比如我们把哈希函数设计成 f(x)=x%n。其中,x 表示数字,n 表示位图的大小(1 亿),也就是,对数字跟位图的大小进行取模求余。
哈希函数会存在冲突的问题,一亿零一和 1 两个数字,经过刚刚那个取模求余的哈希函数处理之后,最后的结果都是 1。位图存储的是 1 还是一亿零一了。为了降低这种冲突概率,当然我们可以设计一个复杂点、随机点的哈希函数。布隆过滤器的处理方法是,既然一个哈希函数可能会存在冲突,那用多个哈希函数一块儿定位一个数据,是否能降低冲突的概率呢?我来具体解释一下,布隆过滤器是怎么做的。
我们使用 K 个哈希函数,对同一个数字进行求哈希值,那会得到 K 个不同的哈希值,我们分别记作 X1,X2,X3,…,XK。我们把这 K 个数字作为位图中的下标,将对应的 BitMap[X1],BitMap[X2],BitMap[X3],…,BitMap[XK]都设置成 true,也就是说,我们用 K 个二进制位,来表示一个数字的存在。
当我们要查询某个数字是否存在的时候,我们用同样的 K 个哈希函数,对这个数字求哈希值,分别得到 Y1,Y2,Y3,…,YK。我们看这 K 个哈希值,对应位图中的数值是否都为 true,如果都是 true,则说明,这个数字存在,如果有其中任意一个不为 true,那就说明这个数字不存在。
布隆过滤器的误判有一个特点,那就是,它只会对存在的情况有误判。如果某个数字经过布隆过滤器判断不存在,那说明这个数字真的不存在,不会发生误判;如果某个数字经过布隆过滤器判断存在,这个时候才会有可能误判,有可能并不存在。
布隆过滤器的误判率,主要跟哈希函数的个数、位图的大小有关。当我们往布隆过滤器中不停地加入数据之后,位图中不是 true 的位置就越来越少了,误判率就越来越高了。所以,对于无法事先知道要判重的数据个数的情况,我们需要支持自动扩容的功能。此外,只要我们调整哈希函数的个数、位图大小跟要存储数字的个数之间的比例,那就可以将这种误判的概率降到非常低。
尽管布隆过滤器会存在误判,但是,这并不影响它发挥大作用。很多场景对误判有一定的容忍度。比如我们今天要解决的爬虫判重这个问题,即便一个没有被爬取过的网页,被误判为已经被爬取,对于搜索引擎来说,也并不是什么大事情,是可以容忍的,毕竟网页太多了,搜索引擎也不可能 100% 都爬取到。
那我们再来看下,利用布隆过滤器,在执行效率方面,是否比散列表更加高效呢?布隆过滤器用多个哈希函数对同一个网页链接进行处理,CPU 只需要将网页链接从内存中读取一次,进行多次哈希计算,理论上讲这组操作是 CPU 密集型的。而在散列表的处理方式中,需要读取散列值相同(散列冲突)的多个网页链接,分别跟待判重的网页链接,进行字符串匹配。这个操作涉及很多内存数据的读取,所以是内存密集型的。我们知道 CPU 计算可能是要比内存访问更快速的,所以,理论上讲,布隆过滤器的判重方式,更加快速。
布隆过滤器非常适合这种不需要 100% 准确的、允许存在小概率误判的大规模判重场景。除了爬虫网页去重这个例子,还有比如统计一个大型网站的每天的 UV 数,也就是每天有多少用户访问了网站,我们就可以使用布隆过滤器,对重复访问的用户进行去重。
考题
1、假设我们有 1 亿个整数,可能存在重复数据,且数据范围是从 1 到 10 亿,如何快速并且省内存地给这 1 亿个数据从小到大排序?
传统的做法:1亿个整数,存储需要400M空间,排序时间复杂度最优 N×log(N)
使用位图算法:数字范围是1到10亿,用位图存储125M(10亿/8/1024/1024)就够了,然后将1亿个数字依次添加到位图中,然后再将位图按下标从小到大输出值为1的下标,排序就完成了,时间复杂度为 N。重复的值,再用一个hashmap存储,key是值,value是重复次数