C++哈希扩展
1、位图
1.1、位图面试题
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。
思路1:暴力遍历这40亿个数,时间复杂度为O(N),效率太低。
思路2:排序+二分查找,时间复杂度为O(NlogN)+O(logN),也是不行的,需要存储数据消耗空间太大。
思路3:map和set,这就更不行了,使用map和set不仅要存储整型数据,还有指针等额外开销,消耗的空间会更大。
我们来算算40亿个整数需要多少空间:

1G算下来差不多是10亿+字节,那么40亿整数就是160亿字节,算下来差不多就是16G,这是数组的开销,如果是map和set的开销就要远大于16G,所以我们需要利用位图来解决。
1.2、位图设计及实现
位图本质是一个直接定址法的哈希表,每个整型值映射一个bit位,位图提供控制这个bit的相关接口。

我们通过比特位来映射数据,比特位为0表示数据不存在,比特位为1表示数据存在。
那么将40亿整数映射到比特位上,现在需要判断某个值是否存在,直接到这个值对应的比特位上去找,如果是1就存在,否则不存在。
然后比特位的表示我们可以通过整型或字符来表示,如图使用的就是整型。一个整型可以映射32个数据,如果是字符型可以映射8个数据。
那么需要开多少空间呢?实际上无符号整数的最大值就是42亿多,范围就是在0~42亿多,也就是2^32。最多需要2^32个比特位,一个字节是8个比特位,也就是说最多需要2^29字节,而1G差不多是2^30字节,所以需要0.5G内存。
位图的结构如下:
#pragma once
#include <vector>
namespace zzy
{
template<size_t N>
class bitset
{
public:
bitset()
{
_a.resize(N / 32 + 1);
}
void set(size_t n)
{
}
void reset(size_t n)
{
}
bool test(size_t n)
{
}
private:
vector<int> _a;
};
}
这里给了一个非类型模板参数N,使用时需要传入,用来给构造函数开空间,避免空间过大或过小。
由于开空间是按int,4个字节来开的,所以需要除去32再加1,向上取整。
set函数就是把数据映射的比特位置为1,reset就是把数据映射的比特位置为0,test是用来判断数据是否存在。
下面来实现位图的主要接口:

代码如下:
#pragma once
#include <vector>
namespace zzy
{
template<size_t N>
class bitset
{
public:
bitset()
{
_a.resize(N / 32 + 1);
}
void set(size_t x)
{
size_t i = x / 32;
size_t j = x % 32;
_a[i] |= (1 << j);
}
void reset(size_t x)
{
size_t i = x / 32;
size_t j = x % 32;
_a[i] &= (~(1 << j));
}
bool test(size_t x)
{
size_t i = x / 32;
size_t j = x % 32;
return _a[i] & (1 << j);
}
private:
vector<int> _a;
};
}
1.3、C++库中的位图bitset


C++标准库中的bitset包含于头文件<bitset>,使用的时候也是需要根据我们传入的数据进行扩容。主要的函数就是set、reset、test。
不过它还实现了operator[]函数,我们可以看一下:

支持两个函数,一个是判断是否存在返回true/false,另一个是返回引用可以修改比特位的值。
1.4、位图相关考察题目
1、给定100亿个整数,设计算法找到只出现一次的整数?

方式一:可以使用两个比特位来映射一个数据,比特位的00表示没有出现过,01出现过一次,10出现过两次。将所有数据映射到位图,然后判断对应的标识是不是01。
方式二:直接使用两个位图来控制,跟方式一一样,00、01、10分别表示没有出现过、出现过一次、出现过两次。出现过两次以上的就不用管了。而且比方式一更加简便。
下面我们直接手撕一个方式二:
template<size_t N>
class twobitset
{
public:
void set(size_t x)
{
if (!_bs1.test(x) && !_bs2.test(x))
{
_bs2.set(x);
}
else if (!_bs1.test(x) && _bs2.test(x))
{
_bs1.set(x);
_bs2.reset(x);
}
}
bool is_once(size_t x)
{
return !_bs1.test(x) && _bs2.test(x);
}
private:
bitset<N> _bs1;
bitset<N> _bs2;
};
2、给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
一个思路是:一个文件所有值映射到一个位图中,然后遍历另一个文件根据位图判断是否有交集。但是这种方式会出现重复的问题,出来的交集需要再次去重。
更好的思路是:两个文件分别映射到两个位图中,对应的数据位置按位与一下,如果为1说明有交集,否则没有交集。
两个位图刚好1G内存:

