🌈欢迎来到高阶数据结构专栏~~位图 & 布隆过滤器
- (꒪ꇴ꒪(꒪ꇴ꒪ )🐣,我是Scort
- 目前状态:大三非科班啃C++中
- 🌍博客主页:张小姐的猫~江湖背景
- 快上车🚘,握好方向盘跟我有一起打天下嘞!
- 送给自己的一句鸡汤🤔:
- 🔥真正的大师永远怀着一颗学徒的心
- 作者水平很有限,如果发现错误,可在评论区指正,感谢🙏
- 🎉🎉欢迎持续关注!
文章目录
- 🌈欢迎来到高阶数据结构专栏~~位图 & 布隆过滤器
- 一. 引入
- 二. 位图模型
- 三. 设计位图
- 1️⃣set标记
- 2️⃣reset取消标记
- 3️⃣test检查
- 完整代码
- 四. 扩展问题
- 🌏给定100亿个整数,设计算法找到只出现一次的整数
- 🌏给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件的交集?
- 🌏一个文件有100亿个int,1G内存,设计算法找出出现次数不超过2次的所有整数
- 😎boss:布隆过滤器
- 🥑控制误判
- ⚡具体实现
- 🎃插入
- 🎃查找
- 🎃删除
- 💥优劣分析:
- 🔥哈希切割面试题
- 📢写在最后
一. 引入
先来道面试题:
🔥给40亿个不重复的无符号整数,没排过序;给一个无符号整数,如何快速的判断这个数是否在这40亿个数中
- 40亿个无符号整数的空间是:大概是
16G
- 1️⃣搜索树和哈希表,都不太行。因为内存放不下:搜索树不仅仅只有数据还有三个指针和一个标记颜色,大小起码还要
*4
;哈希表还要2~3倍的空间 - 2️⃣排序+ 二分查找呢?效率:
log(N)
,但问题并不在于你搜索这个数字的效率问题,而是你在遍历也好排序也罢,这些数字在内存中放的下么?只能存在磁盘文件上,数据在磁盘上效率慢
- 1️⃣搜索树和哈希表,都不太行。因为内存放不下:搜索树不仅仅只有数据还有三个指针和一个标记颜色,大小起码还要
接下来引入一个boss:位图
二. 位图模型
我们表示一个数据在还是不在,只需要一个标记值就可以,不需要真正的把这个值存储起来!使用直接定值法:(一个比特位映射标记值,1就是在,0就是不在)
那么此处的空间大小是多少呢?因为开的是bit
位,一个字节有8个比特位,4g除8后只需512mb就可以完成,效率嘎嘎高
记住我们开的是范围,而不是数据的个数
三. 设计位图
为了方便,我们将位图用一个数组表示,让vector
帮我们开辟一段连续的空间,我们只负责将数据设置或者移除就行
template<size_t N>
class bitset
{
public:
bitset()
{
_bits.resize(N/8+1, 0);//永远多开一个char,防止N过小(比如10)
}
private:
vector<char> _bits;
};
1️⃣set标记
举例x
= 20 ;
在第几个char:x / 8
在这个char里的第几个比特位:x%8
void set(size_t x)
{
size_t i = x / 8;//在哪个char
size_t j = x % 8;//在char中的具体位置
_bits[i] |= (1 << j);
}
2️⃣reset取消标记
为了表达j
位为0,必须先取反,在与比较,处理后的j
位必须为0
void reset(size_t x)
{
size_t i = x / 8;//在哪个char
size_t j = x % 8;//在char中的具体位置
_bits[i] &= ~(1 << j);//先取反 再与
}
3️⃣test检查
很简单,把j
位的数据与(&)
上即可判断是否存在数据
bool test(size_t x)
{
size_t i = x / 8;//在哪个char
size_t j = x % 8;//在char中的具体位置
return _bits[i] & (1 << j);
}
完整代码
namespace ljj
{
template<size_t N>
class bitset
{
public:
bitset()
{
_bits.resize(N/8+1, 0);
}
void set(size_t x)
{
size_t i = x / 8;//在哪个char
size_t j = x % 8;//在char中的具体位置
_bits[i] |= (1 << j);
}
void reset(size_t x)
{
size_t i = x / 8;//在哪个char
size_t j = x % 8;//在char中的具体位置
_bits[i] &= ~(1 << j);
}
bool test(size_t x)
{
size_t i = x / 8;//在哪个char
size_t j = x % 8;//在char中的具体位置
return _bits[i] & (1 << j);
}
private:
vector<char> _bits;
};
}
四. 扩展问题
🌏给定100亿个整数,设计算法找到只出现一次的整数
如何设计呢?实际上是kv的统计次数搜索模型
使用两个位图来表示,满足01的就是只出现了一次的整数
0次就是 00
1次就是 01
两次及以上就是 10
template<size_t N>
class twobitset
{
public:
void set(size_t x)
{
bool inset1 = _bs1.test(x);
bool inset2 = _bs2.test(x);
//00
if (inset1 == false && inset2 == false)
{
//00 -> 01 变成01
_bs2.set(x);
}
else if (inset1 == false && inset2 == true)
{
//01 -> 10
_bs1.set(x);
_bs2.reset(x);
}
}
void print_once_num()
{
for (size_t i = 0; i < N; i++)
{
if (_bs1.test(i) == false && _bs2.test(i) == true)
{
cout << i << endl;
}
}
}
private:
bitset<N> _bs1;
bitset<N> _bs2;
};
void test_bit_set3()
{
int a[] = { 3, 4, 5, 2, 3, 4, 4, 4, 4, 12, 77, 65, 44, 4, 44, 99, 33, 33, 33, 6, 5, 34, 12 };
twobitset<100> bs;
for (auto e : a)
{
bs.set(e);
}
bs.print_once_num();
}
记过如下:
🌏给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件的交集?
两个文件中都存在的就是交集,前提是要先去重
🌏一个文件有100亿个int,1G内存,设计算法找出出现次数不超过2次的所有整数
这题与上面的类似,思路大近相同:多判断一次把10->11
,最后找不超过两次的整数
0次就是 00
1次就是 01
两次就是 10
三次及以上就是 11
void set(size_t x)
{
bool inset1 = _bs1.test(x);
bool inset2 = _bs2.test(x);
//00
if (inset1 == false && inset2 == false)
{
//00 -> 01 变成01
_bs2.set(x);
}
else if (inset1 == false && inset2 == true)
{
//01 -> 10
_bs1.set(x);
_bs2.reset(x);
}
else if(inset1 == true && inset2 == false)
{
//10 -> 11
_bs1.set(x);
_bs2.set(x);
}
}
位图特点:
- 快,节省空间(直接定值法,不存在冲突)
- 相对局限,只能映射整形
😎boss:布隆过滤器
我们现实中也遇到过:
比如王者荣耀中要新注册一个ID
的时候,你想到一个很有有趣的昵称,但此时系统告诉你 “此昵称已被注册”,这个昵称的唯一性就是运用了哈希的布隆过滤器,他本质上是就是一个 key 的模型,他只需要判断对象是否存在过就行。
此时的布隆过滤器当仁不让,布隆过滤器其实就是位图的一个变形和延申,虽然无法避免哈希冲突,但我们可以想办法降低误判的概率;当一个数据映射到位图中时,布隆过滤器会用多个哈希函数映射到多个比特位,当判断一个数据是否在位图当中时,需要分别根据这些哈希函数计算出对应的比特位,比特位设置了代表着当前状态的默认值,设置为 1 则判定为该数据存在
当然也存在误判
- 在:不准确的,存在误判
- 不在:准确的,不存在误判
🎨重点:
虽然布隆过滤器会出现误判,因为这个数据的比特位被其他数据所占有,但是判断一个数据不存在确实准确的,不存在的就是0
🥑控制误判
不可能完全去掉误判,只有尽可能的减少误判率
很显然,过小的布隆过滤器比特位很快就会都被设为 1,此时误判率就会飙升,因此布隆过滤器的长度会直接影响误判率,布隆过滤器的长度越长其误判率越小
理论而言,一个值映射的位越多,误判的概率越低,但是不敢映射太多,会造成空间消耗
大佬就此得出一个公式:
k 是哈希函数个数
m 为布隆过滤器长度
n为插入的元素个数
p为误判率
我们这里可以大概估算一下,如果使用 3 个哈希函数,那么 k 的值就为 3,ln2 的值我们取 0.7,那么 m 和 n 的关系大概是 m = 4.2 × n
,也就是过滤器长度应该是插入元素个数的 4 倍
布隆的应用:
⚡具体实现
因为插入过滤器的元素不仅是字符串,也可以是其他类型的数据,只有调用者能够提供对应的哈希函数将该类型的数据转换成整型即可,但一般情况下过滤器都是用来处理字符串的,我们布隆过滤器可以实现为一个模板类,所以这里可以将模板参数 T 的缺省类型设置为 string。
template<size_t N,
class T = string, class Hash1 = HashBKDR, class Hash2 = HashAP, class Hash3 = HashDJB>
class BloomFilter
{
public:
//...
private:
const static size_t _ratio = 5;
bitset<_ratio*N> _bits;
};
实例化布隆过滤器需要调用者提供三个哈希函数,由于布隆过滤器一般处理的是字符串类型的数据,因此这里我们可以默认提供几个将字符串转换成整型的哈希函数。
这里选取将字符串转换成整型的哈希函数,是综合评分最高的 BKDRHash、APHash 和 DJBHash,这三种哈希算法在多种场景下产生哈希冲突的概率是最小的:
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;
}
};
🎃插入
布隆过滤器当中需要提供一个 set 接口用于插入元素,插入元素时,需要通过三个哈希函数分别计算出该元素对应的三个比特位,然后在位图中设置为1
即可:
void Set(const T& key)
{
//key传给仿函数Hash1 变成整形
size_t i1 = Hash1()(key) % (_ratio * N);//数据不一定在范围里
_bits.set(i1);
size_t i2 = Hash2()(key) % (_ratio * N);
_bits.set(i2);
size_t i3 = Hash3()(key) % (_ratio * N);
_bits.set(i3);
}
🎃查找
检测时,需要通过三个哈希函数分别计算出该元素对应的三个比特位,然后判断这三个比特位是否被设置为1
思路:
- 只要有一个比特位未被设置则说明该元素一定不存在(准确的)~ 反向判断,一个为0就
false
- 如果三个比特位全部被设置,则返回 true 表示该元素存在(可能仍存在误判)
bool Test(const T& key)
{
size_t i1 = Hash1()(key) % _ratio * N;
//反向判断,一个为0就false
if (!_bits.test(i1))
return false;//准确的
size_t i2 = Hash2()(key) % _ratio * N;
if (!_bits.test(i2))
return false;
size_t i3 = Hash3()(key) % _ratio * N;
if (!_bits.test(i3))
return false;
return true;//此处的在可能存在误判
}
🎃删除
布隆过滤器一般是不支持删除的:
因为布隆过滤器判断一个元素存在时可能存在误判,此时无法保证要删除的元素确实在过滤器当中,此时将位图中对应的比特位清 0 会影响其他元素
当然也不是完全没有办法的:
- 我们只需要在每个比特位加一个计数器,当存在插入操作时,在计数器里面进行
++
操作,删除后对该位置进行--
即可
其实过滤器的本来目的就是为了提高效率和节省空间,在每个比特位增加额外的计数器,更是让空间开销飙升到本身的好几倍,空间消耗更多了,优势削弱了
💥优劣分析:
优势相当亮眼:
- 不受数据量大小影响,增加和查询元素的时间复杂度为
O(K)
,K为哈希函数的个数,一般比较小 - 布隆过滤器不需要存储元素本身,对保密要求比较严格的场合有很大优势
- 在能够承受一定的误判时,布隆过滤器比其他数据结构有着很大的空间优势
- 数据量很大时也可以表示全集,其他数据结构不能
- 使用同一组哈希函数的布隆过滤器可以进行交、并、差运算
缺点也有
- 有误判率,存在假阳性即不能准确判断元素是否在集合中(补救方法:再自建一个白名单,存储可能会误判的数据)
- 不能获取元素本身
- 一般情况下不能从布隆过滤器中删除元素
🔥哈希切割面试题
1️⃣给两个文件,分别有100亿个query,我们只有1G的内存如何找到两个文件的交集?(精确算法)
- 首先我们分析大小:每个query 30个字节, 100亿个query需要3000亿个byte,也就是300G的空间(
1G == 10亿字节
) - 重点:相同的query,是一定进入相同编号的小文件,再对这些文件放进内存的两个set中,编号相同的Ai和Bi小文件找交集即可
- 小文件不是平均分的,有些进的多,有些少,如果某个小文件过大,则要递归调用另一个哈希函数
2️⃣给超过100G大小的log file,log中存着IP地址,设计算法找出出现次数最多的IP地址?如何找到top K 的IP?
- 相等的
ip
一定会进入相同的小文件 - 依次使用
map<string,int>
对每个小文件统计次数 - topK,建一个K个值为<ip, count>的小堆
📢写在最后
女拳什么时候爬?