🎈个人主页:库库的里昂
✨收录专栏:C++从练气到飞升
🎉鸟欲高飞先振翅,人求上进先读书🎉
目录
⛳️推荐
一、位图
1.1 一道面试题
1.2 位图的概念
1.3 位图的模拟实现
1.4 位图的应用
1.4.1 给定100亿个整数,设计算法找到只出现一次的整数
1.4.2 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
1.4.3 1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
二、布隆过滤器
2.1 布隆过滤器的提出
2.2 布隆过滤器的概念
2.3 布隆过滤器的插入
2.4 布隆过滤器的查找
2.5 布隆过滤器的删除
2.6 布隆过滤器的优点
2.7 布隆过滤器的缺陷
2.8 布隆过滤器的实际应用场景
三、哈希切分
⛳️推荐
前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站
一、位图
1.1 一道面试题
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。
解决方案:
-
遍历:遍历这40亿个整数,去判断该数是否在这40亿个整数中。时间复杂度是 O(N)。
-
set:用 set 将这40亿个整数存起来,然后去判断这个数是否在 set 中。时间复杂度是O(log2^N)。
-
排序 + 二分查找:快排的时间复杂度是O(N * log2^N),二分查找的时间复杂度是O(log2^N)。
存在的问题:40亿个整数是160亿Byte。由1G=1024MB=1024∗1024Kb=1024∗1024∗1024Byte,推出10亿Byte≈1G。所以160亿Byte≈16G(不到 16G)。上面这三种方法都需要将这40亿个整数先存储起来,这就需要从内存中分配 16G 空间来存储(假设单纯的用数组来存储,如果使用 set,set 中的节点不止存储整型数据,还会存储一些附带的东西,比如指针,最终需要的内存空间是远大于 16G 的),但是普通电脑的内存大小就是 8G 或者 16G,上面这三种做法显然是不符合实际的。那该怎么去解决呢?这道题目本质上是让我们去判断在不在,因此我们无需将这 40亿 个整数都存起来,只需要用一个标志位去判断在不在,比如 1 表示在,0 表示不在。存储标志位的最小单位是 bit。大体思路有了,下面引出位图的概念,这道题目需要使用位图去解决。
1.2 位图的概念
所谓位图,这里位指的就是比特位,位图就是用每一个比特位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。
如上图所示,要判断一个数字是否在 array 数组中,这里我们可以借助位图来实现。这里我们采用直接定址法,即 array 中的每一个数字都对应一个比特位,互相不重复。这就要求位图的大小是 array 数组中整型变量的范围。array 中整型的范围是 [1,22],里面一共包含 22 个整型,因此这里的位图需要 22 个比特位。一个字符型 char 会向内存申请一个字节(8比特),所以这里我们创建一个可以存储 3 个字符的字符数组来充当位图。有了位图之后,接下来我们就需要对位图中的每一位进行标记,将 array 中的整型所对应的位图中的位设置成 1。然后给我们一个整型数据 x,要判断 x 是否在 array 中,我们只需要去判断 x 对应位图中的那个位上是 1 还是 0,如果是 1 就说明 array 中存在 x 这个整型,如果是 0 说明 array 中不存在 x 这个整型。
回到上面的面试题:先创建一个位图,位图的大小取决于数据的范围,面试题中说的是40亿个不重复的无符号整型,所以这里的数据范围就是无符号整型的范围,即 0~4294967295。因此我们需要向内存申请 4294967295 个比特位也就是2^32个比特位。2^32个比特位就是一个字节,所以2^32个比特位就是2^29个字节。由1G = 1024MB = 1024*1024Kb = 1024*1024*1024 Byte = 2^30 Byte,推出2^29Byte = 0.5G = 500MB。对普通电脑来说,向内存申请 0.5G 的空间还是轻轻松松的。
1.3 位图的模拟实现
template<size_t N>//这里的 N 是一个非类型模板参数,表示要开 N 个比特位
class bitset
{
public:
bitset()
{
size_t size = N / 32 + 1;
_a.resize(size, 0);
//最坏情况下,N / 32 能够整除,那就会浪费一个整型的大小,即四个字节。
}
//把 x 映射的那个比特位标记成1
void set(size_t x)
{
size_t i = x / 32;
size_t j = x % 32;
_a[i] |= (1 << j);//位运算的左移和右移不是方向,左移是往高位进行移动,右移是往地位进行移动,至于底层到底是大端机还是小端机我们根本不用关心是怎么移动的
}
//把 x 映射的那个比特位标记成0
void reset(size_t x)
{
size_t i = x / 32;
size_t j = x % 32;
_a[i] &= ~(1 << j);
}
//检测 x 映射的那个比特位是 0 还是 1
bool test(size_t x)
{
size_t i = x / 32;
size_t j = x % 32;
return _a[i] & (1 << j);
}
private:
vector<int> _a;
};
小Tips:C++ 中没有一个类型的大小是一个比特位,因此我们采用一个整型数组的方式,一个整型 4 个字节,32 个比特位。bitset 这个类采用了非类型模板参数,用户将需要开辟多少个比特位传进来,然后开辟对应大小的数组。其次位图中的一个重点内容就是找到 x 对应的比特位,这里分两步,第一步要先找到 x 属于数组中的哪一个整型,用x/32即可以得到,然后需要确定 x 对应该整型的哪一位,用x%32即可以得到。 找到对应的比特位后,还要对该比特位进行置 0 或置 1 操作来进行标记,因为我们的底层是用的整型数组,所以对某一个比特位进行置 0 或置 1 操作,本质上是对整型数组中的某一个整型进行位操作。
1.4 位图的应用
1.4.1 给定100亿个整数,设计算法找到只出现一次的整数
思路:回顾一下上面 40亿 个整数判断在不在的问题,我们的做法是让所有的整数都对应一个比特位,这个比特位用来标记该整型是否出现在这 40亿 个整型中。这里为什么每一个整型只需要对应一个比特位?因为一个整型只有在这 40亿 个数里面和不在这 40亿 个数里面两种情况,这两种情况我们分别可以用 1 和 0 来表示,而 1 和 0 都只占用一个比特位。
回到这道题目,我们任然可以采用标记位的方式来处理。对于一个整数来说,它在这 100亿 个整数中出现的情况有三种:出现了 0 次,出现了 1 次,出现了多次(两次及以上)。这道题目我们只需要这三种标记即可,一个比特位可以表示两个标记,那要想能够表示三个标记就至少需要两个比特位了,两个比特位一共有四种组合:00、01、10、11,完全够用了。总结:这道题目要对上面提到的位图稍作修改,这道题目中一个整数需要对应两个比特位。我们现有的位图都是一个整数对应一个比特位,自己再重新写一个对应两个比特位的会比较麻烦,这里我们可以直接使用现成的,开辟两个位图即可。
template<size_t N>
class twobitset
{
public:
void set(size_t x)
{
//00-->01
if (!_bs1.test(x) && !_bs2.test(x))
{
_bs2.set(x);
}
else if (!_bs1.test(x) && _bs2.test(x))//01-->10
{
_bs1.set(x);
_bs2.reset(x);
}
//如果是10,那就表示出现次数是两次及以上
}
bool is_once(size_t x)
{
return !_bs1.test(x) && _bs2.test(x);
}
private:
bitset<N> _bs1;
bitset<N> _bs2;
};
void Testtwobitset()
{
int arr[] = { 1,1,2,3,77,7,7,7,90,100,100,90 };
wcy::twobitset<101> tbs;
for (auto e : arr)
{
tbs.set(e);
}
for (auto e : arr)
{
if (tbs.is_once(e))
{
cout << e << ' ';
}
}
}
1.4.2 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
思路一:将其中一个文件的所有值映射到一个位图,然后去遍历另一个文件中的数据去判断在不在。这种思路存在一个缺陷,得到的交集中可能出现重复值的情况,而正常情况下交集中是不应该出现重复值,因此在前面求得交集后,还需要用 set 进行去重。这里可能会出现重复的数据过多的情况,去使用 set 可能会超过 1G 内存。
思路二: 将两个文件中的整数分别映射到两个位图中,然后再将两个位图按位与一下,得到一个新的位图,该位图中值为 1 的比特位对应的整数就是交集。
void Testintersection()
{
int arr1[] = { 1,1,2,3,77,7,7,7,90,100,100,90 };
int arr2[] = { 1111,12,2,31,771,71,7,70,901,100,10011,90 };
wcy::bitset<10012> bs1;
wcy::bitset<10012> bs2;
for (auto e : arr1)
{
bs1.set(e);
}
for (auto e : arr2)
{
bs2.set(e);
}
for (size_t i = 0; i < 10012; ++i)
{
if (bs1.test(i) && bs2.test(i))
{
cout << i << ' ';
}
}
}
小Tips:上面用两个数组 arr1 和 arr2 来模拟两个文件。另外需要注意:位图到底需要多少个比特位不是取决于数据的个数,而是取决于这一堆数据可能的范围,数据范围有多大位图就需要开辟多少个比特位。
1.4.3 1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
思路:这道题的思路和 1.4.1 的思路是相同的,用两个位图,产生两个标记位来解决即可,这里就不再过多赘述。
二、布隆过滤器
2.1 布隆过滤器的提出
我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的?用服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那些已经存在的记录。如何快速查找呢?
-
用哈希表存储用户记录,缺点:浪费空间。
-
用位图存储用户记录,缺点:位图一般只能处理整型,如果内容编号是字符串,就无法处理了。
-
将哈希与位图结合,即布隆过滤器。
2.2 布隆过滤器的概念
布隆过滤器是由布隆(Burton Howard Bloom)在 1970 年提出的一种紧凑型的、比较巧妙的概率型数据结构特点是高效地插入和查询,可以用来告诉你“某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
小Tips:布隆过滤器如果判断出不再那么一定是不再,如果判断出在那么有可能是误判。因为在或不再分别是用 1 和 0 来标记的,如果不再那么标记位为 0,一定是真的。如果在,说明标记位是 1,这个 1 并不可信,因为这个 1 有可能是其它数据映射到这里来的,将该比特位置为 1。这个问题无法完全解决,只能通过增加哈希函数,让一个数据映射多个比特位,来尽最大的努力避免冲突发生,依此降低误判率。这里给大家分享一篇关于布隆过滤器的文章:详解布隆过滤器的原理,使用场景和注意事项。感兴趣的小伙伴可以去看看。
2.3 布隆过滤器的插入
//Bloom.h
struct BKDRHash
{
size_t operator()(const string& str)
{
register size_t hash = 0;
for (auto e : str)
{
hash = hash * 131 + e; // 也可以乘以31、131、1313、13131、131313..
// 有人说将乘法分解为位运算及加减法可以提高效率,如将上式表达为:hash = hash << 7 + hash << 1 + hash + ch;
// 但其实在Intel平台上,CPU内部对二者的处理效率都是差不多的,
// 我分别进行了100亿次的上述两种运算,发现二者时间差距基本为0(如果是Debug版,分解成位运算后的耗时还要高1/3);
// 在ARM这类RISC系统上没有测试过,由于ARM内部使用Booth's Algorithm来模拟32位整数乘法运算,它的效率与乘数有关:
// 当乘数8-31位都为1或0时,需要1个时钟周期
// 当乘数16-31位都为1或0时,需要2个时钟周期
// 当乘数24-31位都为1或0时,需要3个时钟周期
// 否则,需要4个时钟周期
// 因此,虽然我没有实际测试,但是我依然认为二者效率上差别不大
}
return hash;
}
};
struct APHash
{
size_t operator()(const string& str)
{
register 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)
{
register size_t hash = 5381;
for (auto ch : str)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
namespace wcy
{
template<size_t N, class K = string
,class HashFunc1 = BKDRHash
,class HashFunc2 = APHash
,class HashFunc3 = DJBHash>
class bloomfilter
{
public:
void set(const K& key)
{
size_t hash1 = HashFunc1()(key) % N;
_bs.set(hash1);
size_t hash2 = HashFunc2()(key) % N;
_bs.set(hash2);
size_t hash3 = HashFunc3()(key) % N;
_bs.set(hash3);
cout << hash1 << ' ' << hash2 << ' ' << hash3 << endl;
}
bool test(const K& key)
{
size_t hash1 = HashFunc1()(key) % N;
size_t hash2 = HashFunc2()(key) % N;
size_t hash3 = HashFunc3()(key) % N;
if (!_bs.test(hash1) || !_bs.test(hash2) || !_bs.test(hash3))
{
return false;//100%准确
}
return true;//存在误判
}
private:
bitset<N> _bs;
};
}
//test.cpp
void Test()
{
wcy::bloomfilter<100> bf;
bf.set("孙悟空");
bf.set("牛魔王");
bf.set("猪八戒");
bf.set("二郎神");
cout << bf.test("孙悟空") << endl;
cout << bf.test("猪八戒") << endl;
cout << bf.test("沙悟净") << endl;
}
int main()
{
Test();
return 0;
}
小Tips:由上面的两次运行结果可以看出,影响布隆过滤器的一个重要因素就是 N 的大小,这里的 N 表示底层位图要开辟 N 个比特位。N 越大意味着比特位越多,冲突的概率就会下降,布隆过滤器的误判率就会跟着下降,但是空间利用率也会下降。相反,N 越小空间利用率就会提升,但是冲突就会越多,布隆过滤器的误判率就会上升。那 N 到底多大合适呢?在程序中我们可以利用哈希中的那一套理论,即通过负载因子去控制 N 的大小。
小Tips:上图中 k 为哈希函数个数,m 为布隆过滤器长度,n 为插入的元素个数,p 为误判率。通过上图可以发现,影响布隆过滤器误判率的最主要因素还是哈希函数的个数,在增加了哈希函数的个数后,误判率有了明显的下降。
2.4 布隆过滤器的查找
布隆过滤器的思想是将一个元素用多个哈希函数映射到一个位图中,因此被映射到的位置的比特位一定为 1。所以可以按照以下方式进行查找:首先计算出每个哈希函数对应的哈希值,再去查看该哈希值对应的比特位置存储的是否为零,只要有一个为零,代表该元素一定不在布隆过滤器中,否则可能在布隆过滤器中。
注意:布隆过滤器如果说某个元素不在时,该元素一定不存在,如果布隆过滤器说该元素存在时,那么该元素可能存在,因为有一些哈希函数值存在一定的冲突,导致最终的误判。
例如:在布隆过滤器中查找 “沙悟净”时,假设 3 个哈希函数计算的哈希值为:1、3、7,刚好和其他元素对应的比特位重叠,此时布隆过滤器告诉该元素存在,但实际该元素是不存在的。
2.5 布隆过滤器的删除
布隆过滤器不能直接支持删除操作,因为在删除一个元素时,可能会影响其它元素。
例如:删除删除上面程序中的 “猪八戒”,如果直接将该元素所对应的二进制比特位置 0,那么 “二郎神” 也被删除了,因为这两个元素经过HashFunc1 计算出来的结果是一样的,这就导致他俩对应的比特位会发生重叠。
一种正确的删除方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给 k 个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给 k 个计数器减一,通过多占用几倍存储空间的代价来给布隆过滤器增加删除操作。
缺陷:
-
无法确认元素是否真正在布隆过滤器中
-
存在计数回绕
2.6 布隆过滤器的优点
2.8 布隆过滤器的实际应用场景
不需要精确的场景,比如快速判断昵称是否注册过。用户在注册账号时,输入一个昵称,在后端的数据库中要能够快速判断出该昵称是否已经存在数据库中,若存在说明该昵称已经被其他人注册过了。此时,就可以将数据库中的所有昵称都放到布隆过滤器中,若布隆过滤器判断出不在,那一定是不在,若布隆过滤器判断出在那可能是误判,我们可以通过负载因子来控制误判率,且这种误判影响不大。如果要精确判断,可以对上面的方式进行优化,若布隆过滤器判断出不在,那此结果一定是准确的,直接返回即可,若布隆过滤器判断的结果是存在,此时可以拿着该昵称到数据库中去搜索一遍,以数据库的查询结果返回给用户。
-
添加和查询的元素的时间复杂度是:O(K)(K 是哈希函数的个数,一般比较小),与数据量大小无关。
-
哈希函数相互之间没有关系,方便硬件并行运算。
-
布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势。
-
在能够承受一定的误判时,布隆过滤器与其他数据结构相比有着很大的空间优势。
-
在数据量很大的时候,布隆过滤器可以表示全集,其他数据结构不能。
-
使用同一组哈希函数的布隆过滤器可以进行交、并、差运算。
2.7 布隆过滤器的缺陷
-
有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补数方法:再建立一个白名单,存储可能会误判的数据)。
-
不能获取元素本身。
-
一般情况下不能从布隆过滤器中删除元素。
-
如果采用计数方式删除,可能会存在计数回绕的问题。
三、哈希切分
题目:给两个文件 A 和 B,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法。
思路一:首先假设 一个 query 占用 30Byte,那么100亿个 query 就会占用3000亿Byte,1G近似10亿Byte,所以3000亿Byte差不多就是300G。这里直接去遍历 A 中的元素再到 B 中去查找是不现实的,我们可以将这两个大文件分成若干份小文件,这里就将每个文件按照大小平均分成1000个小文件(A0~A999、B0~B999),这样以来每个小文件的大小就是300MB,然后我们可以依次拿着A0~A999这1000个小文件,去和B0~B999这1000个小文件进行对比,有相同的就放进交集。但是这样做的问题在于,A0~A999的每一个小文件都要和B的所有小文件对比一遍,这样效率也十分的低。所以下面我们引出哈希切分的方法。
思路二(哈希切分):大思路上还是将 A 和 B 这两个大文件进行切分。但是并不再按照上面的内存大小进行平均切分,而是采用哈希切分。所谓哈希切分就是:根据一个哈希函数去计算确定将该元素划分到哪一个小文件里,可以采取下面这个公式进行计算:hashi=HashFunc(query) % 1000。最终每个文件中存放的都是 hashi 相同的元素,这里的一个小文件就类似于一个哈希桶,里面的这些元素只有两种可能:重复、冲突。将 A 和 B 文件都采取相同的方式进行划分,最终 A 和 B 中相同的 query 一定会分别进入 Ai 和 Bi 编号相同的小文件。接下来我们只需要去对比 Ai 和 Bi 即可,这样效率会大大提高。这种做法还是存在一些小问题,有可能出现一个小文件中数据过多的情况,这样就不满足题目中的内存要求了。一个小文件中数据过多有以下两种情形。
-
比如 Ai 有 5G 数据,其中有 4G 都是相同的 query(即重复数据),还有 1G 是不同的 query(即冲突数据)。
-
基本上都是不同的数据(即冲突数据)。
针对这两种情形提出以下解决方案:
-
先把 Ai 的 query 读到一个 set 里面,如果 set 的 insert 报错抛异常(bad_alloc,内存错误),那么就说明该小文件中大多数 query 都是不相同的(即冲突)。如果能够全部读出来,insert 到 set 里面,那么说明 Ai 里面大部分都是相同的 query(这里利用了 set 可以去重的机制)
-
如果抛异常,说明有大量冲突,就再换一个哈希函数,进行二次切分。
小Tips:这道题目和 1.4.2 不同。这道题中的 query 不是整数,不适合使用位图来解决。使用哈希切分的目的就是为了满足内存限制,切分后再使用 set,对小文件的数据做进一步处理,如果小文件中都是重复的数据,set 会起到去重的作用,如果小文件中都是不相同的冲突数据,set 会抛异常,那么我们就再找一个哈希函数对该小文件继续切分。最终再用 set 去查找在不在,这样还可以做到精确查找。要做近似查找,直接使用布隆过滤器即可。
题目:给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?
思路:这道题还是采取哈希切分的方法去完成。用一个哈希函数将 100G 大小的文件分成若干份小文件,相同的 IP 一定会被划分到同一个小文件中,然后再用一个 map 去统计出每一个小文件中 IP 出现的次数。记录下出现次数最多的即可。