1 位图(哈希的应用)
1.1 位图概念
1.面试题
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。【腾讯】
对于这道面试题
我可以想出来的解决方法有
1.排序+二分查找
2.使用红黑树
3.使用哈希表
但是我们仔细的思考一下:
40亿个无符号的整数,要占据多少内存呢?
经过计算,大约需要16G的内存,在可以存储这么多的整数。因此使用我上述想出来的解决方案,一般的电脑是无法满足运行条件的
解决方案4:位图解决
数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。
如下所示:
- 位图概念
所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。
1.2 位图的实现
// 位图的类模板
template<size_t N>
class qwyset
{
public:
// 位图的构造函数
qwyset()
{
// 初始化确定大小为N的整数,需要开辟几个字节
// N/8就可以确定开辟char的数量,char占1字节
// 但是此时我们还需要注意一个问题,例如整数16/8 == 2,20/8 == 2,
// 16对应的数组的下标为2,也就是_bits[i],对应的是第3个char
// 20对应的数组的下标为2,也就是_bits[i],对应的是第3个char
// 但是如果只开辟两个字节,我们就找不到16和20对应的bit位
// 因此N除以8之后,需要再加1,开辟N/8+1个字节
// 使用(N >> 3)一定注意要加括号,因为 + 的优先级高于 >>
// _bits.resize(N/8+1, 0);
// 将N右移一位,代表N除以2,右移3位就是除以8
// 在vector类型的数组上面开辟(N >> 3) + 1个字节的空间,并将其初始化为0
_qwys.resize((N >> 3) + 1, 0);
}
// set()的作用就是将x对应第i个字节的第j个bit位,从0标记为1
void set(size_t x)
{
// 将x右移3位就相当于 x/8
// i就是x对应的char的下标(也就是x属于第i个字节)
// j就是x对应的char中bit位的下标(也就是x属于第i个字节的第j个bit位)
size_t i = x >> 3;
size_t j = x % 8;
// _qwys[i]就是x对应的第i个字节的值
// |(按位或) 有1,则按位或之后,为1(bit位进行按位或)
// 将1左移j位,_bits[i] 按位或等上(1 << j) 就会将_qwys[i]的第i个字节的第j个bit位变为1
_bits[i] |= (1 << j);
}
// reset()的作用就是将x对应的第i个字节的第j个bit位,从1标记为0
void reset(size_t x)
{
// 通过一下两行代码,找到x映射的char对应的下标i,和在char中的bit位的下标j
size_t i = x >> 3;
size_t j = x % 8;
// &(按位与) 对应的bit位都为1,则按位与之后为1,只要存在0则按位与后为0
// (1 << j) 将1左移j为,则只有下标为j的bit位为1,取反之后(~(1 << j)),则只剩下下标为j的bit位为0
// _qwys[i] 按位与等 (~(1 << j))之后,则_qwys[i]的下标为j的bit位必然为0
_bits[i] &= (~(1 << j));
}
// test()的作用就是测试x对应的第i个字节的第j个bit位是否为1
// 如果为1,则返回真,如果为0,则返回假
// 也就是测试x是否被映射了
bool test(size_t x)
{
size_t i = x >> 3;
size_t j = x % 8;
// (1 << j) 将1左移j位,则此时只有第j位的bit为1,其他位都为0
// _qwys[i] 按位与 1 << j,则除了第j位,其他位与完后都为0
// 所以如果x被映射了,那么_bits[i] 的第j位为1,按位与 (1 << j),得到一个非0的整数
// 如果x没有被映射,那么_bits[i] 的第j位为0,按位与 (1 << j),得到整数0,被返回
return _bits[i] & (1 << j);
}
private:
vector<char> _qwys;
};
- 测试
void test_qwyset()
{
// 传递100,就是开辟可以映射100以内所有数字的空间
// qwyset<100> bs1;
// 因为接收参数的N是一个无符号整型,因此传递-1,就代表32位下,unsigned int可以表示的最大正整数
// qwyset<-1> bs2;
// 0xffffffff也是 unsigned int可以表示的最大值
qwyset<0xffffffff> bs2;
bs2.set(10);
bs2.set(10000);
bs2.set(8888);
cout << bs2.test(10) << endl;
cout << bs2.test(10000) << endl;
cout << bs2.test(8888) << endl;
cout << bs2.test(8887) << endl;
cout << bs2.test(9999) << endl << endl;
bs2.reset(8888);
bs2.set(8887);
cout << bs2.test(10) << endl;
cout << bs2.test(10000) << endl;
cout << bs2.test(8888) << endl;
cout << bs2.test(8887) << endl;
cout << bs2.test(9999) << endl;
}
1.3位图应用
- 给定100亿个整数,设计算法找到只出现一次的整数?
如上图所示:
我们可以使用两个bit位来映射一个整数数,这样的话,不仅可以反映出整数有没有被映射,而且能够反映出这个整数出现了几次。
-
我们使用 00 表示这个整数出现了0次
-
我们使用 01 表示这个整数出现了1次
-
我们使用 10 表示这个整数出现了1次以上
-
两个bit位,如果放入同一个vector中,这样操作其实是比较困难的,
-
但是如果整数映射的两个bit分别在对应的两个vector中,那么映射的逻辑与
qwyset
基本是一致的,接下来我们来实现
twoqwyset
1.4位图的实现(twobitset)
// 注:100亿个整数,这些整数的范围是在0~2^32内, 因此是存在大量的重复值的
template<size_t N>
class twoqwyset
{
public:
// 对x进行映射
void set(size_t x)
{
// 当_bs1.test(x) 和 _bs2.test(x) 都为假时
// 说明x在两个vector中对应的bit位都没有映射,所以x对应的两个bit位为00
if (!_bs1.test(x) && !_bs2.test(x)) // 00
{
// 此时,是对x的第一次映射
// 因此要给_bs2对应的底位的bit位,进行映射
// 映射之后,此时x对应的两个bit位就变为了01
_bs2.set(x); // 01
}
else if (!_bs1.test(x) && _bs2.test(x)) // 01
{
// 当_bs1.test(x)为假,并且_bs2.test(x)为真,
// 则说明x映射的两个bit位为01,也就是说x已经出现了一次
// 如果再次对x映射,就需要将01变为10
// 那么就需要在_bs1中对x进行映射,将x对应的bit位从0变为1
// 在_bs2中,将x对应的bit位从1变为0,也就是使用reset
_bs1.set(x);
_bs2.reset(x); // 10
}
// 其他情况,都属于x被映射一次及一次以上,都标记为10
// 10 不变
}
// 对只出现一次的整数进行打印
void PirntOnce()
{
for (size_t i = 0; i < N; ++i)
{
// 当_bs1.test(i)为假,_bs2.test(i)为真
// 说明i对应的两个bit位为01
// 也就是i只出现了一次,所以对i进行打印
if (!_bs1.test(i) && _bs2.test(i))
{
cout << i << endl;
}
}
cout << endl;
}
private:
// qwyset是库里面有的类型(可以使用我们自己实现的,也可以使用c++库中的)
qwyset<N> _bs1;
qwyset<N> _bs2;
};
void test_twoqwyset()
{
twoqwyset<100> tbs;
int a[] = { 3, 5, 6, 7, 8, 9, 33, 55, 67, 3, 3, 3, 5, 9, 33 };
for (auto e : a)
{
tbs.set(e);
}
tbs.PirntOnce();
}
- 给两个文件,分别有
100
亿个整数,我们只有1G
内存,如何找到两个文件交集?
解题思路:
- 两个文件都有100亿个整数,那么我们将两个文件分别放入两个位图
_bs1
和_ bs2
, - 这个的话,我们就完成了两个文件中整数的去重,
- 我们再将两个位图
_bs1
和_bs2
对应的字节进行按位与,即_bs1[i] &= _bs2[i]
- 进行按位与之后,在位图
_bs1
中的bit为1对应的整数,就是两个文件的交集
- 位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
解题思路:
我们可以使用两个bit位来映射一个整数数,这样的话,不仅可以反映出整数有没有被映射,而且能够反映出这个整数出现了几次。
- 我们使用 00 表示这个整数出现了0次
- 我们使用 01 表示这个整数出现了1次
- 我们使用 10 表示这个整数出现了2次
- 我们使用 10 表示这个整数出现了3次及3次以上
1.5哈希切割
给一个超过100G
大小的log file
, log
中存着IP
地址, 设计算法找到出现次数最多的IP
地址?
2 布隆过滤器
2.1 布隆过滤器提出
我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那些已经存在的记录。 如何快速查找呢?
用哈希表存储用户记录,缺点:浪费空间
- 用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理了。
- 将哈希与位图结合,即布隆过滤器
2.2布隆过滤器概念
1.最开始的布隆过滤器
将哈希与位图结合,即布隆过滤器
如下图的3个字符串,使用哈希函数来确定 i = hashFunc(str)
,这样就可以将不同的字符串映射到不同的bit
位当中
但是这种方法存在误判
1.判断一个字符串在是不准确的:因为这个字符串可能本来不存在,但是这个位置和其他字符串的的位置冲突了,这样的话,它自己虽然
不在,但是如果别人在,那么对应的bit位依旧为1,则这样也会误判它也存在。
2.不在是准确的:因为一个字符串映射的bit位为0,则这个字符串是一定不存在的。
2.布隆过滤器的改良
布隆过滤器经过改良之后,降低了其误判率
2.3布隆过滤器的应用场景
布隆过滤器的应用场景,也就是不需要一定准确的场景
1.注册时的昵称判重
2.判断网址是否在黑名单上
3.如下图所示
2.4布隆过滤器的实现
- 根据上面这篇文章我们可以得出这样一个公式:
k = m/n*ln2
- 其中,k为哈希函数的个数,m为布隆过滤器的长度,n为插入的元素个数,
ln2
约等于 0.7 - 因此,我们可以得出
m = k*n/0.7
- 假设 k==3, 则
m == 4.2n
- 假设 k==4, 则
m == 5.7n
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;
}
};
布隆过滤器的实现
- 假设最多存储n个数据
- 平均存储一个值,开辟X
- 因此我们使用了4个哈希函数,则根据公式
m = k*n/0.7 = 4/0.7*n = 5.7n
,因此当n==1
时,我们取m==6
,也就是当存储的数据个数为1时,x为6 - 因为布隆过滤器传递的大部分的类型都为
string
,因此我们给到class K = string
缺省值,后续用到其他类型,我们也可以自己进行传参
// 布隆过滤器的类模板
template<size_t N,
// 存储一个值,那么就开辟6
size_t X = 6,
// 类型缺省为string
class K = string,
// 4个缺省的hash函数
class HashFunc1 = BKDRHash,
class HashFunc2 = APHash,
class HashFunc3 = DJBHash,
class HashFunc4 = JSHash>
class BloomFilter
{
public:
// 将key映射到位图中
void set(const K& key)
{
// 使用四种哈希函数,计算出对应key的不同的hashi的值
// 这样就可以将key映射到_bs的4个bit位中
// N为插入数据的个数,X为插入一个数据我们所要开辟的空间
// 因此布隆过滤器的长度 m = N*x
// HashFunc1()(key)(模)% 布隆过滤器的长度 就是key映射的4个位置
// 也就可以得到4个hashi
size_t hash1 = HashFunc1()(key) % (N*X);
size_t hash2 = HashFunc2()(key) % (N*X);
size_t hash3 = HashFunc3()(key) % (N*X);
size_t hash4 = HashFunc4()(key) % (N*X);
// 将对应的hashi的bit位从0标记为1
_bs.set(hash1);
_bs.set(hash2);
_bs.set(hash3);
_bs.set(hash4);
}
// 测试key是否已经被映射
bool test(const K& key)
{
// 以下四个hashi对应位置的bit位的标记如果不为1
// 则说明key并没有被映射到_bs中
// 因此只要_bs.test(hashi)为假,则返回false
size_t hash1 = HashFunc1()(key) % (N*X);
if (!_bs.test(hash1))
{
return false;
}
size_t hash2 = HashFunc2()(key) % (N*X);
if (!_bs.test(hash2))
{
return false;
}
size_t hash3 = HashFunc3()(key) % (N*X);
if (!_bs.test(hash3))
{
return false;
}
size_t hash4 = HashFunc4()(key) % (N*X);
if (!_bs.test(hash4))
{
return false;
}
// 前面判断不在都是准确,不存在误判
return true; // 可能存在误判,映射几个位置都冲突,就会误判
}
private:
std::qwyset<N*X> _bs;
};
测试
- 测试1
void test_bloomfilter1()
{
string str[] = { "猪八戒", "孙悟空", "沙悟净", "唐三藏", "白龙马1","1白龙马","白1龙马","白11龙马","1白龙马1" };
BloomFilter<10> bf;
// 将string对象的数据进行映射
for (auto& str : str)
{
bf.set(str);
}
// 测试,string对象的数据是否被映射
for (auto& s : str)
{
cout << bf.test(s) << endl;
}
cout << endl;
// 测试string对象的数据+随机值后,会不会被误判
srand(time(0));
for (const auto& s : str)
{
cout << bf.test(s + to_string(rand())) << endl;
}
}
- 测试2
void test_bloomfilter2()
{
srand(time(0));
const size_t N = 100000;
// 初始化为存储100000个数据的空间
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)
{
// 将改造后的字符串(url + std::to_string(i))插入到v1中
v1.push_back(url + std::to_string(i));
}
// 将字符串(url + std::to_string(i))映射到bf中
for (auto& str : v1)
{
bf.set(str);
}
// v2跟v1是相似字符串集,但是不一样
std::vector<std::string> v2;
for (size_t i = 0; i < N; ++i)
{
// 将(url += std::to_string(999999 + i))插入到v2中
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)
{
// (bf.test(str))如果为真,则证明bf存在误判,误判了v2中的相似于v1的字符串映射到了bf中
if (bf.test(str))
{
// 每误判一个字符串就++呢n2,因此n2代表的就是误判的字符串的个数
++n2;
}
}
cout << "相似字符串误判率:" << (double)n2 / (double)N << endl;
// v3和v1是不相似字符串集
std::vector<std::string> v3;
for (size_t i = 0; i < N; ++i)
{
string url = "zhihu.com";
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;
}
完整实现
#pragma once
#include <bitset>
namespace qwy
{
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;
}
};
// 假设N是最多存储的数据个数
// 平均存储一个值,开辟X
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:
void set(const K& key)
{
size_t hash1 = HashFunc1()(key) % (N*X);
size_t hash2 = HashFunc2()(key) % (N*X);
size_t hash3 = HashFunc3()(key) % (N*X);
size_t hash4 = HashFunc4()(key) % (N*X);
_bs.set(hash1);
_bs.set(hash2);
_bs.set(hash3);
_bs.set(hash4);
}
bool test(const K& key)
{
size_t hash1 = HashFunc1()(key) % (N*X);
if (!_bs.test(hash1))
{
return false;
}
size_t hash2 = HashFunc2()(key) % (N*X);
if (!_bs.test(hash2))
{
return false;
}
size_t hash3 = HashFunc3()(key) % (N*X);
if (!_bs.test(hash3))
{
return false;
}
size_t hash4 = HashFunc4()(key) % (N*X);
if (!_bs.test(hash4))
{
return false;
}
// 前面判断不在都是准确,不存在误判
return true; // 可能存在误判,映射几个位置都冲突,就会误判
}
private:
std::bitset<N*X> _bs;
};
void test_bloomfilter1()
{
string str[] = { "猪八戒", "孙悟空", "沙悟净", "唐三藏", "白龙马1","1白龙马","白1龙马","白11龙马","1白龙马1" };
BloomFilter<10> bf;
for (auto& str : str)
{
bf.set(str);
}
for (auto& s : str)
{
cout << bf.test(s) << endl;
}
cout << endl;
srand(time(0));
for (const auto& s : str)
{
cout << bf.test(s + to_string(rand())) << endl;
}
}
void test_bloomfilter2()
{
srand(time(0));
const size_t N = 100000;
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";
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;
}
}
2.5 布隆过滤器的应用
给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法