3、一个文件有100亿个整数,1G内存,设计算法找到出现次数不超过2次的所有整数
类似问题一,我们利用两个位图,00表示没有出现过,01表示出现过一次,10表示出现过两次,11表示出现两次以上,最后统计出所有01和10标记的值即可。
位图的优点:增删查改快,节省空间。
位图的缺点:只适用于整型。
2、布隆过滤器
2.1、什么是布隆过滤器
现在我们的数据变成字符串,那么就不能使用位图了,而使用红黑树和哈希表也可能内存空间不足,因此就需要布隆过滤器来解决了。
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你“某样东西⼀定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。

如果数据是字符串,我们需要先使用哈希函数计算出一个数值,然后再用这个值%容量,然后映射到对应的位置。现在来新的字符串需要判断是否存在,思考:存在是否会误判,不存在是否会误判?
存在是可能会误判的,因为不同的字符串经过哈希函数处理的值可能相同,这是第一层,哈希函数处理后的值%容量计算出来的值也可能相同,这是第二层。因此存在可能误判,而不存在是不会误判的。
那么误判可以消除吗?答案是不行的,像哈希表也存在哈希冲突的问题。这里的误判是不可能完全消除的,我们只能说尽可能的降低误判,这就是布隆过滤器的思路。
布隆过滤器的思路就是把key先映射转成哈希整型值,再映射一个位,如果只映射一个位的话,冲突率会比较多,所以可以通过多个哈希函数映射多个位,降低冲突率。
布隆过滤器这里跟哈希表不一样,它无法解决哈希冲突的,因为他压根就不存储这个值,只标记映射的位。它的思路是尽可能降低哈希冲突。判断一个值key在是不准确的,判断一个值key不在是准确的。

如图:布隆过滤器将字符串映射到三个位置,判断一个字符串是否存在需要保证映射的三个位置数据都是1,如果有一个位置数据不为1,说明该字符串不存在。映射到三个位置还是可能会出现误判的问题,只不过误判的概率小了很多。

