哈希是一种映射思想,这里再讲解两种应用哈希思想的数据结构。
问题:
- 给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中?
根据我们现有的知识,该如何处理上诉问题呢?
这是本喵首先想到的处理方式,当然还有很多其他的方式,但是这些方式都行不通,先来看一下40亿的无符号整数占用多大的内存空间:
- 10亿个字节 ≈ 1GB。
- 40亿个字节 ≈ 4GB。
- 40亿个无符号整数 ≈ 16GB。
而一般的内存根本放不下这么多的数据,无论是上面的哪种方法,都需要存放数据本身,即使是用数组来存放都需要16GB,如果用红黑树(有三叉链,颜色)需要大的内存,哈希表虽然少一点,但是仍然有next指针,还是存放不下。
- 问题中只要求判断一个数是否在这40亿个数据中,所以可以不存放数据本。
可以采用位图的方式来处理这个问题。
- 位图:就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。
对于40亿个数据,至少需要40亿个比特位才能标识它们的状态,对于这种情况一般选择232个比特位:
232 = 42亿9千多万,40亿个数据完全可以表示的下,此时相当于一个数组,有232个元素,每个元素是一个比特位。
- 232个比特位 = 229个字节 = 219KB = 29MB = 512MB = 0.5GB
- 从最开始需要16GB内存空间直接下降到了需要0.5GB的空间。
但是在语言层面上并没有比特位的数组。
随便例举一些数字,如上图所示,这里采用char类型为数组的基本单位。
- 数据范围是1到22,所以需要3个char类型的变量。
- 下标为1的比特位表示数字1的存在情况,下标为18的比特位表示数字18是否存在。
上图中,存在3个char类的变量,一共24个比特位,整体标号的话是0~23。
- 0~7使用第一个char类型的变量。
- 8~15使用第二个char类型变量。
- 16~23使用第三个char类型变量。
这3个char类型的变量是用一个数组实现的,即char [3]。这3个char类型变量的地址从左到右依次升高。
- 每个char类型中比特位却是:低的比特位在右,高的比特位在左。
这是由我们的使用习惯决定的,比如3用二进制表示就是11,6用二进制表示就是100,低比特位在右,高比特位在左。
不适用int类型数组的原因:
我们知道,数据在内存中的存储是有大小端的,如果使用int类型的数组,上图就变成:
只需要一个int类型的数据就够了,并且还多出8个比特位。
假设上图中是小端存储方式,并且是处理完的位图,此时将这份代码换到了大端存储方式的机器上:
此时位图结构就变成了上图中所示,原本表示数字0~7的8个比特位放在了高地址处,变成了表示24 ~31的8个比特位。
而采用char类型数组就不用考虑大小端的问题,因为一个char类型就是一个字节,每个char都是从低地址到高地址排列。
上面是在内存中存储的真实样子,我们在使用的时候无需知道位图在内存中样子。
这种方式其实就是一种哈希思想,将数据直接映射到位图上。
确定数据的映射位置:
如何确定一个数据映射在位图的哪个比特位呢?以整数18为例说明:
- 确定映射到char类型变量的下标:18 / 8 = 2。
- 确定映射到比特位的下标:18 % 8 = 2。
可以根据上面的图确定一下,发现和我们算出来的结果是一样的。求其他数据的映射位置时,只需要将18换成对应数据即可。
namespace wxf
{
template <size_t N>
class bitset
{
public:
bitset()
{
//_bits.resize(N / 8 + 1 , 0);
_bits.resize((N >> 3) + 1, 0);
}
private:
vector<char> _bits;
};
}
在构造函数中需要指定vector的大小,否则vector的大小是0,一个比特位也没有。
- 非类型模板参数N指定是比特位的个数,而构造函数开辟的是char类型变量的个数,所以需要N / 8。
- 由于N / 8的结果不是整数时会取整而抛弃小数部分,所以需要在N /8 后再加1,也就是再增加 8 个比特位来确保位图够用。
CPU在计算除法的时候,其实是很复杂的,而进行移位运算就很简单,效率也非常高。
- N / 8相当于N右移3位。
所以我们使用移位运算来代替除法来提高效率,需要注意的是,加法的优先级比移位运算高,所以必须给(N>>3)加括号,否则就是成了 N>>4了。
set():
//置一
void set(size_t x)
{
//size_t i = x / 8;//映射到第几个char中
size_t i = x >> 3;
size_t j = x % 8;//映射到char中第几个比特位
//将映射到位图中的比特位置一
_bits[i] |= (1 << j);
}
该接口的作用是将x映射在位图中的比特位置1,表示该数据存在。
如上图所示,要将一个char类型中的8个比特位的某一个位置一而不影响其他位,就需要或等一个只有那个位是1其他位都是0的char类型,这样一个char类型可以通过1左移固定位数得到。
reset():
//清零
void reset(size_t x)
{
//映射到位图中的位置
size_t i = x >> 3;
size_t j = x % 8;
//将比特位清0
_bits[i] &= (~(1 << j));
}
该接口的作用是将x映射在位图中的比特位清0,表示数据x不存在。
如上图所示,将char类型中的某个比特位清0而不影响其他位,需要与等一个只有那个位是0其他位都是1的char类型变量,这样一个char类型可以通过1左移固定位数,然后取反得到。
test():
//查找是否存在
bool test(size_t x)
{
//映射到位图中的位置
size_t i = x >> 3;
size_t j = x % 8;
return _bits[i] & (1 << j);
}
该接口的作用是在位图中查找数据x是否存在。
如上图所示,判断某个比特位是1还是0,需要与一个只有这个位是1其他位都是0的char类型变量,如果这个bit是0,那么与以后的结果就是0,对应的bool值flase,如果这个bit是1,那么与以后的结果就不是0,对应的bool值是true。
- bool值本质上是4个字节的整形,所以这里涉及到了整形提升,但是并没有影响。
- 如果与以后的结果是0,整形提升后的结果仍然是0,bool值就是false。
- 如果与以后的结果非0,即使符号位是1,整形提升和的结果仍然非0,bool的值就是true。
位图主要的接口就是这三个,下面来测试一下:
创建232个比特位的位图方式:
- 第一种方式:指定大小位-1,因为非类型模板参数是size_t类型的,所以-1强转位size_t以后,32个比特位都是1,所以就是232。
- 第二种方式:使用十六进制的方式,指定非类型模板参数的size_t类型的32个比特位都是1,此时也是232。
- 比较差的方式:使用232的十进制,也就4294967296,这个数字容易记错。
根据上面程序运行结果,可以看到,置一,清零,判断都符合我们的预期。
从任务管理器中查看我们的程序所占的内存,当32个比特位的位图没有创建的时候,所占内存大小7.9MB,位图创建以后,所占内存变成了519.8MB,增加了512MB,也就是0.5GB,这和我们之前分析的一样。
- 任何一个数据集,使用32个比特位的位图都可以统计的下,也就是最多占用0.5GB的空间。
- 因为整数的最大值就是232,也就是4294967296,32个比特位的位图足够放的下。
- 即使数据集的数据个数是10个亿,但是这里有很多的重复的数据,而最大值也不会超过232。
注意:位图只能判断整数存不存在,并不存放数据本身。
将数组中的数据在位图中置一和清零,分别进行依次判断,得到的结果和我们预期的一致。
STL中的位图:
在STL库中,是存在位图的,但是用的比较少。
我们实现的这3个操作也是有的,当然它还提供了其他的接口,有兴趣的小伙伴可以看看文档它们的用法,本喵就不再介绍了。
- 问题一:给定100亿个整数,设计算法找到只出现一次的整数?
分析:
- 两个位图相同下标的两个比特位来表示一个数据的状态。
- 00表示0次,01表示1次,10表示一次1以上。
- 问题二:给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
分析:
用两个位图来表示各自文件中数据的存在情况,虽然有重复的数据,但是只统计一个。
- 将两个位图进行按位与运算,得到的结果中,比特位是1的就是交集。
这里本喵仅讲解思路,具体的实现就不再写了,要注意体会位图的应该,也就是哈希应用的思想。
- 问题三:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数?
分析:
只需要增加一种情况的处理即可,如上图代码所示。
位图的优缺点:
优点:节省空间,效率高。
缺点:一般要求数据相对集中,否则会导致空间消耗上升。
位图的一个致命缺点:只能针对整形。
如果我就要使用位图来存放字符串呢?当然也是可以的,只是需要和哈希表一样,将字符串转换成整数。
如上图所示,将不同的字符串通过hashfunc函数转换成不同的整数,然后将这些整数映射到位图中,从而表示字符串的存在情况。
这就会产生误判的情况:
- 位图中存在:不一定真正存在。
如上图中“find”和“insert”转换成的整数都是1234,所以位图中第1234个比特位是1,就可以说“find”和“insert”都存在,但实际上是“insert”存在,而“find”不存在,于是就产生了误判。
- 位图不存在:必然不存在。
还使用上面的例子,如果位图的第1234个比特位是0,说明“find”和“insert”都不存在。
所以根据位图判断出的结构,不存在是准确的,存在是不准确的。
有没有办法能提高一下判断的准确率呢?答案是有的,布隆过滤器就可以降低误判率,提高准确率。
- 布隆过滤器:用多个哈希函数,将一个数据映射到位图结构中。
使用两个哈希函数,将同一个字符串转换成两个整数,并且都映射在位图中,如上图所示。
"find"经过哈希函数处理后的两个整数,只有一个是被“insert”映射的,另一个是0,说明“find”不存在。而“insert”经过哈希函数处理后的两个整数,在位图中都有映射,可以说明“insert”存在。
此时提高了准确率:
- 位图存在:字符串存在的准确率提高,但是仍有不存在的可能。
字符串“find”经过两个哈希函数处理后得到两个整数,与字符串“insert”得到的两个整数相同的概率,比之前各自有一个整数相同的概率低的多。
但是仍然有可能“find”的两个整数和“insert”的两个整数相同,此时就会又出现误判。
- 位图不存在:必然不存在。
布隆过滤器对于不存在的判断是准确的,并且可以降低存在时的误判率。
布隆过滤器的应用场景:
- 不需要一定准确的场景,比如注册昵称时的存在判断。
如上图中,一个昵称的数据库是放在服务器中的,这个数据库中昵称的存在情况都放在了布隆过滤器中,当从客户端注册新的昵称时,可以通过布隆过滤器快速判断新昵称是否存在。
- 这里对存在的准确率要去就没有太高,布隆过滤器显示存在(不准确),就换一个昵称,显示不存在(准确),就注册这个昵称,并放入数据库中。
- 通过布隆过滤器查找可以提高效率,如果之前去数据库中查找的话,效率就会大大降低。
哈希函数个数和布隆过滤器长度的关系:
现在知道布隆过滤器是什么了,但是我们到底该创建多少个比特位的位图(布隆过滤器长度),又应该使用多少个哈希函数来映射同一个字符串呢?
如何选择哈希函数个数和布隆过滤器长度一文中,对这个问题做了详细的研究和论证:
最后得出一个公式:
- m:表示布隆过滤器长度。
- k:表示哈希函数个数。
- n:表示插入的元素个数。
- 其中:ln2约等于0.69。
首先需要写几个哈希函数来将字符串转换成整形,各种字符串Hash函数一文中,介绍了多种字符串转换成整数的哈希函数,并且根据冲突概率进行了性能比较,有兴趣的小伙伴可以自行研究一下。
这里本喵选择分数较高的4个哈希函数:
struct BKDRHash
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto ch : key)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
struct APHash
{
size_t operator()(const string& key)
{
unsigned int hash = 0;
int i = 0;
for (auto ch : key)
{
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ (ch) ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ (ch) ^ (hash >> 5)));
}
++i;
}
return hash;
}
};
struct DJBHash
{
size_t operator()(const string& key)
{
unsigned int hash = 5381;
for (auto ch : key)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
struct JSHash
{
size_t operator()(const string& s)
{
size_t hash = 1315423911;
for (auto ch : s)
{
hash ^= ((hash << 5) + ch + (hash >> 2));
}
return hash;
}
};
以上是四个字符串转换成整形的仿函数。
然后就是将布隆过滤器的框架搭出来:
namespace wxf
{
template <size_t N,
size_t X = 6,
class K = string,
class HashFunc1 = BKDRHash,
class HashFunc2 = APHash,
class HashFunc3 = DJBHash,
class HashFunc4 = JSHash>
class BloomFilter
{
public:
private:
std::bitset<X* N> _bs;
};
}
该模板有多个参数,但是大部分都是使用的缺省值,不用必须去传参,底层使用的STL库中的bitset。
- size_t N:最多存储的数据个数。
- size_t X:平均存储一个值,需要开辟X个位,这个值是根据上面的公式算出来的,此时哈希函数是4个,所以m = 4n/ln2 = 5.8n,取整后X位6,这里先给个缺省值是6。
- class K:布隆过滤器处理的数据类型,默认情况下是string,也可以是其他类型。
- 哈希函数:将字符串或者其他类型转换成整形进行映射,给的缺省值是将字符串转换成整形的仿函数。
set():
将数据经过四个哈希函数的处理得到四个整数,然后将这四个整数都映射到位图中来表示这个数据存在。
test():
对每一个哈希函数得到的整数所映射的位置进行判断,如果某个位置不存在直接返回false,说明这个字符串不存在,当四个整数所映射的位置都存在,说明这个字符串存在。
- 判断每个比特位时,判断它不存在,不要判断它存在,因为不存在是准确的,存在是不准确的。
误判率:
- 用一个网站字符串,在它的基础上创造出十万个字符串,然后将这十万个字符串全部映射到布隆过滤器中。
- 再创造出另外十万个不同的字符串,但是必须相似(在前面基础上加了999999),这十万个不映射到位图。
- 在布隆过滤器中统计后十万个字符串存在的个数,理论上后十万个字符串是不会存在于布隆过滤器中的。这里存在的都是误判的,也就是在统计误判的字符串个数。
- 最后用误判字符串个数除以十万,得到误判率。
可以看到,X值越大,也就是一个字符串所需要的映射比特位越多,布隆过滤器的误判率越小。但是空间消耗也增加了。
- 哈希函数的个数越多,误判率也会越小,但是对于的空间消耗也会增加。
布隆过滤器只能提高存在判断的准确率,并不能让它完全准确。
- 布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。
“baidu”和“tencent”映射的比特位都有第4个比特位。删除上图中"tencent"元素,如果直接将该元素所对应的二进制比特位置0,“baidu”元素也被删除了,因为这两个元素在多个哈希函数计算出的比特位上刚好有重叠。
一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作。
但是也存在缺陷,无法确认元素是否真正在布隆过滤器中,甚至会有计数回绕。
总的来说,布隆过滤器最好不要支持删除操作。
布隆过滤器的优缺点:
优点:
- 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关。
- 哈希函数相互之间没有关系,方便硬件并行运算。
- 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势。
- 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势。
- 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能。
- 使用同一组散列函数的布隆过滤器可以进行交、并、差运算。
缺点:
- 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)。
- 不能获取数据本身。
- 一般情况下不能从布隆过滤器中删除元素。
- 如果采用计数方式删除,可能会存在计数回绕问题。
- 问题一: 给两个文件,分别有100亿个字符串,我们只有1G内存,如何找到两个文件交集?给出近似算法。
分析:
具体代码本喵不写了,这里主要体会布隆过滤器是使用的思想。
- 问题一:给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?
分析:
如果将这100GB的文件均分为100给1GB的小文件,统计会出现问题。
- 假设A0中出现次数最多的IP是“IP1”,出现最少次数的IP是“IP2",那么这个小文件最终得到是”IP1“出现最多。
- A1小文件中,出现最多的是”IP2“,出现最少的是”IP1“,那么这个小文件最终得到是”IP2“出现最多。
- 最终是A0中统计出来”IP1“的次数和A1中统计出来”IP2“的次数在比较。
这样最终比较时的数据具有片面性,因为在统计每个小文件时,会舍弃很多的数据,这些舍弃的数据再最终比较时并没有被考虑到。
此时就需要用到哈希切分的方法。
- 哈希切分:通过哈希函数,将相同或者相近的数据切分到一组。
如上图所示,通过哈希函数,将100GB文件中的所有IP都转换成整数,然后模100,得到多少就进入标号为多少的小文件中。
- 哈希切分时:相同的IP经过哈希函数处理得到的整数必然是相同的,所以也必然会被分到同一个小文件中。
- 虽然会有哈希碰撞的情况,产生碰撞的IP都会在一个小文件中,而不会被分到其他小文件。
经过哈希切分后,每个小文件中统计出现次数最多的IP就是这100GB文件中该IP出现的总次数。最后再从每个小文件中出现次数最多的IP中比较出最终出现次数最多的IP。
但是此时又存在问题,哈希切分并不是均分,也就意味着每个小文件中的IP个数不一样,有的多有的少。如果某个小文件的大小超出1GB怎么办?
有两种超出1GB的情况:
- 这个小文件中冲突的IP很多,都是不同的IP,大多数是不重复的,此时无法使用map来统计——需要换一个哈希函数递归切分这个小文件。
- 这个小文件中冲突的IP很多,都是相同的IP,大多数是重复的,此时仍然可以用map来统计——直接统计。
无论是哪种情况,我们先都直接用map去统计,如果是第二种情况,内存就够用,map可以进行统计,而且不会报错。
如果是第一种情况,map就会因为内存不够而插入失败,相当于new节点失败,就会抛异常,此时我们只需要捕获这个异常,然后换一个哈希函数递归切分这个小文件即可。
问题二:给两个文件,分别有100亿个字符串,我们只有1G内存,如何找到两个文件交集?给出精确算法。
分析:
假设平均每个字符串的大小是50B,那么100亿个字符串就是500GB,所以需要将这500GB哈希切分成1000份,每个小文件才能在内存中进行准确的次数统计。
找交集的方法有很多,本喵就不再详细讲解了,但是需要注意的是,每个小文件Ai和Bi都需要各自降重以后再找交集。
位图和布隆过滤器都是针对数据量很大的情况下使用的数据结构,并且它们不能存放数据本身,只能表示数据存在或者不存在,位图只针对整形,并且不存在误判的情况,布隆过滤器主要针对字符串,但是也可以是其他自定义类型,但是存在误判,可以通过增加哈希函数或者映射一个数据所需要的比特位来降低误判率,但是会消耗更多的空间。
这篇文章主要是介绍哈希思想的应用,位图以及布隆过滤器归根到底还是哈希思想的体现。