文章目录
- 一.位图面试题
- 1.给定100亿个整数,设计算法找到只出现一次的整数
- 2.给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
- 3.1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
- 二.布隆过滤器
- 总结
一、位图面试题
给 40 亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在
这 40 亿个数中。【腾讯】
1.遍历,时间复杂度为O(N)
2.排序(NlogN),二分查找(logN)
3.位图。时间复杂度O(1)
下面我们先讲解一下题目,首先我们要判断一个数是否存在,那么这个数只需要有两种状态即可,要不存在,要不就是不存在,而比特位中0和1就能表示存在和不存在两种状态,所以我们用一个char类型就能判断8个值是否存在(因为一字节有8个bit位),现在我们分析一下题目,首先40亿个无符号整数是多大呢我们算一下:一个整数是4字节,40*4=160亿字节,1G = 1024*1024*1024大约10亿字节,1G是10亿字节那么160亿字节就是16G,这么大的内存仅仅是为了判断某个数存不存在也太浪费了,那么我们再算算位图需要耗费多少内存:一个char可以判断8个数是否存在,所以仅需40亿/8=5亿个char就能判断40亿个数,一个char是1字节,5亿个char就是5亿字节,10亿字节是1G,5亿字节是512MB,从刚刚需要16G内存到现在512MB内存就能搞定,面试官一定会对你刮目相看的。
下面我们画图分析一下位图的原理:
如上图理解了位图的原理我们现在就开始实现吧:
namespace sxy
{
template <size_t N>
class biteset
{
public:
biteset()
{
_bits.resize(N / 8 + 1, 0);
}
private:
vector<char> _bits;
};
}
首先我们先将大致的框架写出来,N这个模板参数是我们要开多少个比特位,然后我们直接用vector来实现,vector中存char类型,一个char是8个比特位,当用户想要10个比特位的时候我们应该开几个char呢?很明显如果仅仅10/8算出来1个char是不够的,所以我们需要多开一个防止不能整除的情况,然后记得将每个char初始化为0,这样每个比特位都是0了。
//将某个数映射为比特位将那个比特位置为1
void set(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] |= (1 << j);
}
//将某个数的比特位重新置为0
void reset(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] &= ~(1 << j);
}
首先我们看set,将某个比特位设置为1很简单,我们只需要让1左移j个比特位然后或上那个位置的char,为什么是或呢?要注意L我们进行某个位的运算是不能改变其他位的,比如我们要修改第2个比特位结果把第5个也修改了,那么一定是错了,让其按位或1如下图:
上图中以10这个数演示,最后的结果变成了0 1 0 0 0 1 0 0 既没有改变原先第6个比特位的1又改变了第2个比特位将10这个数表示出来了,所以我们的代码是正确的。
下面看reset函数:
如果对于位运算不懂得还是先去看看位运算-.-。
下面我们用test函数来测试一个数是否存在:
bool test(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
return _bits[i] & (1 << j);
}
这里要注意我们只是检查某个数是否存在,不能改变原来的位图所以不是&=,注意我们的返回值是bool型,如果返回值不为0说明那个比特位为1就是存在,否则就是不存在。
当然我们也可以写一个看位图中记录多少个存在的数的函数,不过这个函数与本题无关罢了:
//位图中有映射了多少个数
size_t Count()
{
size_t count = 0;
for (size_t i = 0; i < _bits.size(); i++)
{
int j = 0;
while (j < 8)
{
if (_bits[i] & (1 << j))
{
++count;
}
++j;
}
}
return count;
}
只需要让1依次为每个char的比特位按位与一遍,不为0的就是1让计数器++即可。
下面我们测试一下:
void testbit()
{
//-1给无符号就是整形的最大值,开500多MB内存
biteset<200> bt;
bt.set(10);
cout << bt.test(10) << endl;
bt.reset(10);
cout << bt.test(10) << endl;
/*bt.set(11);
bt.set(12);
bt.set(13);
bt.set(19);
bt.set(46);
bt.set(75);
cout << bt.Count() << endl;*/
}
运行没问题我们在看看内存中的值:
可以看到刚开始设置的时候确实将char的第2个比特位改为1了,因为内存中是16进制,所以
0000 0100在内存显示 :0 4 然后reset的时候又改回了00.下面我们多放几个值测试一下:
也是没问题的,这里我们讲一下如何开最大整形的比特位,我们的N是size_t无符号整数,当我们传-1时那么就是整形的最大值,如下图:
1.给定100亿个整数,设计算法找到只出现一次的整数?
其实很简单,还是我们刚刚的位图,只需要变形一下。我们用两个位图,这样每个值就有两个比特位来表示,00表示0次,01表示1次,10表示多次,然后没有其他情况,下面我们实现一下:
template <size_t N>
class two_biteset
{
public:
void set(size_t x)
{
//如果这个值没有被映射过
if (_bs1.test(x) == false && _bs2.test(x) == false)
{
// 00 我们以bs1代表左边那个0,bs2代表右边那个0
//要想00->01 只需要让s2变1
_bs2.set(x);
}
//如果这个值被映射过1次 01
else if (_bs1.test(x) == false && _bs2.test(x) == true)
{
//将1次改为多次,多次用10表示 01->10
_bs1.set(x);
_bs2.reset(x);
}
}
//打印出现1次的数
void print()
{
for (size_t i = 0; i < N; i++)
{
if (_bs2.test(i) == true)
{
cout << i << " ";
}
}
cout << endl;
}
private:
biteset<N> _bs1;
biteset<N> _bs2;
};
我们直接复用刚刚的位图结构,代码中我们已经注释过了,需要注意的是,当用户开100个比特位时,那么要set的数一定是在0-100这个区间内的,超出这个区间的数算出来的第多少个char是越界的,所以我们打印的时候按照范围0-N即可。下面测试一下:
void test_twobite()
{
int a[] = { 1,1,4,4,89,95,45,45,65,65,25,25 };
two_biteset<100> bt;
for (auto& e : a)
{
bt.set(e);
}
bt.print();
}
可以看到并没有问题,这样就解决了100亿个整数找出只出现1次的数的问题了。
2.给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
两个文件,100亿整数是400亿字节,也就是40G,两个40G的文件如何找交集呢?
1.将一个文件的数据映射到位图中,然后遍历第二个文件,判断第二个文件中的数是否在位图中存在,如果存在就是交集。当然有可能发生:第一个文件{1,3,4},第二个文件{1,1,3,4,4},这个时候判断出来的交集是{1,1,3,4,4,}也就是会有重复的值,所以我们可以再去一次重。当然也可以不去重,直接看是否存在,存在就保存这个交集中的值然后将这个值映射的比特位置为0,这样下面的重复值就不会被保存。
2.将两个文件分别映射到两个位图中,这样做可以直接去除两个文件中的重复值。然后范围遍历,两个位图中某个数都为true则这个数就是交集,代码如下:
for (size_t i = 0;i<N;i++)
{
if (bs1.test(i)==true&&bs2.test(i)==true)
{
//则是交集
}
}
3. 1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
不超过两次的整数就是1次和2次,所以我们将上面的代码修改一下,让10表示2次,11表示多次。
template <size_t N>
class three_biteset
{
public:
void set(size_t x)
{
//如果这个值没有被映射过
if (_bs1.test(x) == false && _bs2.test(x) == false)
{
// 00 我们以bs1代表左边那个0,bs2代表右边那个0
//要想00->01 只需要让s2变1
_bs2.set(x);
}
//如果这个值被映射过1次 01
else if (_bs1.test(x) == false && _bs2.test(x) == true)
{
//将1次改为2次,2次用10表示 01->10
_bs1.set(x);
_bs2.reset(x);
}
else
{
//多次用11表示
_bs1.set(x);
_bs2.set(x);
}
}
void print()
{
for (size_t i = 0; i < N; i++)
{
if (_bs1.test(i) == true && _bs2.test(i) == false
|| _bs1.test(i) == false && _bs2.test(i) == true)
{
cout << i << " ";
}
}
cout << endl;
}
private:
biteset<N> _bs1;
biteset<N> _bs2;
};
下面我们测试一下:
void test_threebite()
{
int a[] = { 1,1,1,4,4,4,89,95,45,45,45,65,65,65,25,25,25,45 };
three_biteset<100> bt;
for (auto& e : a)
{
bt.set(e);
}
bt.print();
}
我们总结一下位图的优缺点:
优点:速度快,节省空间
缺点:只能映射整形,其他类型如:浮点数,string等等不能存储映射。
二、布隆过滤器
布隆过滤器实际上就是位图和哈希的组合,位图只能处理整形处理不了字符串,而布隆过滤器可以映射字符串,如下图(图片是转载):
这个时候向布隆过滤器插入“baidu”:
也就是说我们让哈希函数将字符串进行转化,转化后将这个值映射到位图中,学过哈希我们都知道哈希冲突是解决不了的,所以一定会存在冲突问题(一个值映射一个位置有很大概率会冲突,一个值映射多个位置就可以减少冲突),为了避免冲突我们就引入多个哈希函数,让多个哈希函数将字符串转化后映射到不同的比特位。
了解了以上知识后问大家一个问题,布隆过滤器是判断在的时候会被冲突还是判断不在的时候会被冲突呢?这里一定是在的时候被冲突,如下图:
现在有baidu和left两个字符串,如果有一个字符串hello计算出来的哈希值映射到位图中正好是baidu和left被置为1的位置,那么这个时候就会误判hello是存在,但实际上hello并不存在。所以布隆过滤器一定可以判断某个字符串不存在,而对于存在的情况会产生误判。所以布隆过滤器的使用场景是:能容忍误判的场景。比如:注册时快速判断昵称是否使用过
那么手机号码是否注册过可以用布隆过滤器判断吗?可以,但是一定要结合数据库。我们用布隆过滤器判断手机号码是否注册过,因为布隆过滤器判断不存在是准确的,所以如果不存在那么就一定没有注册过,如果判断存在我们就让其去数据库再判断一次,最后的结果是看数据库的结果,这样的效率比直接去数据库查找手机号码是否注册快了很多,因为数据库是在磁盘中存储的。
下面我们实现一下:
struct BKDRHash
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto& e : s)
{
hash += e;
hash *= 31;
}
return hash;
}
};
struct APHash
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (long i = 0; i < s.size(); i++)
{
size_t ch = s[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& s)
{
size_t hash = 5381;
for (auto ch : s)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
template <size_t N, class K = string,
class Hash1 = BKDRHash,
class Hash2 = APHash,
class Hash3 = DJBHash>
class BloomFilter
{
public:
void set(const K& key)
{
size_t hash1 = Hash1()(key) % N;
//用第一个哈希函数映射位图
_bs.set(hash1);
size_t hash2 = Hash2()(key) % N;
//用第二个哈希函数映射位图
_bs.set(hash2);
size_t hash3 = Hash3()(key) % N;
//用第三个哈希函数映射位图
_bs.set(hash3);
}
bool test(const K& key)
{
size_t hash1 = Hash1()(key) % N;
if (_bs.test(hash1) == false)
{
return false;
}
size_t hash2 = Hash2()(key) % N;
if (_bs.test(hash2) == false)
{
return false;
}
size_t hash3 = Hash3()(key) % N;
if (_bs.test(hash3) == false)
{
return false;
}
return true;
}
private:
sxy::biteset<N> _bs;
};
首先模板参数中的N代表最多要插入多少个key数据的个数,K是存储数据的类型,然后就是3个哈希函数,当然也可以多来几个哈希函数。set也很简单,就是用哈希函数算出K类型的值然后去位图中映射,要注意的是我们每次算出来后都要对N进行一次取模。查找是否存在也很简单,只需要判断每一个哈希函数算出来的值映射的比特位是否为1,如果有一个为0那么一定不存在(这点我们上面说过,布隆过滤器判断不存在是准确的)。注意:布隆过滤器天生不支持删除,如果实现删除可以像我们用两个位图实现计数那样,要删除只需要--即可。
下面是哈希算法的网址:https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html
下面我们优化一下布隆过滤器:
也就是说哈希函数的个数和N之间有一个关系,3 = (m/n)*0.69,然后同时除0.69得到m与n之间是4倍的关系。
void set(const K& key)
{
size_t len = N * _x;
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);
}
bool test(const K& key)
{
size_t len = N * _x;
size_t hash1 = Hash1()(key) % len;
if (_bs.test(hash1) == false)
{
return false;
}
size_t hash2 = Hash2()(key) % len;
if (_bs.test(hash2) == false)
{
return false;
}
size_t hash3 = Hash3()(key) % len;
if (_bs.test(hash3) == false)
{
return false;
}
return true;
}
下面我们打印哈希函数看一下冲突的情况:
void test_bloomfilter()
{
BloomFilter<100> bf;
bf.set("sort");
bf.set("bloom");
bf.set("hello world");
bf.set("test");
bf.set("etst");
bf.set("estt");
}
我们可以看到sort字符串的hash3这个值和bloom字符串的hash1冲突了都是相同的值,下面我们用一个测试用例来测试布隆过滤器发生冲突的百分比:
void test_bloomfilter2()
{
srand(time(0));
const size_t N = 10000;
BloomFilter<N> bf;
std::vector<std::string> v1;
std::string url = "https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html";
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 url = "https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html";
url += std::to_string(999999 + i);
v2.push_back(url);
}
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 = "https://www.cctalk.com/m/statistics/live/16845432622875";
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;
}
v1是字符串集,v2是v1的相似字符串集合,下面我们运行一下:
测试后我们发现4倍的误判率在15%左右,下面我们换成5倍看看:
可以发现5倍时误判率下降到了%6,但这是用空间换来的,以上就是布隆过滤器,下面我们做两道面试题:
1. 给两个文件,分别有 100 亿个 query ,我们只有 1G 内存,如何找到两个文件交集?分别给出
精确算法和近似算法
query查询,本质query就是一个字符串,如:sql语句
100亿个query大概要占用多少空间?假设单个query平均50字节,100亿query就是5000亿字节,500G内存。两个这么大的文件如何找交集呢?近似算法:将每个文件都切成一个个的小文件,这里要用哈希切割,哈希切割:i = HashFunc(query)%1000,然后每个query算出的对应的i是多少就进入Ai号小文件,第二个文件同样的操作,然后我们根据算出的i编号让第一个文件中的小文件和第二个文件中的小文件找交集。但是这样会有个问题:因为不是平均切分,可能会出现冲突多,每个Ai,Bi小文件过大。我们将上面的问题分为两个情况:
1.单个文件中有某个大量重复的query。
2.单个文件中,有大量不同的query。
利用set/unordered_set去重,依次读取文件query,插入set中,1.如果读取整个小文件query,都可以成功插入set,那么说明是情况1.
如果读取整个小文件query,插入过程中抛异常,则是情况2,这种情况直接换其他的哈希函数再次分割,然后再求交集。什么意思呢,就是说:set插入key,如果已经有了就返回false,如果没有内存则会抛bad_alloc异常,剩下的都会成功。
2. 如何扩展 BloomFilter 使得它支持删除元素的操作
我们之前用两个位图实现了计算数据1次两次或多次,如果要让布隆过滤器支持删除只需要把映射的值改为可以记录次数的,每次删除只需要--即可。
总结
布隆过滤器的查找
布隆过滤器的思想是将一个元素用多个哈希函数映射到一个位图中,因此被映射到的位置的比特
位一定为 1 。所以可以按照以下方式进行查找: 分别计算每个哈希值对应的比特位置存储的是否为
零,只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中 。
注意:布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可
能存在,因为有些哈希函数存在一定的误判。
布隆过滤器删除
布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。
比如:删除上图中 "tencent" 元素,如果直接将该元素所对应的二进制比特位置 0 , “baidu” 元素也
被删除了,因为这两个元素在多个哈希函数计算出的比特位上刚好有重叠。
一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给 k 个计
数器 (k 个哈希函数计算出的哈希地址 ) 加一,删除元素时,给 k 个计数器减一,通过多占用几倍存储
空间的代价来增加删除操作。
缺陷:
1. 无法确认元素是否真正在布隆过滤器中
2. 存在计数回绕
布隆过滤器优点
1. 增加和查询元素的时间复杂度为 :O(K), (K 为哈希函数的个数,一般比较小 ) ,与数据量大小无关
2. 哈希函数相互之间没有关系,方便硬件并行运算
3. 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
4. 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
5. 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
6. 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
布隆过滤器缺陷
1. 有误判率,即存在假阳性 (False Position) ,即不能准确判断元素是否在集合中 ( 补救方法:再
建立一个白名单,存储可能会误判的数据 )
2. 不能获取元素本身
3. 一般情况下不能从布隆过滤器中删除元素
4. 如果采用计数方式删除,可能会存在计数回绕问题