这里有一个应用场景,注册账户时快速判断昵称是否被注册过。将所有已有的昵称放到过滤器中,然后用户输入昵称后将这个昵称进行比对,能快速判断昵称是否被注册过。虽然可能误判,但是误判了并没有关系。
如果需要精确判断呢?也还是可以使用布隆过滤器,可以快速判断昵称不存在返回,如果存在再去数据库中查询,这样相比原来不管存在或不存在都直接去数据库查询效率来得高,降低了数据库查询压力,提高效率。
这里有一个布隆过滤器误判率的推导,但是我们直接记下结论就好:
m:布隆过滤器的bit长度。n:插⼊过滤器的元素个数。k:哈希函数的个数。
在m和n一定,在对误判率公式求导,误判率尽可能小的情况下,可以得到hash函数个数:k = m/n * ln2时误判率最低。
2.2、实现布隆过滤器
#pragma once
#include <bitset>
struct BKDRHash
{
size_t operator()(const string& str)
{
size_t hash = 0;
for (auto ch : str)
{
hash = hash * 131 + ch;
}
return hash;
}
};
struct APHash
{
size_t operator()(const string& str)
{
size_t hash = 0;
for (size_t i = 0; i < str.size(); i++)
{
size_t ch = str[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& str)
{
size_t hash = 5381;
for (auto ch : str)
{
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:
bitset<N> _bs;
};
这里我们直接使用C++标准库中的bitset,同时把数据类型实现成模板,当数据是其他类型时,我们可以传入哈希函数处理其他数据。
思考:布隆过滤器能支持删除吗?
布隆过滤器默认是不支持删除的,因为比如"猪八戒“和”孙悟空“都映射在布隆过滤器中,他们映射的位有一个位是共同映射的(冲突的),如果我们把孙悟空删掉,那么再去查找“猪八戒”会查找不到,因为“猪八戒”间接被删掉了。
解决方案:可以考虑计数标记的方式,一个位置用多个位标记,记录映射这个位的计数值,删除时,仅仅减减计数,那么就可以某种程度支持删除。但是这个方案也有缺陷,如果一个值不在布隆过滤器中,我们去删除,减减了映射位的计数,那么会影响已存在的值,也就是说,一个确定存在的值,可能会变成不存在,这里就很坑。当然也有人提出,我们可以考虑计数方式支持删除,但是定期重建一下布隆过滤器,这样也是一种思路。
2.3、布隆过滤器的应用
首先我们分析一下布隆过滤器的优缺点:
优点:效率高,节省空间,相比位图,可以适用于各种类型的标记过滤。
缺点:存在误判(在是不准确的,不在是准确的),不好支持删除。
布隆过滤器在实际中的一些应用:
1、爬虫系统中URL去重:
在爬虫系统中,为了避免重复爬取相同的URL,可以使用布隆过滤器来进行URL去重。爬取到的URL可以通过布隆过滤器进行判断,已经存在的URL则可以直接忽略,避免重复的网络请求和数据处理。
2、垃圾邮件过滤:
在垃圾邮件过滤系统中,布隆过滤器可以用来判断邮件是否是垃圾邮件。系统可以将已知的垃圾邮件的特征信息存储在布隆过滤器中,当新的邮件到达时,可以通过布隆过滤器快速判断是否为垃圾邮件,从而提高过滤的效率。
3、预防缓存穿透
在分布式缓存系统中,布隆过滤器可以用来解决缓存穿透的问题。缓存穿透是指恶意用户请求一个不存在的数据,导致请求直接访问数据库,造成数据库压力过大。布隆过滤器可以先判断请求的数据是否存在于布隆过滤器中,如果不存在,直接返回不存在,避免对数据库的无效查询。
4、对数据库查询提效
在数据库中,布隆过滤器可以用来加速查询操作。例如:一个app要快速判断一个电话号码是否注册过,可以使用布隆过滤器来判断一个用户电话号码是否存在于表中,如果不存在,可以直接返回不存在,避免对数据库进行无用的查询操作。如果在,再去数据库查询进行二次确认。
3、海量数据处理问题
1、给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法。
近似算法:直接使用布隆过滤器,一个文件的query放进布隆过滤器,另一个文件依次查找,在的话就是交集。问题是交集不够准确,可能存在误判。
精确算法:哈希切分,首先内存的访问速度远大于硬盘,大文件放到内存搞不定,那么我们可以考虑切分为小文件,再放进内存处理。
假设平均一个query是30byte,100亿query就是3000亿byte,而1G大概是10亿+byte,所以大概就是300G。所以肯定是要切分为小文件的,那么可以平均切分吗?假设平均切分为600个小文件,每个小文件就是500M,好像也还行,但是进行比对的时候每个小文件需要与另一个文件中切分的所有小文件进行比对,这样效率必定就很低,所以也不能平均切分,需要使用哈希切分。

哈希切分:现在我们要把大文件分为1000个小文件,我们遍历文件中的所有query,然后通过哈希函数计算出一个值再去%1000,即i = Hash(query)%1000,计算出的i是多少就进入第i个小文件,两个大文件都这样处理,第一个大文件处理出A1->A1000,第二个大文件处理出B1->B1000。哈希切分的关键在于:相同的query必定会进入编号为i的小文件。也就是说,我们只需要比对Ai和Bi,编号i相同的文件即可。
那么对于每个小文件,我们可以直接将Ai中所有query读入set,然后依次读取Bi的query,判断在不在,在就是交集然后删除,因为可能存在重复的query,如果不删除就会重复判断。
但是这里还有一个问题,哈希切分并不是平均切分,如果冲突太多会导致某个Ai文件太大,超过1G内存,这时候该怎么办?
这时候有两种场景:首先假设这个文件是5G
1、4G都是相同的query,1G冲突。
2、大多数都是冲突。
针对第二种情况,我们可以换个哈希函数再次进行哈希切分,但是第一种情况使用哈希切分是不行的。
解决方案:
1、把Ai中的query读到set,如果set的insert报错抛异常(bad_alloc),那就说明是大多数query冲突。如果能够全部读出来insert到set里面,说明大多数都是重复的,全部insert之后另一个文件直接拿来判断即可。
2、如果抛异常说明有大量冲突,换一个哈希函数进行二次切分。
2、如何扩展BloomFilter使得它支持删除元素的操作?
多个位标识一个值,使用引用计数。
3、给一个超过100G大小的log file,log中存着ip地址,设计算法找到出现次数最多的ip地址?查找出现次数前10的ip地址?
哈希切分:i = Hash(ip) % 300,可以保证相同的ip一定进入到编号相同的小文件中。然后用map去分别统计每个小文件中ip出现的次数,超过当前最多次数ip就更新。
如果是查找出次数前十呢?直接建一个10个数据的小堆,每次统计次数判断是否比堆顶元素大,如果比堆顶元素大就弹出堆顶元素然后进堆,最后遍历完所有小文件,堆里的10个ip地址就是出现次数最多的10个ip地址。相当于topK问题。
5030

被折叠的 条评论
为什么被折叠?



