目录
前言
1.位图
1.1.位图的实现与原理
1.2.如何使用位图处理海量数据
2.布隆过滤器
2.1.知识引入
2.2.布隆过滤器的实现
2.3.布隆过滤器的应用
3.哈希切割
前言
我们在之前对哈希表的学习,明白了哈希的本质就是一种映射!!!只要是实现了映射关系我们就能够说这个是“哈希”。
哈希:通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
在我们实际开发中,大部分场景是处理海量的数据,例如在博客中3.3中提及的如何在40亿数据中判断某个数据在不在的问题。因为哈希映射关系的这个思路,我们最终就可以通过更小的比特位来实现数据之间的映射。C++学习进阶:二进制与位运算-CSDN博客
1.位图
1.1.位图的实现与原理
位图(Bitmap)通常用于表示一个二进制数据的集合,其中每个位可以独立地设置或清除。位图特别适用于需要快速检查某个元素是否存在于集合中的情况,因为每个元素的状态可以通过单个位来表示,这通常比使用其他数据结构(如数组或链表)更节省空间。
位图的哈希原理:通过二进制位来表示其他数据,实现判断任意一个数num是否存在的问题,等价于判断num映射的比特位是0还是1。
位图实现:
- 我们构建元素均为0的int类型数组,这样子我们就能得到 与数组大小一致的size个 的32位全为0的比特位流
- 因为数据大多时候是无序的,但是我们在比特流中是有序的,比如data =60,我们映射在第2段32位比特流的第28个比特位上设置为1。
- 那我们对于传入的一个数据data,我们如何找到他对应比特位的位置呢?因为这个比特流由size个32位的00000000 0000000 0000000构成,那么我们查找是第几个就通过data/32可以得到,通过data%32就可知道在第几个比特位上
- 当我们找到数据num在第i个int和对应32位比特的第j个下标时,我们现在的需求就是只将当前这个int的第j个比特位设为1或者0,并且不影响其他的比特位
- 当我们可以完成4的需求时,下一步就是判断num在不在这个比特流中了,也就是查找第i个int和对应32位比特的第j个下标是0还是1.
代码图解和实现:
set的思路:
rset的思路 :
test的思路:
// N为非类型模版参数
template<size_t N>
class bitset
{
public:
// N为比特位个数
bitset()
{
// 开辟空间大小,所有整型均设为0
_bits.resize(N / 32 + 1, 0);
}
// 添加这个值,把对应比特位的数据置为1
void set(size_t x)
{
// 找到数组中的第几个整型
size_t i = x / 32;
// 找到这个整型下的第几个比特位
size_t j = x % 32;
// 将1左移j位置,到达映射位,通过 或 可以把这一位变为1其他位不变
// 因为任何数 或1 都是 1, 1左移j位其他位置全为0,不会对其他位影响
_bits[i] |= (1 << j);
}
// 删除这个值,把对应比特位的数据置为0
void reset(size_t x)
{
size_t i = x / 32;
size_t j = x % 32;
// 这里我们通过取反,使得第j位为0,其他位为1
// 接着与一下,把对应映射的第j位变成0,其他位置因为是1,与 后均为本身
_bits[i] &= ~(1 << j);
}
// 查找这个值在不在
bool test(size_t x)
{
size_t i = x / 32;
size_t j = x % 32;
// 将1左移到对应位置与一下就好了
if (_bits[i] & (1 << j))
return true;
else
return false;
}
private:
vector<int> _bits;
};
到了这里位图的具体操作我们就实现了,而库中提供的原生bitset有更多功能,大家有时间可以去读一下文档 bitset - C++ Reference
1.2.如何使用位图处理海量数据
例题一:有40亿个无序且不重复的无符号的整数,如何快速判断这个数是否在那40亿个数当中
我们通过位图就能实现O(1)的时间复杂度查找,这一点我们在位图的实现模块可以看出来,并且40亿个int类型的数据需要开辟16G的内存,而通过2^32次方个比特位映射出40亿个数据,所需空间仅为0.5G
void find_mass_data()
{
// 文件操作将40亿个数据写入array中
// 这里仅做模拟
int array[] = {111,33,21,45};
// 开辟42亿个比特位的位图
bitset<0xffffffff> bs;
for (auto e : array)
{
// 通过set实现映射
bs.set(e);
}
int x = 9999999999;
// 实现O(1)复杂度查找
bool ifExit = bs.test(x);
}
例题二:给定100亿个整数,设计算法找到只出现一次的整数
查找出现1次的整数,那么我们就需要分多种情况,出现0次,出现1次和出现2次及以上。我们在上面的位图学习时,我们通过比特位0,1两种状态来表示不在和在。那么这三种情况如何表示呢?
我们可以通过两个位图来实现3种状态的表示,具体实现如下。
template<size_t N>
class twoBitset
{
public:
void set(size_t x)
{
// 表示00->01
if (_bs1.test(x) == false && _bs2.test(x) == false)
{
_bs1.reset(x);
_bs2.set(x);
}
else if (_bs1.test(x) == false && _bs2.test(x) == true)
{
_bs1.set(x);
_bs2.reset(x);
}
else if (_bs1.test(x) == true && _bs2.test(x) == false)
{
_bs1.set(x);
_bs2.set(x);
}
}
void findOnce()
{
for (size_t i = 0; i < N / 32; i++)
{
// 打印出现一次
if (_bs1.test(i) == false && _bs2.test(i) == true)
cout << i << endl;
}
}
private:
bitset<N> _bs1;
bitset<N> _bs2;
};
// 测试函数
void find_once_in_mass_data()
{
zhong::twoBitset<1000> tbs;
int array[] = { 1,3,3,4,6,9,1,2 };
for (auto e : array)
{
tbs.set(e);
}
tbs.findOnce();
}
当然因为具体的场景还是得根据具体的实现,所以我们可以通过文件导入100亿个整数,然后分批进入array数组中,然后在开辟一个数组来存放出现一次的数据,循环往复就能够完成了。
例题三:给两个文件,分别有100亿个整数,如何找到两个文件的交集
这个我们就先把这个文件的数据%500,各自分成500个小文件,然后各自的小文件就写入set中,去重,然后再比较重合的数据。然后再开辟一个新的set来存放交集的数据,接着释放掉写入当前小文件数据的set,进行读取下一个文件,循环不断地把交集数据放在专门存储交集的set中,最后就完成了交集的查找了
例题四:1个文件中有100亿个int,1G内存设计算法找到出现次数不为2次的所有整数
这个就是先分割成小文件,然后把数据写入位图中,接着就是和例题二一样的操作了
2.布隆过滤器
2.1.知识引入
布隆过滤器(Bloom Filter)是由布隆在1970年提出的一种概率型数据结构。它实际上是一个很长的二进制向量和一系列随机映射函数。这种数据结构的主要特点在于其高效的插入和查询性能,常被用于检索一个元素是否存在于一个集合中。
布隆过滤器的特点:高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”。
如何体会这一句话呢?接下来我们从布隆过滤器的底层开始讲起:
而布隆过滤器要解决的就是:保证哈希映射的数据结构高效的插入和查询功能的同时,让哈希冲突尽量小。(因为哈希冲突不可避免,体现在:字符串无限,而int有限)
解决方法:当我们进行插入或者查询时,定义多几个哈希函数,让同一个字符串映射多个int(多个int再映射到多个比特位),接着在判断函数处判断某一个字符串的映射是否同时满足这几个映射,那么就能够以极小的出错概率来判断存在了。
接着我们问一个问题:对于存在哈希冲突的场景中,如何判断一个字符串是否存在的准确性?
- 字符串存在是不准确的,因为会出现哈希冲突,比如:abcd,和acbd如果单纯通过ASCII相加的哈希函数进行映射(值为394),最终两个的映射值是一致的,因为当我们查找到映射值为394,我们判断abcd存在,但是可能存在的是acbd
- 字符串的不存在是准确的,因为如果查找不到394,那么无论是abcd,还是acbd肯定都不在,当我们查找abcd时就能判断准确了
注意这里的例子我们通过简单情况来进行这个问题的解释,不过就算我们在实际场景下,还是会出现哈希冲突,也就是对应着复杂情况下的这个例子,因此还是很有意义的。
到了这里布隆过滤器的知识我们大概就讲解完成了,更加深入的内容我们可以在这个链接中进行学习详解布隆过滤器的原理、使用场景和注意事项
2.2.布隆过滤器的实现
布隆过滤器 = 某种类型通过多个哈希函数映射成的int对应的位图,所以实现时我们需要多种哈希函数,这里我们以string类型为主要情况,哈希函数选择string类型。
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;
// 对于这个key我们实现3个映射
_bs.set(hash1);
_bs.set(hash2);
_bs.set(hash3);
}
// 一般不支持删除,删除一个值可能会影响其他值
// 非要支持删除,也是可以的,用多个位标记一个值,存引用计数
// 但是这样话,空间消耗的就变大了
// void Reset(const K& key);
bool Test(const K& key)
{
// 对于当前key我们判断它的三个hash映射值
size_t hash1 = HashFunc1()(key) % N;
size_t hash2 = HashFunc2()(key) % N;
size_t hash3 = HashFunc3()(key) % N;
// 只要有一个哈希映射值在比特流中对应不为1
if (_bs.test(hash1) == false || _bs.test(hash2) == false || _bs.test(hash3) == false)
return false;
else
return true;
}
private:
bitset<N> _bs;
};
值得注意的是:我们通过3个哈希函数实现了3分映射,假如apple映射为99,182,912,那么当我们通过Test函数查找是,如果为这三个映射只要有一个不符合,那么就表示apple没有映射在这个位图中。如果恰好符合,可以说apple有一定存在么?
所以这里:我们能够判断某一个元素一定不在,但是无法判断一定在。
2.3.布隆过滤器的应用
场景一:英雄联盟创建新用户的昵称
一般来说,用户数据是存放在服务器下的数据库中的,当用户创建昵称时,系统会避免昵称重复,所以当我们查询昵称是否重复时,我们可以通过访问数据库,进行字符串的数组的遍历比对来进行,理论上是可行的。但是这种做法需要频繁的进行客户端与服务器之间的访问数据库的请求,会消耗大量的资源……
实际场景中:
在这个场景中,布隆过滤器过滤了一定不存在数据库中的昵称表示可以直接使用,而通过过滤器映射为存在的昵称不一定被使用了,可能是存在哈希冲突,所以需要传入数据库进行字符串的比对。通过这个布隆过滤器,我们就减少了字符串比对的量,减少了直接访问全部字符串的开销。
3.哈希切割
哈希切割是一种常用的数据分割方法,它通过哈希函数将数据划分到不同的分区或桶中。哈希函数将数据映射到一个固定大小的哈希值,然后根据哈希值将数据分配到对应的分区中。
哈希切割的主要目的是将数据均匀地分布到多个分区中,以便实现数据的快速查找和访问。通过将数据分散到多个分区中,可以减少每个分区中的数据量,提高查询效率。
接下来我们通过两个场景来进行哈希切割的学习!!!
场景一:两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件的交集。
100亿个数据库读取的query请求,我们可以假设它为50大小字节的字符串,那么就与5000亿个字节的字符串,我们知道1G大小约为10亿字节,所以完整地将这些数据写入内存需要500G内存显然是不符合当前场景的。
那么我们无法一次性地写入进内存,我们能不能够通过分批,切分写入内存呢?也就是能不能通过一种算法将大文件分割成小文件写入内存中在进行找交集呢?
- 首先我们通过哈希函数对分别对两个文件的query进行映射,可以映射到大量的int值,然后我们对映射的值进行取模,就能够分割这些数据,即HashFunc(query)%500,实现了将大量的query对应的int分割成0~500份的小query
- 我们知道两个大文件中存在交集,那么就会出现两个文件交集部分通过哈希函数的映射值一定相同,那么分割后一定落在相同的小份的query中,这样就实现了哈希分割
- 对于分割后的小文件,对应的下标为0~500,通过相同的哈希函数分割后,交集一定会在相同文件的下标中,接下来我们定义两个set,然后把两个文件写入各自的set中。写入完成后,我们就能够通过遍历判断key是否一致来实现找到交集。
然而实际情况下,可能存在大部分的数据并不是重复的,也就是通过这样子分割的0~500个小query可能会存在,一些query占的内存10G,一些内存只占0.1G这样子,我们无法保证通过这个单一的哈希算法,实现均等分割成小文件。
那么如何解决这个问题呢?
- 我们还是得继续分析一下,假设恰好有一个分割后文件对应的query大小为10G,这个文件中可能大部分为重复的数据,也可能为大部分都不是重复的数据。对于前者,我们可以通过set这个容器(set不允许重复数据写入),写入进内存中,这样子就可能实现少部分的非重复数据写入进内存中。
- 但是现实可能是骨感的,这时候对应着大部分都不是重复的数据,那么我们就通过其他的哈希函数,将这个很大的小文件进行二次的哈希分割,不断继续迭代。
- 可是我们如何判断对应的大部分不是重复数据呢?这种情况下我们通过set写入内存时,因为数据不是重复的,就会导致内存不够用,进而编译器会抛异常,那么我们就可以通过异常处理机制来作为这种情况的判断。
- 通过1,2,3我们就能够实现这个算法的迭代了!!!
场景二: 有一个100G的日志文件存放着IP地址,我们如何找到次数最多的IP地址
跟上一个的情况一致,我们都无法直接地将这个文件写入内存中。同样的我们也可以通过哈希分割成小文件,通过哈希映射再分割后相同的ip一定进入了相同的小文件中。然后不断地把小文件写入map中开始统计次数,用一个变量记录最大值。