🔥个人主页: Forcible Bug Maker
🔥专栏: C++ || 数据结构
目录
🌈前言
本篇博客主要内容:哈希在数据存储和处理中的应用。
本篇博客大致可以分为三部分,分别对应三个知识点:位图,布隆过滤器,以及海量数据处理。话不多说,开始我们本次的内容。
🔥位图
位图的概念
位图:所谓位图,即使用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常用来判断某个数据是否存在。
下面看一道腾讯曾出过的面试题:
给40亿个不重复的无符号整数,没排过序。给定一个无符号整数,如何快速判断一个数是否在这40亿个数中。
有三种思路供参考:
- 一个一个数遍历,时间复杂度 O ( N ) O(N) O(N)
- 排序 O ( N l o g ( N ) ) O(Nlog(N)) O(Nlog(N)) ,然后利用二分查找 O ( l o g N ) O(logN) O(logN)
- 位图解决⭐
数据是否在给定的整型数据中,结果是在或者不在,刚好是两种状态,那么可以用一个二进制比特位来代表是否存在的信息,如果二进制比特位为1,代表存在,为0则代表不存在。如:
按照这样的方式进行设计,既能有 O ( 1 ) O(1) O(1)(位图也是一种哈希思想)的查找速度,又能节省空间来存下40亿个数的存在状态。
位图的实现
#pragma once
#include<vector>
namespace ForcibleBugMaker
{
template<size_t N>
class bitset
{
public:
bitset()
:_bs(N / 32 + 1, 0)
{}
// x映射的位标记为1
void set(size_t x)
{
size_t i = x / 32;
size_t j = x % 32;
_bs[i] |= (1 << j);
}
// x映射的位置标记为0
void reset(size_t x)
{
size_t i = x / 32;
size_t j = x % 32;
_bs[i] &= (~(1 << j));
}
// 判断x映射的位是0还是1
bool test(size_t x)
{
size_t i = x / 32;
size_t j = x % 32;
return _bs[i] & (1 << j);
}
private:
std::vector<size_t> _bs;
};
}
测试一下咱们的代码:
#include<iostream>
#include"MyBitSet.h"
using namespace std;
using ForcibleBugMaker::bitset;
int main()
{
bitset<UINT_MAX> bs; // 我们自己实现的可以开40多亿个
for (int i = 0; i < 20; i++)
bs.set(i);
for (int i = 5; i < 15; i++)
bs.reset(i);
for (int i = 0; i < 20; i++)
if (bs.test(i))
cout << i << " ";
cout << endl;
return 0;
}
虽然我们自己实现了bitset,但库中有原生提供的,被包在头文件#include<bitset>
里。
如果想了解细节可以取参考相关文档:std::bitset
不过库中提供的bitset有一个缺陷,因为库里面的bitset的底层是使用静态数组实现的,所以其值无法开到INT_MAX
。
如图:
库中bitset运行结果:卡了一会,什么都没打印最后崩掉。
位图的优缺点:
- 优点:增删查改非常快,同时节省空间。
- 缺点:只适用于整型。
位图的变体
题目:
一个文件中存着100亿个整数,找出出现次数不超过2次的所有整数。
既然要判断数据存在的次数,咱们可以创建两个位图,用两个比特位表示一个数据:
- 00:表示数据出现0次
- 01:表示数据出现1次
- 10:表示数据出现2次
- 11:表示数据出现3次及以上
用类实现此数据结构,嵌套之前实现的bitset:
template<size_t N>
class twobitset
{
public:
void set(size_t x)
{
bool bit1 = _bs1.test(x);
bool bit2 = _bs2.test(x);
if (!bit1 && !bit2) { // 00->01
_bs2.set(x);
}
else if (!bit1 && bit2) { // 01->10
_bs1.set(x);
_bs2.reset(x);
}
else if (bit1 && !bit2) { // 10->11
_bs2.set(x);
}
}
void reset(size_t x)
{
bool bit1 = _bs1.test(x);
bool bit2 = _bs2.test(x);
if (bit1 && bit2) { // 11->10
_bs2.reset(x);
}
else if (!bit1 && bit2) { // 01->00
_bs2.reset(x);
}
else if (bit1 && !bit2) { // 10->01
_bs1.reset(x);
_bs2.set(x);
}
}
int get_count(size_t x)
{
bool bit1 = _bs1.test(x);
bool bit2 = _bs2.test(x);
if (bit1 && bit2) { // 11
return 3;
}
else if (!bit1 && bit2) { // 01
return 1;
}
else if (bit1 && !bit2) { // 10
return 2;
}
else return 0;
}
private:
bitset<N> _bs1;
bitset<N> _bs2
};
这时候,将数据放入上面类创建的对象中,就可以快速找出出现次数不超过2次的数据了。
#include<iostream>
#include"MyBitSet.h"
using namespace std;
using ForcibleBugMaker::twobitset;
int main()
{
twobitset<100> tbs;
int arr[] = { 1,33,33,33,33,55,42,42,27,27,27,24,24,24,24,5,2 };
for (auto& e : arr) {
tbs.set(e);
}
for (int i = 0; i < 100; i++) {
int flag = tbs.get_count(i);
if (flag == 1 || flag == 2)
cout << i << ":不超过两次" << endl;
else if (flag == 3)
cout << i << ":大于两次" << endl;
}
return 0;
}
位图的应用:
- 快速查找某个数据是否在一个集合中
- 排序+去重
- 求两个集合的交集,并集等
- 操作系统中磁盘块标记
🔥布隆过滤器
布隆过滤器的概念
当我们在客户端刷视频的时候,它会不断给我们推荐新内容,在推荐之前,会经行去重操作。那么问题来了,这个去重操作是如何实现的?服务器记录了用户看过的内容的历史记录,当推荐视频时会从历史记录中进行筛选,过滤掉已经存在的记录。如何快速查找判呢?
- 用哈希表存储用户记录,缺点:浪费空间
- 用位图存储用户记录,缺点:位图一般只能处理整型,如果视频编号是字符串,就难以处理了
- 将哈希和位图结合,即布隆过滤器
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
布隆过滤器的插入
向布隆过滤器中插入字符串:hello world
再向其中插入字符串:flower
一个字符串可以同时映射多个位,从而达到降低冲突的效果。
布隆过滤器的查找
布隆过滤器的思想是将一个元素用多个哈希函数映射到一个位图中,因此被映射到的位置的比特位一定为1。所以可以按照以下方式进行查找:分别计算每个哈希值对应的比特位置存储的是否为零,只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中。
注:布隆过滤器如果判断某个元素为不存在时,该元素一定不存在;如果判断元素存在,则该元素可能存在,因为哈希函数仍然存在一定的误判,这时不可避免的。
比如:在布隆过滤器中查找"alibaba"时,假设3个哈希函数计算的哈希值为:1、3、7,刚好和其他元素的比特位重叠,此时布隆过滤器告诉该元素存在,但实该元素是不存在的。
布隆过滤器的删除
布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。
比如:删除上图中flower
元素,如果直接将该元素所对应的二进制比特位置0,hello world
元素也被删除了,因为这两个元素在多个哈希函数计算出的比特位上刚好有重叠。
一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作。
然而使用这种设计方式依然有缺陷,不能确认元素是否真正在布隆过滤器中,还可能存在计数回绕。
布隆过滤器的实现
实现布隆过滤器并不困难,不过需要实现准备好哈希函数:
// 三种不同的字符串哈希函数
struct BKDRHash
{
size_t operator()(const string& s)
{
// BKDR
size_t value = 0;
for (auto ch : s)
{
value *= 31;
value += ch;
}
return value;
}
};
struct APHash
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (long i = 0; i < s.size(); i++)
{
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ s[i] ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ s[i] ^ (hash >> 5)));
}
}
return hash;
}
};
struct DJBHash
{
size_t operator()(const string& s)
{
size_t hash = 5381;
for (auto ch : s)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
// 布隆过滤器
template<size_t N,
size_t x = 5,
class K = std::string,
class Hash1 = BKDRHash,
class Hash2 = APHash,
class Hash3 = DJBHash>
class BloomFilter
{
public:
void set(const K& key)
{
size_t hash1 = Hash1()(key) % M;
size_t hash2 = Hash2()(key) % M;
size_t hash3 = Hash3()(key) % M;
_bs.set(hash1);
_bs.set(hash2);
_bs.set(hash3);
}
bool test(const K& key)
{
bool flag1 = Hash1()(key) % M;
bool flag2 = Hash2()(key) % M;
bool flag3 = Hash3()(key) % M;
if (flag1 && flag2 && flag3)
return true;
else return false;
}
// 不支持删除,会影响其他值
void reset(const K& key);
private:
const size_t M = N * x;
bitset<M> _bs;
};
如何选择哈希函数个数和布隆过滤器长度
如果布隆过滤器的长度不够,插入几个元素就满了,不管查找什么都会返回true,过滤器的效率此时就很低。
同时要考虑哈希函数的个数,哈希函数过多可能会导致效率低下,而哈希函数过少由可能导致哈希冲突率上升。
上图中:k 为哈希函数个数,m 为布隆过滤器长度,n 为插入的元素个数,p 为误报率
如何选择合适的k和m就很重要了,先看结果:
m
=
−
n
∗
l
n
p
(
l
n
2
)
2
m = -\text{\(\frac {n*lnp} {(ln2)^2}\)}
m=−(ln2)2n∗lnp
k
=
m
n
∗
l
n
2
k = \text{\(\frac m n\)}*ln2
k=nm∗ln2
公式的推导,就使用来说并没有太大意义,这里可以简单推一下:
设k次哈希某一bit未置为1的概率是
(
1
−
1
m
)
k
(1-\text{\(\frac 1 m\)})^k
(1−m1)k
插入n个元素依然为0和为1的概率分别是
依然为0:
(
1
−
1
m
)
n
k
(1-\text{\(\frac 1 m\)})^{nk}
(1−m1)nk
依然为1:
1
−
(
1
−
1
m
)
n
k
1-(1-\text{\(\frac 1 m\)})^{nk}
1−(1−m1)nk
标明某个元素是否在集合中所需的 k 个位置都按照如上的方法设置为 1,但是该方法可能会使算法错误的认为某一原本不在集合中的元素却被检测为在该集合中(False Positives),该概率由以下公式确定(当m取无穷小时,得到右式)
[
1
−
(
1
−
1
m
)
n
k
]
k
≈
(
1
−
e
−
k
n
/
m
)
k
[1-(1-\text{\(\frac 1 m\)})^{nk}]^k\approx(1-e^{-kn/m})^k
[1−(1−m1)nk]k≈(1−e−kn/m)k
注:极限公式,当 m l i m → ∞ m\varinjlim\infin mlim∞时, ( 1 − 1 m ) m = e (1-\text{\(\frac 1 m\)})^m=e (1−m1)m=e
小结
- 优点:
- 增加和查询元素的时间复杂度为: O ( K ) O(K) O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关
- 哈希函数相互之间没有关系,方便硬件并行运算
- 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
- 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
- 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
- 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
- 缺陷:
- 有误判率有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中
- 不能获取元素本身
- 一般情况下不能从布隆过滤器中删除元素
- 如果采用计数方式删除,可能会存在计数回绕问题
🔥海量数据处理(哈希切分)
问题:
给两个文件,分别由100亿个query(查询:理解成字符串),我们只有1G内存,如何找两个文件的交集?
分析:假设每个query字符串50byte,100亿个query就是5000亿byte,约等于500G(1G约等于10亿多byte)。
这样的大小对于红黑树/哈希表来说是无能为力的。
解决方案1:
使用一个布隆过滤器,将一个文件的query放进布隆过滤器,另一个文件依次查找,在的就是交集,问题是找到的交集不够准确,因为存在的值可能是误判的,但是交集一定被找到了。
解决方案2⭐:
哈希切分,首先内存访问速度远远大于硬盘,大文件放到内存搞不定,我们可以考虑将大文件切成小份文件,再放入内存处理。
切分不能平均切分,因为平均切分以后,每个小文件都要依次进行暴力处理,效率还是会很低。
哈希切分,是依次读取文件中的query,i = HashFunc(query) % N
,N为准备切分成的小文件的个数,使内存得以放下,query放进第i号小文件,这样A和B中相同的query算出的哈希值是一样的,就能放到相同的小文件当中,效率就提升了。
本质是相同的query在哈希切分的过程中,一定进入同一个小文件的Ai和Bi,不可能出现A中的query进入Ai,但是B中相同的query进入Bj的情况,所以对Ai和Bi求交集即可,不需要Ai和Bj求交集。(其中i和j是不同的整数)
哈希切分的问题是,每个小文件是不均分的,可能会导致某个小文件很大内存放不下。导致这种情况的原因有二:
- 小文件中大部分是同一query
- 这个小文件由很多不同的query组成(本质为哈希冲突过多)
针对问题一,我们可以将小文件的query放入set,进行去重。针对问题二,需要我们再换一个哈希函数对小文件进行二次切分。
所以当我们遇到大于1G的小文件时,可以放入set中找交集,如果set的insert抛出了异常(申请内存失败),那么就说明放不下的原因是情况二,对小文件进行二次切分后再对应找交集。
🌈结语
哈希的三个应用,分别是位图,布隆过滤器,以及海量数据处理。位图能以少量的空间快速存放数据的存在情况;布隆过滤器中需要注意k,m和n之间合适的比例关系;最后海量数据处理使用哈希切分的方式,将大文件分割成小文件,从而使计算机能够对其进行处理。本篇博客的内容到这里就结束了,感谢大家的支持♥