位图与布隆过滤器
- 一,位图
- 题目分析
- 位图设计
- 位图代码
- 经典题目
- 二,布隆过滤器
- 布隆过滤器概念
- 布隆过滤器的插入
- 布隆过滤器的结构
- 布隆过滤器总结
- 经典题目
- 三,哈希切割
一,位图
题目分析
🚀给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。
🚀思路1:排序+二分查找
首先估算一下,40亿个整数的大小为16GB(10亿字节是1GB,40亿整数是4 * 10亿 * 4 字节 = 16GB)。显然,在内存中是完不成排序的,只能将这份数据分散到几个小文件中,然后利用归并排序,即使排序完成后二分查找也是非常困难的,因为二分查找是基于下标的随机访问,显然在文件中是不能随机访问的,所以只能每次局部加载一部分到内存中进行查找。可以看到经过上面的分析,过程是非常复杂且效率低的,如果是查找多个无符号整数,那这种方法更不能考虑。
🚀思路2:将数据存入到红黑树或者哈希表中进行查找
无论是哈希表还是红黑树,内部不仅要存储数据还有维护结点间关系的指针,光数据就要16GB,算上其他开销那就不仅仅是16GB了,要使用这种方法也只能是每次加载一部分进行查找,所以这种方法也不是很好。
🚀思路3:针对这个问题利用位图来解决绝对是再合适不过了,位图就是一种哈希结构,是一种直接定址法,将每个整数映射到一个固定的比特位,检查一个整数存不存在直接查看那个比特位是否为1即可,利用位图结构对于内存来说空间是绝对足够的,因为只需要42亿左右的比特位空间即可(因为无符号整数最大值为42亿多),大概只需要512MB左右的空间(40亿 / 8 = 5 亿字节 = 512 MB),并且位图结构查询效率极高,时间复杂度为O(1)。
下面,看下位图的设计。
位图设计
🚀位图最重要的三个方法:
set(将整数对应的比特位置1),
reset(将整数对应的比特位置0),
test(检测整数对应的比特位是否为1).
🚀我们用char的数组来模拟位图结构,如何定位一个整数映射到哪一个比特位呢?假设整数为N,第一步,用N / 8 得到N对应的比特位属于第几个char中。第二步,用N % 8得到N对应的比特位数据某个char中的第几个比特位。如果是用int数组模拟的话就是除32和模32。
🚀以13为例,13 / 8 = 1,说明13对应的比特位位于第1个char中(注意char是从0开始计数的),13 % 8 = 5,说明13对应的比特位是第一个char中的第五个比特位。就是上图中13指向的比特位。
template<size_t N> //N代表要开多少比特位
class bitset
{
public:
bitset()
{
_bits.resize(N / 8 + 1, 0);
}
void set(size_t N)
{}
void reset(size_t N)
{}
bool test(size_t N)
{}
private:
vector<char> _bits;
};
🚀如何将某个比特位置为1:
例如将上图中绿色格子对应的比特位置为1,在将这一位置1的同时要保证不能破坏其他位的内容,所以只要将这一位 |= 1,其它位 |= 0即可(0 |= 0 还是0,1 |= 0 还是1)。
_bits[i] |= (1 << j);
🚀如何将某个比特位置为0:
与将某个比特位置为1相反,那么用这个比特位 &= 0,其他比特位 &= 1即可(0 &= 1 = 0,1 &= 1 = 1)
_bits[i] &= (~(1 << j));
🚀检测某个比特位是否为1:
假设N对应的比特位是属于某个char的第j位,那么将这个char右移j位再& 1,如果结果是1表示N对应的比特位为1,反之为0.
((_bits[i] >> j) & 1) == 1;
位图代码
namespace gy
{
template<size_t N>
class bitset
{
public:
bitset()
{
_bits.resize(N / 8 + 1, 0);
}
void set(size_t N)
{
size_t i = N / 8;
size_t j = N % 8;
_bits[i] |= (1 << j);
}
void reset(size_t N)
{
size_t i = N / 8;
size_t j = N % 8;
_bits[i] &= (~(1 << j));
}
bool test(size_t N)
{
size_t i = N / 8;
size_t j = N % 8;
return ((_bits[i] >> j) & 1) == 1;
}
private:
vector<char> _bits;
};
}
经典题目
- 给定100亿个整数,设计算法找到只出现一次的整数?
🚀上面实现的位图结构只能判断某个整数是否出现,不能判断整数出现了几次,但是对上面的结构稍加改造即可,上面的结构是一个整数映射一个比特位,我们可以让一个整数映射2个比特位进而来记录出现的次数。
00:表示没有出现,
01:表示出现了1次,
10:表示出现了两次及以上。
所以直接复用上面的结构即可:
template<size_t N>
class twobitset
{
public:
twobitset()
{
_bs1.resize(N / 8 + 1, 0);
_bs2.resize(N / 8 + 1, 0);
}
void set(size_t N)
{
// 00->01
if (_bs1.test(N) == false && _bs2.test(N) == false)
{
_bs2.set(N);
}
//01-> 10
else if (_bs1.test(N) == false && _bs2.test(N) == true)
{
_bs1.set(N);
_bs2.reset(N);
}
}
bool test(size_t N)
{
return _bs2.test(N);
}
private:
bitset<N> _bs1; //表示较高的比特位
bitset<N> _bs2; //表示较低的比特位
};
- 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
🚀法1:将一个文件中的数据读入到位图结构中,在读取第二个文件的时候每读取一个数据就去位图中检测是否存在,如果存在说明这个数据就是交集,并且在位图中将这个整数对应的比特位置为0。(为了防止交集中出现重复的数据)
例如:第一组数据{1,3,6,8,4,7,2,5,74546,564,87};
第二组数据{2,2,5,6,7,3,3,9,9};
交集应该是{2,3,5,6,7}
如果没有将位图reset这一步的话得到的交集为{2,2,3,3,5,6,7};
🚀法2:分别将两个文件中的数据读入到两个位图中,然后遍历0-无符号整数最大值,如果某个整数N同时存在两个位图中,那么这个N就是交集,并且这种方法不会存在重复的问题。
- 位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
🚀与问题1类似,本题就是找出出现1次或者2次的整数,我们还是将一个整数映射两个比特位,
00表示没有出现
01表示出现1次
10表示出现两次
11表示出现两次以上
所以只要将上面的set和test逻辑稍做修改即可:
void set(size_t N)
{
//00->01
if (_bs1.test(N) == false && _bs2.test(N) == false)
{
_bs2.set(N);
}
//01->10
else if (_bs1.test(N) == false && _bs2.test(N) == true)
{
_bs1.set(N);
_bs2.reset(N);
}
//10->11
else if (_bs1.test(N) == true && _bs2.test(N) == false)
{
_bs1.set(N);
_bs2.set(N);
}
}
bool test(size_t N)
{
return _bs1.test(N) && !_bs2.test(N) //10
|| !_bs1.test(N) && _bs2.test(N); //01
}
二,布隆过滤器
布隆过滤器概念
🚀布隆过滤器是由布隆在1970年提出的一种紧凑的,比较巧妙的概率型数据结构,特点是高效的插入和查询,可以用来告诉你“某样东西一定不存在或者可能存在”,它是由多个哈希函数,将一个数据映射到位图结构中,此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
布隆过滤器的插入
🚀将某个字符串映射到某个比特位是不能直接实现的,因此需要使用字符串哈希函数将字符串转化为相应的整型,再将其映射到某个比特位。但是如果只是映射到某一个比特位的话,那么产生哈希冲突的概率就会很大,也就是说会产生“误判”,假设下面这种情况,hello存在的信息并没有记录在位图结构中,百度存在的信息存储在了位图结构中,但此时去检测hello是否存在时,就会发生“误判的情况”。
🚀这种“误判”是无法避免的,但是要尽量减少,所以通常一个字符串会被映射到多个比特位上(根据需而定,本文采用三个)。所以一个字符串要根据多个哈希函数转化出的多个整数来映射到多个比特位上。
布隆过滤器的结构
template<size_t N,typename K = std::string,
typename Hash1 = BKDRHash,
typename Hash2 = APHash,
typename Hash3 = DJBHash>
class BloomFilter
{
public:
void set(const K& key)
{}
void test(const K& key)
{}
private:
const int _rate = 5;
bitset<N * _rate> _bs;
};
🚀布隆过滤器的大多数使用场景都是针对字符串的,所以模板参数K给的缺省参数就是string,并且对应的三个Hash函数都是字符串Hash函数。
🚀非类型模板参数不再代表开多少比特位,而是代表要存储多少个K类型的对象,N的个数和哈希函数的个数能够推断出具开多少比特位是较为合适的,参考博客: 布隆过滤器。
template<size_t N,typename K = std::string,
typename Hash1 = BKDRHash,
typename Hash2 = APHash,
typename Hash3 = DJBHash>
class BloomFilter
{
public:
void set(const K& key)
{
size_t len = N * _rate;
size_t hash1 = Hash1()(key) % len;
size_t hash2 = Hash2()(key) % len;
size_t hash3 = Hash3()(key) % len;
_bs.set(hash1);
_bs.set(hash2);
_bs.set(hash3);
}
bool test(const K& key)
{
size_t len = N * _rate;
size_t hash1 = Hash1()(key) % len;
size_t hash2 = Hash2()(key) % len;
size_t hash3 = Hash3()(key) % len;
//有一个位置不是1就表示不存在
if (_ba.test(hash1) == false) return false;
if (_ba.test(hash2) == false) return false;
if (_ba.test(hash3) == false) return false;
return true;
}
private:
const int _rate = 5;
bitset<N * _rate> _bs;
};
🚀布隆过滤器一般是不支持删除的,因为删除一个元素时,可能会影响到其他元素。可以通过给位图结构中的比特位扩展成一个小的计数器,由原来的判断是否存在0还是1,转化为出现的次数。在插入元素的时候由哈希函数计算出的k个整型值对应的k个比特位做+1操作,相应删除时做-1操作。
如果为每个比特位增加一个引用计数的话,可能会引发计数回绕的问题。
🚀例如,由原来1个位置只有1个比特位,转化位1个位置存在3个比特位(能表示0 - 7),在插入的时候:
000->001
001->010
010->011
011->100
100->101
101->110
110->111
删除时做相反操作,这样就能支持基本的删除操作了。但这种做法仍然存在缺陷,因为判断一个元素存在是不准确的,那么代表着删除某个不存在的元素就势必会影响到真正存在的元素。
布隆过滤器总结
🚀使用布隆过滤器判断一个元素是否存在时,如果检测到一个元素不存在那么代表真的不存在,如果检测到一个元素存在是不准确的存在“误判“的情况。
🚀布隆过滤器的优点:
- 增加和查询元素的时间复杂度为O(K),K代表使用的哈希函数的个数,与数据量大小无关,效率很高。
- 哈希函数相互之间没有关系,方便硬件并行运算。
- 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
- 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
- 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
- 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
🚀布隆过滤器的缺点:
- 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)。
- 不能获取元素本身
- 一般情况下不能从布隆过滤器中删除元素
- 如果采用计数方式删除,可能会存在计数回绕问题
🚀布隆过滤器适用场景:
比如我们在注册某个网站时要输入一个昵称,电话号码,邮箱等等,往往是我们输入昵称后系统就会返回一个结果告诉我们这个昵称是否被别人使用过,这就是布隆过滤器典型的应用场景,因为如果一个昵称不存在,那就是真的不存在用户可以使用这个昵称。而,一个昵称显示存在的时候,其实它不一定真的存在但用户是感知不到的,也就是说这种场景下的误判是可接受的。
对于电话号码和邮箱这种,用户是清楚自己的手机号还有邮箱是否存在注册记录的,所以对于手机号这种出现误判是不能接受的,对于这种场景起到的是过滤的作用,如果手机号或邮箱不存在那么就是真的不存在,返回给用户电话号码或者邮箱可用。如果检测手机号或邮箱存在,那么再去数据库中查询,看是否真正的存在。可以减少数据库的访问提高效率,这种场景起到的是过滤作用。
经典题目
给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?近似算法
🚀近似算法就可以利用布隆过滤器来完成,首先将一个文件的query的数据存入一个布隆过滤器中,再依次读取出另一个文件中的query去布隆过滤器中检测是否存在,如果存在就是交集。
三,哈希切割
给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?精确算法
🚀由于是找到交集的精确算法就不能使用布隆过滤器这种位图结构来完成了。假设每个query的大小是50字节,那么100亿个query就是5000亿字节 = 500GB。500GB肯定是不能同时加载到内存中的,所以要对其进行切割,假设切割1000份每份为500MB。
如果采用平均切割的方式,那么在寻找交集的过程中就要进行暴力的匹配,任何两个Ai小文件,Bi小文件都要进行依次匹配,这样的效率的很低的。所以有人提出哈希切割这种方式,对于大文件中的每个query都先经过哈希函数进行计算,将计算的结果模1000,得到的结果就是哪个小文件的下标,对于A,B文件来说,如果是相同的query,那么经过同一个哈希函数必定会映射到相同下标的小文件中,于是只需两个下标相同的小文件求交集,最终再汇总即可。
🚀但是,由于不是平均切割,就可能会造成某个小文件的体积过大已经超过内存的大小,那么它就不能被加载到内存中,后续的工作就不能继续运行。单个小文件体积过大有两种可能:
1,文件中存在大量重复的query。
2,文件中有大量不冲突的query。
针对这两种情况,我们可以将这个小文件加载到红黑树或者哈希表中,如果在加载的过程中,出现了抛出了内存的异常,那么就表示为情况2,此时就需要换一个新的哈希函数对此小文件继续切割。如果在加载过程中并没有出现异常那么就表明为情况1,那么直接依次加载下标相同的另一个小文件的query,在红黑树或者是哈希表中查找是否存在,如果存在就是交集。
给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?与上题条件相同,如何找到top K的IP?如何直接用Linux系统命令实现?
🚀此问题与上个问题的解法类似,都是采用哈希切割的方式,100GB的文件哈希切割成若干份,但是同样会出现某个小文件的体积过大的问题:
1,文件中存在大量重复的IP。
2,文件中有大量不冲突的IP。
解决方法也是类似的,由于本题是统计出出现次数最多的IP地址,那么就将小文件加载到map中统计次数,如果加载成功没有抛出内存的异常表示为情况1,直接找出次数最多的即可。如果在加载过程中抛出内存异常,那么就需要换一个哈希函数继续切割此文件。
🚀如果是找到TopK个IP,只需建立一个大小为K的小堆即可,将每个IP出现的次数与堆顶元素比较,如果比堆顶元素大那么就进堆,这样就可以找到出现次数TopK的IP。
🚀如果用Linux指令来切割的话,使用sort指令配合uniq指令就能完成。首先使用sort对文件进行排序,在使用uniq进行去重(uniq只能处理相邻文本),uniq指令搭配-c选项使用,- c:显示出重复出现的次数,最终在使用依次sort -nr指令,
-n :依照数值大小排序,
-c:按降序方式排序。
如果是取出现次数最多的数据再配head -1 指令即可。如果是取TopK的数据 搭配head -K 即可。如果需要将得到的字符串再保存到文件中直接重定向到指定文件即可。
sort test.txt | uniq -c | sort -nr | head -1
下面是随便造的一些字符串。对上面的指令做测试使用。
🚀测试取出现次数最多的字符串
sort test.txt | uniq -c | sort -nr | head -1
🚀取出现次数前三多的数据。
sort test.txt | uniq -c | sort -nr | head -3