目录
位图 && 布隆过滤器
1.哈希的应用
1.1位图
位图概念:
所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。
面试题:
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。【腾讯】
-
遍历,时间复杂度O(N)
-
排序(O(NlogN)),利用二分查找: logN
-
位图解决
而且前两种方式可能会在内存装不下,因为一个int4字节。40亿个数就是16G。
数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。比如:
1.2位图的实现
bitset.h:
#pragma once
#include<iostream>
#include<vector>
using namespace std;
namespace wzf
{
// 位图的模拟实现
class bitset
{
public:
bitset(size_t N) //开辟N个比特位
{
// 由于没有开辟空间的单位是一个比特位,因此这里开空间一定会有浪费
_bits.resize(N/32 + 1, 0);//用resize可以将所有位都初始化为0
// +1的话,最多浪费31个位。
}
//将数据映射到位图中存储
void set(size_t x)
{
// 要先计算出x在那个数字中,x是60,就要存到第60位比特位,就是属于1这个下标的整形中
size_t index = x / 32; // /32是因为用的int存储,如果数组是char,那就/8
size_t pos = x % 32; // 知道在那个整形中,就要知道在该整形的第几个位
_bits[index] |= (1 << pos); // 让这个位置1,表示x被存储到该位图中
//一定要注意,这里的左移<< 不是方向!是向高位移动!
// 方向跟大小端存储有关系!但是机器是大端还是小端我们是不知道的!
_nums++;
}
// 将数据映射到位图中,取消存储【删除】
void reset(size_t x)
{
size_t index = x / 32; // 找到在那个int中
size_t pos = x % 32; // 找到在那个位
_bits[index] &= ~(1 << pos); // 把该位置0
_nums--;
}
// 判断数据是否在位图中
bool find(size_t x)
{
size_t index = x / 32;
size_t pos = x % 32;
//if (_bits[index] & (1 << pos))
//{
// // 进入该分支就是该位置为1
// return true;
//}
//return false;
// 直接一步到位
return _bits[index] & (1 << pos);
}
private:
vector<int> _bits; // 用一个数组来当位图.
size_t _nums; // 统计该位图一共映射了几个数字
};
}
测试代码:
// 测试模拟实现的位图接口能否正常运行
void test_bitset()
{
// 开辟100个位,但是实际上开的比这多,100/32 + 1。开个4个int,4*32=128个位
bitset bs(100);
bs.set(12);
bs.set(88);
bs.set(98);
bs.set(19);
bs.set(99);
bs.reset(98);
for (int i = 0; i < 100; i++)
{
printf("[%d]在位图的存储情况:%d\n", i, bs.find(i));
}
}
// 将腾讯面试题解决
void FinishTest()
{
// 给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。【腾讯】
// 由于存储的是无符号整数。范围是0 ~ 42亿多[无符号整数的最大存储值]
bitset(-1); // 构造函数的参数是size_t,这里-1的补码传进去刚好就是无符号整数的最大数
}
1.3位图的应用
-
快速查找某个数据是否在一个集合中
-
排序 + 去重
-
求两个集合的交集、并集等
-
操作系统中磁盘块标记【Linux中的文件系统,还有信号集】
2.布隆过滤器
2.1布隆过滤器的场景
我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉 那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用 户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那 些已经存在的记录。 如何快速查找呢?
- 哈希解决,缺点:使用哈希来存储用户数据,会浪费空间,当数据量过大,占用空间也会特别多
- 位图解决:缺点:位图只能解决整形,如果数据是字符串,那么位图就无法正确的映射存储关系
因此,布隆过滤器此时就需要被用到了。将哈希和位图结合到一起就是布隆过滤器
2.2布隆过滤器的概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存 在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也 可以节省大量的内存空间。
注意:
这里之所以一个值要映射到位图的多个位上,是因为当一个值(如string)根据字符串哈希函数映射到某个位上的时候,很可能有一个不一样的值,但是通过哈希函数得到的映射位置是一样的。这个时候就会导致这个值无法被映射到位图中。
因此当一个数据在被判断是否存在于位图的时候,就可能被误判!【应该映射的位置被其他数据映射了】而这个问题无法杜绝,只能缓解
所以布隆过滤器会选择用多个哈希函数,将一个值映射到多的位上。减少误判的概率
2.3布隆过滤器的实现
2.3.1布隆过滤器的插入
布隆过滤器在插入的时候要考虑一下哈希函数的数量,数量越多,效率越低,但是冲突率也会越低。这里的实现采用三个哈希函数
void set(const K& key)
{
// 利用哈希函数找到key在位图的位置
//在布隆过滤器中,哈希函数越多,效率越低,但是太少误报率会高
// 这里取3个哈希函数,然后将得到的3个哈希地址全部在位图中置1
// 这里也要注意了,该布隆过滤器的长度是数据量的4倍,因此哈希地址注意越界的问题
size_t index1 = Hash1()(key) % _len; // %上该过滤器的长度,确保不会越界
size_t index2 = Hash2()(key) % _len;
size_t index3 = Hash3()(key) % _len; // 中间的()代表构造了一个匿名对象
_bs.set(index1);
_bs.set(index2);
_bs.set(index3);
}
2.3.1布隆过滤器的查找
布隆过滤器的思想是将一个元素用多个哈希函数映射到一个位图中,因此被映射到的位置的比特位一定为1。所以可以按照以下方式进行查找:分别计算每个哈希值对应的比特位置存储的是否为零,只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中。
注意:布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可能存在,因为有些哈希函数存在一定的误判。
比如:在布隆过滤器中查找"abcd"时,假设3个哈希函数计算的哈希值为:1、3、7,刚好和其他元素的比特位重叠,此时布隆过滤器告诉该元素存在,但实该元素是不存在的。
bool test(const K& key)
{
// 一样通过哈希函数算出哈希地址,然后找到在位图中的映射位置,然后判断是否为1
size_t index1 = Hash1()(key) % _len; //要注意哈希地址是否越界的问题
size_t index2 = Hash2()(key) % _len;
size_t index3 = Hash3()(key) % _len;
// 这个输出用来测试
//printf("该key的哈希地址:%zu, %zu, %zu\n", index1, index2, index3);
// 由于有3个哈希函数,因此必须3个哈希地址的位都为1,才说明该key存在于布隆过滤器
if (_bs.test(index1) == false)
return false;
if (_bs.test(index2) == false)
return false;
if (_bs.test(index3) == false)
return false;
return true;
}
2.3.1布隆过滤器的删除
布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。
比如:删除上图中"tencent"元素,如果直接将该元素所对应的二进制比特位置0,“baidu”元素也被删除了,因为这两个元素在多个哈希函数计算出的比特位上刚好有重叠。
一种支持删除的方法:
将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作。
2.3.4完整代码+测试代码
直接看代码。思路和注意的点都在代码的注释了
// 利用仿函数将string类型转化为int类型的哈希地址,然后映射
struct Strhash1
{
// BKDRHash算法
const size_t operator()(const string& s)
{
size_t hashadd = 0;
for (auto& c : s)
{
hashadd *= 131;
hashadd += c;
}
return hashadd;
}
};
struct Strhash2
{
//BKDRHash的变种——SDBM(一种简单的数据库引擎)中被应用而得名,它与BKDRHash思想一致,只是种子不同而已。
const size_t operator()(const string& str)
{
size_t hash = 0;
for (int i = 0; i < str.size(); i++)
{
hash *= 65599;
hash += str[i];
}
return hash;
}
};
struct Strhash3
{
//Unix system系统中使用的一种著名hash算法,后来微软也在其hash_map中实现。
size_t operator()(const string& str)
{
if (str.empty())//以保证空字符串返回哈希值0
return 0;
register size_t hash = 2166136261;
for (auto& c : str)
{
hash *= 16777619;
hash ^= c;
}
return hash;
}
};
//要映射的数据类型是K, 一般来说都是字符串这种位图无法直接映射的。字符串使用场景很多
template<class K = string, class Hash1 = Strhash1, class Hash2 = Strhash2, class Hash3 = Strhash3>
class BloomFilter
{
public:
// 布隆过滤器长度越长,误报率越低。浪费空间越多
// 一个般来说取数据量的3~5倍比较好
BloomFilter(size_t N)
:_bs(4*N)
,_len(4*N)
{}
bool test(const K& key)
{
// 一样通过哈希函数算出哈希地址,然后找到在位图中的映射位置,然后判断是否为1
size_t index1 = Hash1()(key) % _len; //要注意哈希地址是否越界的问题
size_t index2 = Hash2()(key) % _len;
size_t index3 = Hash3()(key) % _len;
// 这个输出用来测试
//printf("该key的哈希地址:%zu, %zu, %zu\n", index1, index2, index3);
// 由于有3个哈希函数,因此必须3个哈希地址的位都为1,才说明该key存在于布隆过滤器
if (_bs.test(index1) == false)
return false;
if (_bs.test(index2) == false)
return false;
if (_bs.test(index3) == false)
return false;
return true;
}
private:
wzf::bitset _bs; // 底层就是一个位图
size_t _len; // 表示该布隆过滤器的长度
};
测试代码:
void test_BloomFilter()
{
BloomFilter<string> bf(100); //用户需要用该BF存储100个数据【在该BF中实际会开400】
bf.set("abcd");
bf.set("aadd");
bf.set("bacd");
bf.set("aaag");
// 如果只有一个哈希函数,那么只有"abcd"能真正存入,其他都会被误判
cout << bf.test("abcd") << endl;
cout << bf.test("aadd") << endl;
cout << bf.test("bacd") << endl;
cout << bf.test("aaag") << endl;
// 为了更好的理解,在test函数运行的时候 将这四个数据的哈希地址打印出来
/*
"abcd"的哈希地址:74, 2, 277
"aadd"的哈希地址:244, 0, 11
"bacd"的哈希地址:204, 0, 119
"aaag"的哈希地址:254, 6, 55
*/
}
2.4布隆过滤器的优缺点
- 优点:
-
增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关
-
哈希函数相互之间没有关系,方便硬件并行运算
-
布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
-
在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
-
数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
-
使用同一组散列函数的布隆过滤器可以进行交、并、差运算
- 缺点:
- 有误判率,即存在假阳性(False Position),没有办法真正判断一个元素是否存在于布隆过滤器中(补救方法:建立一个白名单,存储可能会误判的数据)
- 不能获取元素本身
- . 一般情况下不能从布隆过滤器中删除元素
- 如果采用计数方式删除,可能会存在计数回绕问题
3.海量数据处理
3.1 哈希切割
给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?
将log file切割成1000个小文件,然后通过通过一个字符串哈希函数计算出一个哈希地址
size_t i = hashstr(query) % 1000;
,然后将IP地址映射到第i个文件中。这样相同的IP地址一定在一个小文件中!然后把每个文件都加载到内存中由于这里涉及到统计次数,因此采用map<string, int>,依次让第i个小文件加载到map中,把当前文件出现次数最多的ip地址的次数记到一个max中,读取后面的第i个文件中,如果遇到出现次数比max多的就覆盖max、从而找到出现次数最多的IP地址
与上题条件相同,如何找到top K的IP?如何直接用Linux系统命令实现?
**TopK就是找到出现次数前K个多/少的IP,就是建立对应的容量为K的小堆/大堆。**如:统计次数前K个多的IP,建立容量为K的小堆,堆顶最小,下面的一定比堆顶大,那就是出现次数前K个多的IP
3.2 位图应用
- 给定100亿个整数,设计算法找到只出现一次的整数?
2^30次方就是1G,就是10亿,因此这里有10G * 4 大概40G个字节
将数据的出现分为三种情况。
一次都没出现
只出现一次
出现了两次及以上
因此只需要改进一下位图,两个比特位表示一个数。
- 00——一次都没出现
- 01——只出现一次
- 10出现两次及以上
具体来说可以创建两个位图,让两个位图共同达成一个位图的效果。一个位图专门记载00,01,10的第一位。第二个位图专门记载后一位。
- 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
**方法1:**将文件1的整数映射到一个位图中,然后读取另外一个文件2的数,判断是否在该位图中存在,存在的话就是交集。消耗内存500M
注意:虽然有100亿个数,但是开辟位图只需要开一个size_t能存储的最大数232,因为是整数,范围就是0~232次方。也就是42亿多,即位图开500Mb左右的大小
**方法2:**将文件1和文件2的数据分别映射到两个位图,然后按位与、消耗内存1G
- 位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
还是设计2个位图,和第一题的结构一样,00表示出现0次,01表示出现1次,10表示出现2次,11表示出现三次及以上。这样的话,位图中10和01都是不超过两次的
3.3 布隆过滤器
- 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法
注意:一般query都是sql查询语句或者是网络请求的url,一般是一个字符串。并且一个query平均是 30-60字节,100亿个query就是300G-600G
**近似算法:**将文件1的query映射到一个布隆过滤器,读取文件2的query,判断在不在布隆过滤器。
缺点:不精确,布隆过滤器的特性会导致,不属于交集的数据混到交集中。【因为某个数据在查询的时候,很可能没有映射进去,但是在查询的时候其映射的位都是1,所以判断为交集】
精确算法:
- 法1:两个文件都很大,分为A、B文件。平均切割1000份,一共2000份,每份大概是300-600M,然后就可以加载到内存中了。然后将其中一个小文件A1,装到一个set中,然后取B1来查询是否存在交集。
缺点:一个A1小文件,要和B1B1000的小文件都对比一次,尽管装进set提升搜索效率,但是每个A的小文件都要和B1B1000对比,这样要比1000*1000次【IO次数】
- 法2:哈希切割
将每一个query都通过一个字符串哈希函数计算出一个哈希地址
size_t i = hashstr(query) % 1000;
,算出来这个i是多少,就放到A、B文件对应的Ai、Bi的小文件。比如i是2,那就放到对应A2、B2中。这样每一个query都会映射到属于自己的小文件。【从A取出来的query就映射到Ai,B取的就映射Bi】然后再找交集的时候,只需要让每一个小文件A1去和B1当中找交集即可,也就是Ai去Bi当中找交集。加载到内存的时候仍然是将数据装进一个set中。这样找交集的时候效率高(O(logN))【比如将A1数据加载到一个set中,让B1的数据去查该set是否存在交集】
- 如何扩展BloomFilter使得它支持删除元素的操作
严格来说布隆过滤器是不能删除元素的。
但是如果一定要改造布隆过滤器使他支持删除的话,那就是——计数器。有点类似于Linux中struct file当中的文件描述符的引用计数。
具体就是将每个位都标记成计数器,一旦被一个数据通过哈希地址映射过来,那就+1,两个数据映射就为2。这样删除的时候-1即可。
注意:这个计数器大小最好就是2个字节。一共可以统计216次方个数。如果是一个字节就太小了,就只能统计28次方,也就是256个。但是如果给太多的话,占据的空间就大了。而布隆过滤器本身的优势就是占据内存空间少,节省空间这个优势
4.一致性哈希
实际上的一致性哈希比我这里说的复杂的多,这里简化一下:
实际上,哈希的应用非常广,就拿朋友圈举例。每个人在微信当中都是一个用户,那就要有相对于的信息存储在微信中,比如一个人的微信号和他的朋友圈信息。微信要给用户提供快速访问朋友圈的功能,那怎么做到用户一点就是自己的朋友圈呢?数据都是存储在服务器上的。其实就是哈希、如下图所示:
但是上面的这个解决方案是有问题的!因为随着用户量的增长,数据在不断的变大!也就是服务器的数量要变多,可能从10W台变成15W台。那这样的话原先的哈希映射关系就不对了!就需要重新迁移数据,但是这么多的数据,重新映射的代价是很大的。
因此这个时候一致性哈希就出来了**。在当初计算哈希地址的时候,就不要%10w,直接%2^32次方(也可以是其他数,就是要大一些)**。这样不管怎么换服务器,当初映射的关系都是对的。就不需要进行批量的数据迁移了、如下图所示:
一个范围的数据映射到一个服务器上,如果新添加了一个服务器,就让负载较重的服务器分出一部分范围的数据,映射到新服务器上(部分的数据搬迁)。这样新服务器也有自己的映射数据范围了
比如<x1x2,T1>,即属于x1x2范围内的数据都是映射到T1机器上
- 总结:
一致性哈希就是给一个特别大的数,计算哈希地址的时候,不会因为哈希表长度的变长(增加了服务器数量)而需要重新映射大量的数据。