目录
[C++ 提供的 bitset](#C++ 提供的 bitset)
本篇文章我们主要介绍哈希思想的应用,两个很实用的数据结构:位图 & 布隆过滤器
前置知识:
位图引入
问题:给 40 亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。
方法一:暴力查找,时间复杂度为 O(N)
方法二:先排序 + 二分查找,时间复杂度是 O(N * logN),还有致命问题是无法一次性加载到内存, 1G大约是10亿字节,40亿整数所占空间大约是 16G,内存不够存储,也就无法排序
方法三:位图
判断一个数在不在,只需要两个状态:在 / 不在,因此只需要用1个比特位就可以表示该数在不在,1(真)表示存在,0(假)表示不存在,那么就可以将 40亿 个整数依次映射到相应的比特位,而无符号整数的最大值是42亿多,映射的最大比特位也就是 42亿,42亿个比特位,大约需 0.5 G即可
位图是一种用于表示和管理一组二进制位(0或1)的数据结构,通常用于表示一组布尔值或者处理大量的布尔标志,可以高效地利用内存
位图实现
我们使用一个整形数组就可以实现位图,并且使用非类型模版参数在编译时就确定位图大小

cpp
namespace dck
{
template<size_t N> //非类型模版参数, N表示比特位个数
class bitset
{
public:
bitset()
{
_bits.resize(N / 32 + 1); // "/"是向下取整, 如果N不是32的整数倍, N / 32 空间就小了
}
private:
vector<int> _bits;
};
}
将x对应的比特位置为1 :
cpp
void set(size_t x)
{
size_t i = x / 32; //第i个整数
size_t j = x % 32; //第i个整数的第j个比特位
_bits[i] |= (1 << j);
}
将x对应的比特位置清0 :
cpp
void reset(size_t x)
{
size_t i = x / 32;
size_t j = x % 32;
_bits[i] &= ~(1 << j);
}
判断 x 在不在:
cpp
bool test(size_t x)
{
size_t i = x / 32;
size_t j = x % 32;
return _bits[i] & (1 << j);
}
位图实现代码及测试:
cpp
#include <iostream>
#include <vector>
using namespace std;
namespace dck
{
template<size_t N> //非类型模版参数, N表示比特位个数
class bitset
{
public:
bitset()
{
_bits.resize(N / 32 + 1);
}
void set(size_t x) //将x对应的比特位置1
{
size_t i = x / 32;
size_t j = x % 32;
_bits[i] |= (1 << j);
}
void reset(size_t x) //将x对应的比特位清0
{
size_t i = x / 32;
size_t j = x % 32;
_bits[i] &= ~(1 << j);
}
bool test(size_t x) //检测x在不在
{
size_t i = x / 32;
size_t j = x % 32;
return _bits[i] & (1 << j);
}
private:
vector<int> _bits;
};
}
int main()
{
dck::bitset<100> bt;
bt.set(5);
bt.set(33);
bt.set(38);
bt.set(25);
cout << bt.test(28) << endl; //0
cout << bt.test(38) << endl; //1
bt.reset(38);
cout << bt.test(28) << endl; //0
cout << bt.test(38) << endl; //0
return 0;
}
C++ 提供的 bitset
cpp
#include <iostream>
#include <bitset>
using namespace std;
int main()
{
bitset<8> bt;
bt.set(0);
bt.set(5);
bt.set(2);
bt.set(4);
cout << bt.count() << endl; //4, 位图中有多少个1被设置
cout << bt.size() << endl; //8, 位图大小(一共有多少个比特位)
bt.reset(2);
cout << bt.count() << endl; //3
cout << bt << endl; //00110001
bt[3] = 1; //将3位置比特位设置为1
cout << bt << endl; //00111001
cout << bt.test(3) << endl; //1, test(pos) 检测pos位置有没有被设置为1
bt[3] = 0;
cout << bt.test(3) << endl; //0
cout << bt.any() << endl; //1, 是否有比特位被设置了
cout << bt.none() << endl; //0, 是否没有比特位被设置
cout << bt.all() << endl; //0, 是否所有比特位都被设置了
cout << bt[2] << endl; //0
bt.flip(2); // 翻转比特位
cout << bt[2] << endl; //1
return 0;
}
位图拓展应用
问题一:给定 100 亿 个整数,设计算法找到只出现一次的整数
每个数的出现情况只有三种:0次 / 1次 / 2次及以上,因此每个数只需要两个比特位便可以表示这三种状态,00表示出现0次,01表示出现1次,10表示出现两次及以上,我们只需要两个位图即可
问题二:1个文件有100亿个 整数,1G内存,设计算法找到出现次数不超过2次的所有整数
与问题一解法一样,只是问题二每个树的出现情况分为4种,也是用2个比特位就可以表示,00表示出现0次,01表示出现1次,10表示出现两次,11表示出现超过2次,只需两个位图即可
问题二代码:(问题一代码类似)
cpp
namespace dck
{
template<size_t N>
class twobitset
{
public:
void set(size_t x)
{
if (_bs1.test(x) == false && _bs2.test(x) == false) //00 -> 01
{
_bs2.set(x);
}
else if (_bs1.test(x) == false && _bs2.test(x) == true) //01 -> 10
{
_bs1.set(x);
_bs2.reset(x);
}
else if(_bs1.test(x) == true && _bs2.test(x) == false) //10 -> 11
{
_bs2.set(x);
}
}
void print()
{
for (size_t i = 0; i < N; i++)
{
if (_bs1.test(i) == 0 && _bs2.test(i) == 0)
{
cout << "出现0次: " << i << endl;
}
else if (_bs1.test(i) == 0 && _bs2.test(i) == 1)
{
cout << "出现1次: " << i << endl;
}
else if (_bs1.test(i) == 1 && _bs2.test(i) == 0)
{
cout << "出现2次: " << i << endl;
}
else
{
cout << "出现2次及以上: " << i << endl;
}
}
}
private:
dck::bitset<N> _bs1;
dck::bitset<N> _bs2;
};
}
int main()
{
dck::twobitset<10> bt;
bt.set(7);
bt.set(2);
bt.set(5);
bt.set(3);
bt.set(2);
bt.set(7);
bt.set(1);
bt.set(4);
bt.set(3);
bt.set(9);
bt.set(7);
bt.print();
return 0;
}
布隆过滤器引入
位图的缺陷是 只能解决整数 在不在及扩展问题,对于字符串在不在的问题无法很好处理,因为位图是用 整数 / 32 以及 整数 % 32 直接定址的,每一个比特位固定对应了一个整数。
判断字符串在不在依旧采用哈希思想使用位图,我们需要先要采用哈希算法将字符串映射成一个对应的整数,然后再去定址,整数数量是有限的,无符号整数一共也就42亿多个,但字符串数量有无限多个(不考虑内存上限),那么必然会有不同的字符串最终映射到了同一位置,也就是产生"哈希冲突",由于哈希冲突的存在,就可能存在"误判"的情况
● 判断结果为"不在":没有误判,一定准确
● 判断结果为"在":可能会误判,也就是字符串本身不存在,但是判定为存在

为啥判断结果为"在"可能是误判呢??? 假设字符串A和字符串B映射到了同一位置,字符串A不存在,而字符串B存在,那么判定字符串A是否存在时,计算映射位置,发现该位置的值是 1,判定为存在,就发生了误判。
由于必然存在哈希冲突,所以误判是完全无法避免的,但我们是有办法降低误判的!!
**例如:**同学A说"我喜欢学习",他说谎的概率是%50,同学B说同学A没有说谎,这也无法说明同学A没有说谎,但同学A说谎的概率会降低,如果还有同学C说同学A没有说谎,那么同学A说谎的概率会进一步降低
一个字符串映射到一个位置误判的概率是比较高的,而如果让一个字符串映射到多个位置,那么误判概率就会降低不少。

字符串A映射到 p1 / p2 / p3 三个位置,字符串B映射到 p2 / p3 / p4 三个位置,假如字符串A存在,字符串B不存在,现在要判定字符串B是否存在,先使用哈希函数求出 p2 / p3 / p4 三个位置,发现 p2 和 p3 位置的比特位都是1,但是 p4 位置的比特位是0,因此结果判定为不存在!
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概
率型数据结构,特点是高效地插入和查询,可以用来告诉你 "某样东西一定不存在或者可能存
在",它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也
可以节省大量的内存空间

布隆过滤器实现
cpp
#include "bitset.h"
struct BKDRHash
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto e : key)
{
hash *= 31;
hash += e;
}
return hash;
}
};
struct APHash
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (size_t i = 0; i < key.size(); i++)
{
char ch = key[i];
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ ch ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ ch ^ (hash >> 5)));
}
}
return hash;
}
};
struct DJBHash
{
size_t operator()(const string& key)
{
size_t hash = 5381;
for (auto ch : key)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
template<size_t N, class K = string, class HashFunc1 = BKDRHash, class HashFunc2 = APHash, class HashFunc3 = DJBHash>
class BloomFilter
{
public:
void Set(const K& key)
{
size_t hash1 = HashFunc1()(key) % N;
size_t hash2 = HashFunc2()(key) % N;
size_t hash3 = HashFunc3()(key) % N;
_bs.set(hash1);
_bs.set(hash2);
_bs.set(hash3);
}
//一般不支持删除, 因为某个key的删除会影响其他key, 非要删除可以采用引用计数实现
void Erase() {}
bool Test(const K& key)
{
size_t hash1 = HashFunc1()(key) % N;
if (_bs.test(hash1) == false)
return false;
size_t hash2 = HashFunc2()(key) % N;
if (_bs.test(hash2) == false)
return false;
size_t hash3 = HashFunc3()(key) % N;
if (_bs.test(hash3) == false)
return false;
return true; //困难存在误判
}
private:
dck::bitset<N> _bs;
};
测试布隆过滤器:
cpp
void TestBF1()
{
BloomFilter<100> bf;
bf.Set("猪八戒");
bf.Set("沙悟净");
bf.Set("孙悟空");
bf.Set("二郎神");
cout << bf.Test("猪八戒") << endl; //1
cout << bf.Test("沙悟净") << endl; //1
cout << bf.Test("孙悟空") << endl; //1
cout << bf.Test("二郎神") << endl; //1
cout << bf.Test("二郎神1") << endl; //0
cout << bf.Test("二郎神2") << endl; //0
cout << bf.Test("二郎神 ") << endl; //0
cout << bf.Test("太白晶星") << endl; //0
}
void TestBF2()
{
srand(time(0));
const size_t N = 100000;
BloomFilter<N * 10> bf; //开的空间越大, 误判率越低
std::vector<std::string> v1;
//std::string url = "https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html";
std::string url = "猪八戒";
for (size_t i = 0; i < N; ++i)
{
v1.push_back(url + std::to_string(i));
}
for (auto& str : v1)
{
bf.Set(str);
}
// v2跟v1是相似字符串集(前缀一样),但是不一样
std::vector<std::string> v2;
for (size_t i = 0; i < N; ++i)
{
std::string urlstr = url;
urlstr += std::to_string(9999999 + i);
v2.push_back(urlstr);
}
size_t n2 = 0;
for (auto& str : v2)
{
if (bf.Test(str)) // 误判
{
++n2;
}
}
cout << "相似字符串误判率:" << (double)n2 / (double)N << endl;
// 不相似字符串集
std::vector<std::string> v3;
for (size_t i = 0; i < N; ++i)
{
//string url = "zhihu.com";
string url = "孙悟空";
url += std::to_string(i + rand());
v3.push_back(url);
}
size_t n3 = 0;
for (auto& str : v3)
{
if (bf.Test(str))
{
++n3;
}
}
cout << "不相似字符串误判率:" << (double)n3 / (double)N << endl;
}
int main()
{
TestBF1();
TestBF2();
return 0;
}
布隆过滤器应用
布隆过滤器对于"在不在"可能会误判,而有些场景是容许误判的存在的!

上述在网页中注册昵称的时候就可以用布隆过滤器进行过滤,如果判定为昵称不在,那就一定不在,就不用再去服务器中查找了,但是如果判定为在,可能是误判了,仍要去服务器中查找,但起码过滤了部分数据,网络请求的代价还是很高的,布隆过滤器可以提高效率
哈希切割
场景一: 给两个文件,分别有100亿个query,假设一个 query 50个字节,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法
思路:
100个 query 所占大小为 5000亿个字节,也就是 500个G,显然内存是存不下的,因此无法直接将两个文件全部读取到内存中!
既然文件太大无法直接读到内存,那么我们可以先将大文件切分成小文件,而切分的方法采用的是哈希切割,也就是对于每个 query 字符串,进行哈希映射,决定 query 进入哪个小文件,文件A和文件B中相同的query进入的必然是同一个下标的文件(采用的是同样的哈希函数),因此求交集的时候只需要求 A0 和 A1 的交集,B0 和 B1的交集... 等即可,每次只需要将 A0 & B0 加载到内存,使用 两个set 求交集即可,A1 & B1 依次类推...

问题是如果小文件也太大了呢?此时分两种情况
a. 这个小文件中大多数都是 1 个 query
b. 这个小文件中有很多不同的 query
我们的做法是:不管文件大小,直接将两个小文件读取到内存中,插入到各自的set中,如果是情况a,尽管文件很大,但是有很多重复,那么后续插入重复的值就会失败,可以在内存中完成操作;如果是情况b,不断插入set,内存不足会抛异常,需要换一个哈希函数,进行二次切分,再找交集
场景二:给一个超过100G大小的 log file,log中存着IP地址,设计算法找到出现次数最多的IP地址?与上题条件相同,如何找到 top K的 IP?
思路:
与场景一思路一样,依旧采用哈希切割将大文件切割成多个小文件,相同的 IP地址必然进入了同一个小文件,此时再将小文件加载到内存,使用 unordered_map 统计次数即可
如果是求 top K 的 IP,也采用哈希切割,将小文件加载到内存中 使用 堆求解即可;当然我们也可以不用哈希切割,直接 采用 解决 Top-K 问题的 建堆方法即可