前面的文章中我们讲解了如何进行哈希表的构建以及使用实现的哈希表来模拟实现unordered_map,在本文中我们将继续来讲解一下哈希的应用。
位图
问题引入
首先我们来引入一个问题:给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。
第一眼看到这个题目的时候,我们可能会有这样的解题思路:使用排序+二分查找的方法或者将这些数据放入红黑树或者哈希表中,但是这样的处理有这一个问题就是内存不够。1G的数据约有10亿byte的大小(1G = 1024MB = 1024 * 1024KB = 1024 * 1024 * 1024byte)那么40亿的整数就一共有16G的大小,这么大量的数据是不能够放入内存中的。因为我们的目的是判断数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。
位图概念
所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用
来判断某个数据存不存在的。
位图实现
template<size_t N>
class bitset
{
public:
bitset()
{
_bits.resize(N/8 + 1, 0); // 为了防止访问越界的问题出现
}
void set(size_t x)
{
size_t i = x / 8; // 计算x映射的位置在第i个char数组的位置
size_t j = x % 8; // 计算x映射的位置在第i个数组的第j个位置
_bits[i] |= (1 << j); // 注意左移右移的区别
}
void reset(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] &= ~(1 << j); // 注意左移右移的区别
}
bool test(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
return _bits[i] & (1 << j); // 运算符的优先级的问题,位运算的优先级比较低
// (_bits[i] >> j) & 1; // 10%8 == 2
}
private:
vector<char> _bits;
};
按照上述的代码所示使用vector<char>类型的数组,每一个char有8个比特位我们对需要映射的数据进行映射,例如需要映射10这个数据,首先将10/8可以得到它在第几个char字符中,然后将其再模上8,就可以得到它在char字符的哪一个比特位上。这里还需要我们注意的就是之前学习的比特位是从右往左依次增大的,那么我们要将第i个字符的第j位设置为1时就需要将 1<<j 然后 | _bits[i]这样就可以得到正确的结果。同样要将映射取消就可以将_bits[i] &= ~(1<<j)。下面图中的数据转换为16进制就是8c。
位图的应用
下面我们再来看几道题目:
1. 给定100亿个整数,设计算法找到只出现一次的整数?
2. 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
3. 位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
针对第一个问题,100亿的整数,肯定是有着大量的重复,那么就需要我们开辟整数个数大小的空间,这里空间的开辟可以使用不同的方式,例如:
bitset<-1> bs; // 开辟的空间是根据数据的范围,开辟所有的整数可以通过-1的形式
bitset<0xffffffff> bs;
我们可以使用两张位图来解决第一个问题,给定两个位图进行处理同样的进行标记 00 01 10 11,出现了1次就将这两个位图的值设置为01;或着我们使用一个位图的方式,但是将开辟的空间改为四个,每两个作为一个组合进行标记。
针对第二个问题可以将其中的一个文件读进位图之中,在读取另外的一个文件,在就是存在交集,但是这样会出现一个问题就是存在重复的值需要进行去重操作,这里同样的有两种方法,方法1:将已经找到的交集的比特位归零防止二次统计;方法2:将两个文件分别读取进入两个位图进行比较。第三个问题与第一个问题是同样的解决方案。
总结一下就是位图的优点:速度快节省空间;缺点: 只能映射整形, 其他的类型如浮点数不能存储。那么这里就有新的方案 -- 布隆过滤器。
布隆过滤器
布隆过滤器概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
由于整数的范围比较小字符串的范围比较大,那么从小范围映射到大范围就会产生冲突布,隆过滤器降低冲突概率的方法思想就是,一个值映射一个位置容易误判,那么映射多个位置就可以降低误判率,按上图的例子,可以看出xyz分别映射在了不同的位置,假如现在有一个新的数据M的哈希字符串已经被xyz的数据置为了1,那么当我们进行查询的时候就会发现M虽然没有映射在布隆过滤器中但是在其中可以查询找得到。
对与一个数据来说如果它在布隆过滤器中那么它可能会是存在误判的,但是如果一个数据不存在其中,那么它一定是不存在的。
布隆过滤器的使用场景
例如以前在进行用户注册的时候需要我们填写称昵,这时就可以使用布隆过滤器,只要该用户名不出现在其中就可以使用,即使是出现虚假的重复也没有什么关系。再例如查询手机号是否重复,那么就可以先进行布隆过滤器的查找,如果不在就可以直接返回,如果存在,不管是什么情况,都可以去数据库中再次进行查找,这样就可以减少工作量。
布隆过滤器的实现
// N最多会插入的数据个数
// 哈希函数的个数,代表一个值映射几个位,哈希函数越多,误判率越低;但是哈希函数越多。平均的空间越多
template<size_t N, class K = string,
class Hash1 = BKDRHash,
class Hash2 = APHash,
class Hash3 = DJBRHash>
class BloomFilter
{
public:
void set(const K& key)
{
size_t len = _X * N; // 布隆过滤器的长度
size_t hash1 = Hash1()(key) % len;
_bs.set(hash1);
size_t hash2 = Hash2()(key) % len;
_bs.set(hash2);
size_t hash3 = Hash3()(key) % len;
_bs.set(hash3);
//cout << hash1 << " " << hash2 << " " << hash3 << " " << endl << endl;
}
bool test(const K& key)
{
size_t len = _X * N;
size_t hash1 = Hash1()(key) % len;
if (!_bs.test(hash1))
{
return false;
}
size_t hash2 = Hash2()(key) % len;
if (!_bs.test(hash2))
{
return false;
}
size_t hash3 = Hash3()(key) % len;
if (!_bs.test(hash3))
{
return false;
}
}
private:
static const size_t _X = 4; // 布隆过滤器的长度与存放数据个数的比值
bitset<N*_X> _bs;
};
关于布隆过滤器其中还有着一些具体的问题,读者可以通过这个链接进行更加详细的阅读
详解布隆过滤器的原理,使用场景和注意事项 - 知乎 (zhihu.com)
下面我们来看一道题目
给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?(query就是相当与字符串,假设单个query,平均50byte,100亿个query就是5000亿byte)
按照之前的想法,我们可以将文件进行划分,例如划分成1000个小文件,两个文件分别切分成A0~A999和B0~B999,但是这样有一个问题就是我们要将每两个小文件都进行比较然后再取出交集的部分,这样的话工作量就会变得非常巨大。因此在这里需要我们使用一种名叫哈希切割的方法。
哈希切割 -- 小文件的编号 i = HashFunc(query) % 1000 ,每个query算出对应的i是多少就进入哪一个小文件。
然后,将AB两个文件都进行哈希切割,这样操作之后我们得到的小文件,是对应着的,在A0和B0中存放着进行切割之后相同结果的文件,那么在进行比较的时候只需要比较两者的同号小文件即可。相同的信息一定会存放在相同编号的小文件之中。
此时,有可能会出现新的问题就是:
某些小文件因为不是平均切分会导致文件过大,这里又会有两种不同的情况
1. 单个文件中有某个大量重复的query
2. 单个文件中有大量不同的query
解决方案:使用unordered_map / set 依次读取文件query,插入set中
1. 如果读取中整个小文件query都可以成功插入set那么就说明情况1
2. 如果读取中整个小文件query 插入过程中异常(无内存),则是情况2,换用其他的哈希函数,再次分割,再求交集。
说明:set插入key如果已经有了返回false;如果没有内存就会抛出bad_alloc的异常