目录
位图
位图概念
位图其实就是哈希的变形,他同样通过映射来处理数据,只不过位图本身并不存储数据,而是存储标记。通过一个比特位来标记这个数据是否存在,1代表存在,0代表不存在。
位图通常情况下用在数据量庞大,且数据不重复的情景下判断某个数据是否存在。
例如下面这道十分经典的题目
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数
中。
关于这道题目,解法其实有很多。
1.快速排序后二分搜索。(内存可能不够,要16G内存)
2.位图处理,(40亿无符号整数用位图标记只需要512M的内存)
位图的解法差不多是这道题的最优解,只需要将所有数据读入后将对应位置置1,然后再查找那个数据所储的位置是否为1即可。
位图的应用
- 快速查找某个数据是否在一个集合中
- 排序
- 求两个集合的交集、并集等
- 操作系统中磁盘块标记
位图的实现思路
为了方便实现,位图的底层可以使用一个vector。而开空间并不根据数据的个数来开,而是根据数据的范围来开(如果开的空间不够,可能有位置无法映射到)。并且一个整型具有32个字节,所以如果我们要存N个数据,就只需要开N / 32 + 1的空间即可(+1是为了防止数据小于32和向上取整)。
当要操作一个数据时,先将其除以32来判断它应该处于数组中哪一个整型中。再对其%32,来判断它位于这个整型中的哪一个位上,此时再进行对应的位运算即可。
set
set即将对应标识位置1
可以通过将1左移pos个位置,再让对应位置与这个数据相或即可实现。
//数据的对应标识位置1
void set(size_t x)
{
//计算出在数组中哪一个整型中
size_t index = x >> 5;
//计算出在该整型的哪一个位上
size_t pos = x % 32;
//对应位置 置1
_bits[index] |= (1 << pos);
++_size;
}
reset
reset即将对应标识位置1
首先让1左移pos个位置,再对这个数据进行取反。然后让对应位置数据与这个数据相与即可。
//数据的对应标识位置0
void reset(size_t x)
{
size_t index = x >> 5;
size_t pos = x % 32;
//对应位置数据置零
_bits[index] &= ~(1 << pos);
++_size;
}
test即判断这个数据在不在,只需要让1左移pos个位置,再用对应位置进行与运算,如果为1则说明存在,0则说明不存在
test
bool test(size_t x) const
{
size_t index = x >> 5;
size_t pos = x % 32;
return _bits[index] & (1 << pos);
}
完整代码
#pragma once
#include<vector>
namespace lee
{
class bitset
{
public:
//每一个位标识一个数据,一个整型4个字节,可存储32个位, 所以需要/32(或者右移五位)。+1是为了取整
bitset(size_t size = 32)
: _bits((size >> 5) + 1, 0)
, _size(0)
{}
//数据的对应标识位置1
void set(size_t x)
{
//计算出在数组中哪一个整型中
size_t index = x >> 5;
//计算出在该整型的哪一个位上
size_t pos = x % 32;
//对应位置 置1
_bits[index] |= (1 << pos);
++_size;
}
//数据的对应标识位置0
void reset(size_t x)
{
size_t index = x >> 5;
size_t pos = x % 32;
//对应位置数据置零
_bits[index] &= ~(1 << pos);
++_size;
}
//判断数据是否存在
bool test(size_t x) const
{
size_t index = x >> 5;
size_t pos = x % 32;
return _bits[index] & (1 << pos);
}
size_t size() const
{
return _size;
}
private:
std::vector<int> _bits;
size_t _size;
};
};
布隆过滤器
我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看
过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用户看过的所有历史记
录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那些已经存在的记录。 如何快速查
找呢?
- 用哈希表存储用户记录,缺点:浪费空间
- 用位图存储用户记录,缺点:不能处理哈希冲突
- 将哈希与位图结合,即布隆过滤器
布隆过滤器概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结 构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函
数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
布隆过滤器的优缺点
优点
- 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关
- 哈希函数相互之间没有关系,方便硬件并行运算
- 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
- 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
- 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
- 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
缺点
- 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白 名单,存储可能会误判的数据)
- 不能获取元素本身
- 一般情况下不能从布隆过滤器中删除元素
- 如果采用计数方式删除,可能会存在计数回绕问题
布隆过滤器的实现思路
这里底层使用的数据结构是前面实现的位图,所以对应操作可以直接到上面看。
哈希冲突的问题
之前在哈希那一章说过,当字符串使用哈希时,无可避免的会出现哈希冲突的问题,而位图又是一个不能解决哈希冲突的数据结构,所以这就导致了一个问题,对于一个数据不能只有一个位置来标记,需要用到多个位置。于是我们需要用到多个哈希函数,来将数据映射到多个位置上面,才能确保数据的准确性。
例如下面的baidu,分别通过三种哈希函数映射到了1,4,7。将这三个位置全部置1
这里我使用了三个字符串哈希函数,分别是BKDR,SDBM,RS。
struct _BKDRHash
{
//BKDRHash
size_t operator()(const std::string& key)
{
size_t hash = 0;
for (size_t i = 0; i < key.size(); i++)
{
hash *= 131;
hash += key[i];
}
return hash;
}
};
struct _SDBMHash
{
//SDBMHash
size_t operator()(const std::string& key)
{
size_t hash = 0;
for (size_t i = 0; i < key.size(); i++)
{
hash *= 65599;
hash += key[i];
}
return hash;
}
};
struct _RSHash
{
//RSHash
size_t operator()(const std::string& key)
{
size_t hash = 0;
size_t magic = 63689;
for (size_t i = 0; i < key.size(); i++)
{
hash *= magic;
hash += key[i];
magic *= 378551;
}
return hash;
}
};
如何选择哈希函数个数和布隆过滤器长度
而如果一个数据要映射多个位置,如果布隆过滤器较小,则会导致数据马上全部映射满,此时无论进行什么操作,都会存在大量的误报率。也就是说,布隆过滤器的长度与误报率成反比,与空间利用率成反比。
并且哈希函数的个数也值得思考,哈希函数越多,映射的位置也就越多,此时准确性也就越高,但随之带来的问题就是效率的降低。也就是说,哈希函数的个数与效率成反比,准确率成正比
这张图则是各种长度以及哈希函数的效率对比图。
那么该如何选择哈希函数的个数以及布隆过滤器的长度呢?
这里引用一位大佬计算出的公式,具体求解链接我放在了文章末尾
k 为哈希函数个数,m 为布隆过滤器长度,n 为插入的元素个数,p 为误报率。
所以根据公式,我这里使用的哈希函数为3个,空间就应该开插入元素个数的五倍。
插入
数据分别映射到三个位置上,将三个位置全部置1
void set(const K& key)
{
//为了减少错误率,用多个哈希函数将同一个数据映射到多个位置
size_t pos1 = Hash1()(key) % _capacity;
size_t pos2 = Hash2()(key) % _capacity;
size_t pos3 = Hash3()(key) % _capacity;
_bs.set(pos1);
_bs.set(pos2);
_bs.set(pos3);
++_size;
}
查找
布隆过滤器的查找即分别查找映射位,一旦有任何一个为0,则说明数据不存在。如果全部为1,此时说明数据可能存在,因为可能存在将别人映射的位置误判进来,所以布隆过滤器的查找是不够准确的。所以可以这么说,布隆过滤器只提供模糊查询,如果需要精确查询,只能使用别的方法。
布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可能存在,因
为有些哈希函数存在一定的误判。
bool test(const K& key)
{
size_t pos1 = Hash1()(key) % _capacity;
size_t pos2 = Hash2()(key) % _capacity;
size_t pos3 = Hash3()(key) % _capacity;
if (!_bs.test(pos1) || !_bs.test(pos2) || !_bs.test(pos3))
{
return false;
}
return true;
}
删除
布隆过滤器是不支持删除操作的,因为一旦进行删除,很可能就会将别人映射的位置也置为0,导致出现错误。
但是如果非要删除的话,也不是不行。
可以将每一个比特位拓展为一个计数器,每当有数据插入时对应位置的计数器+1,数据删除是对应位的计数器-1。一个位肯定无法完成计数,需要用到多个位,此时就会导致存储空间的大量增加,使得效率下降,而本身选择布隆过滤器也是为了节省空间,这样就本末倒置了。
完整代码
#pragma once
#include"bitset.hpp"
#include<string>
namespace lee
{
struct _BKDRHash
{
//BKDRHash
size_t operator()(const std::string& key)
{
size_t hash = 0;
for (size_t i = 0; i < key.size(); i++)
{
hash *= 131;
hash += key[i];
}
return hash;
}
};
struct _SDBMHash
{
//SDBMHash
size_t operator()(const std::string& key)
{
size_t hash = 0;
for (size_t i = 0; i < key.size(); i++)
{
hash *= 65599;
hash += key[i];
}
return hash;
}
};
struct _RSHash
{
//RSHash
size_t operator()(const std::string& key)
{
size_t hash = 0;
size_t magic = 63689;
for (size_t i = 0; i < key.size(); i++)
{
hash *= magic;
hash += key[i];
magic *= 378551;
}
return hash;
}
};
template<class K = std::string, class Hash1 = _BKDRHash, class Hash2 = _SDBMHash, class Hash3 = _RSHash>
class BloomFilter
{
public:
BloomFilter(size_t num)
: _bs(num)
, _capacity(num)
, _size(0)
{}
void set(const K& key)
{
//为了减少错误率,用多个哈希函数将同一个数据映射到多个位置
size_t pos1 = Hash1()(key) % _capacity;
size_t pos2 = Hash2()(key) % _capacity;
size_t pos3 = Hash3()(key) % _capacity;
_bs.set(pos1);
_bs.set(pos2);
_bs.set(pos3);
++_size;
}
bool test(const K& key)
{
size_t pos1 = Hash1()(key) % _capacity;
size_t pos2 = Hash2()(key) % _capacity;
size_t pos3 = Hash3()(key) % _capacity;
if (!_bs.test(pos1) || !_bs.test(pos2) || !_bs.test(pos3))
{
return false;
}
return true;
}
size_t size() const
{
return _size;
}
private:
lee::bitset _bs;
size_t _size;
size_t _capacity;
};
};
参考文章: