前言:
前面我们学习了unordered_map和unordered_set和哈希表哈希桶等,并且我们自己用哈希桶封装了unordered_map和unordered_set。我们知道哈希的查找效率非常高为O(1),本章我们将延续哈希的思想,共同学习哈希的应用。
目录
(一)位图
1、概念
2、海量数据处理的思路
3、位图的模拟实现
4、位图的应用
应用一:
应用二:
应用三:
(二)布隆过滤器
1、概念
2、布隆过滤器的实现
3、布隆过滤器的测试
4、为什么布隆过滤器无法实现数据的删除
5、优缺点
6、应用
(三)哈希切割 (思想)
(一)位图
1、概念
概念:
所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。
在STL官方库中就有位图:文档入口--->位图文档
主要的接口如下图:
- set是置位的意思,就是通过哈希函数找到对应的比特位,然后置为1;
- reset是复位的意思,就是通过哈希函数找到对应的比特位,然后置为0。
这里我们对于位图有了初步的理解,下面我们通过几道题目来深入理解一下。
这里只是介绍,下面我们在模拟实现中会对位图有更详细的解释。
2、海量数据处理的思路
我们来看一道面试题:
大家看到这道题,绝大部分人的第一思路就是历遍比较查找,但是这是40亿个数啊...
1、我们先来算一下40亿个数存储起来的大小:
先用10亿Byte为例:
- 10亿Byte=10亿/1024KB=10亿/1024/1024MB=10亿/1024/1024/1024G≈0.93G
40亿个数,每个数4Byte,那么40亿个数的大小就是16G左右。
2、我们能否通过之前学的容器将这么多的数据存储进去:
第一种方法:
我们用vector存放这些数,然后历遍查找。
首先对于40亿数查找效率O(N)很低,把这些数都读出来比较至少也得要16G连续的内存,不太现实。
第二种方法:
使用查找效率高的容器存放:
- 很显然这更不可能,原因也很简单
-
- 以红黑树为例,每存4个Byte还要有4个Byte的消耗(颜色 + 三叉链)
-
- 哈希表也是类似的道理,但是比红黑树消耗小一点
3、我们能否通过排序的方式:
- 首先一点内排序肯定是不行的,数据量太大了
- 通过外排序 + 二分查找的方式(也是不可以的)
-
- 16G可以开出来,但是连续的16G就开不出来
-
- 文件不能随机读取,只能挨个挨个读取,所以是不支持二分查找的
-
- 同时外排序的话消耗也很大,这里的数据量也非常大
4、直接定址法——用一个比特位标识映射值在不在
通过上面分析,发现我们之前学的一些方法都不太能行得通。
所以我们要用到了今天学习的位图。
- 既然这么多数据我们存不了,我们的目的是判断其在不在这40亿个数据中
- 我们只需要标识一下该数字在不在就可以,这里采用用一个比特位标识
- 比特位是1就表示其在,比特位是0就表示其不在
-
- 那么我们知道无符号整数最大就是
4,294,967,295
,我们只需要给0 ~ 4,294,967,295
个比特位
- 那么我们知道无符号整数最大就是
-
- 就能将所有的无符号整数的范围都包含在内
-
- 接下来直接哈希映射,直接定址法映射
注意:
这里开的是42亿九千万个(整形最大值个)比特位,而不止题目中的40亿个
- 因为开的是范围,而不是个数
用一个比特位标识的原因
- 用一个比特位只能有两个值(0和1),正好表示两种状态
- 如果用一个int来标识的话,那么一个int能标识256种状态
这就是用位图的方式来解决该问题。
3、位图的模拟实现
1、位图类的成员:
我们是通过每一个比特位来表示该位映射的值存不存在的,所以类中的成员我们引入一个vector来存放状态值。但是,vector中存放的是什么类型的数据比较好呢?
我们知道,比特位是最小的存储单位,我们的数据类型最小是char,1个字节,占有8个比特位,我们还是存储最小的数据类型比较好,这样便于后面的数据映射。
2、位图如何开空间:
因为我们vector存放的数据类型最小是char,是一个字节(只能8个8个这样开比特位空间),但是我们开的是比特位的整数个(可能不是8的整数个),所以没有直接精确控制到比特位个数的开空间方法,故采用以下方式:
3.位图如何插入标识位:
这里的插入并不是直接将数据插入到位图中,而是将数据对应的哈希地址所在的比特位标识成1。
- 因为我们用的是
char
为一个vector
的数据类型,所以我们先定位在哪一个char中 - 例如:
18
,我们先定位它在哪一个char
中,所以我们18 / 8
,先定好位 - 再用
18 % 8
定位其具体在这个char
中的哪一个比特位 - 这道题定位是下标为2的char中(也就是第三个char类型数值中),第二个比特位
- 注意:我们想把第二个比特位修改成1,需要用到的是移位和或运算,但是把00000001向左移位2后再和该处char存放的值进行或运算(其实修改位置的数据和我们惯性思维是有出入的,他对于每一个char类型存的比特位对应的值是从右向左)
- 请看图:
4、位图如何实现删除:
和上面插入一个道理,只不过用到的位运算不一样:
- 同样的道理我们先定位到要删除的那个位置通过:
/、%
操作 - 只需要将1左移要标记的位置之后,先取反,再与一下
5、如何实现查找:
- 只需要将1左移要标记的位置之后,直接与所在的char所表示的整数值与一下即可
由图也就验证了开了四十二亿个比特位,大概是五百多MB。
具体代码:
template<size_t N>
class bitset
{
public:
bitset()
{
//+1保证足够的比特位,最多浪费8个
_bits.resize(N / 8 + 1, 0);
}
void set(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] |= (1 << j);
}
void reset(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] &= ~(1 << j);
}
bool test(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
return _bits[i] & (1 << j);
}
private:
vector<char> _bits;
};
4、位图的应用
应用一:
- 我们知道无符号整数最多也就四十多亿个,100亿个必然有大量重复的。
- 我们来计算一下100亿个整数是多大,100亿个整数就是400亿个字节,那么大概就是40G的大小。
很显然用之前学的容器内存是放不下40G这么大的数据的,那我们如何用位图来解决?
- 先来分析一下,该用几个为来标识
- 分类一下:出现0次,出现1次,出现两次及以上的
- 那我们此时就要用两个比特位来标识
- 那我们是对之前的位图进行修改吗,这里我们并没有,而是直接复用两个位图
每个位图还是原来的大小(四十二亿九千万个),因为计算机表示的整数也就4,294,967,295
个,其余的也都是重复。
- 我们知道100亿个整数的范围肯定是在
0 ~ 4,294,967,295
之间的,所以我们开两个位图,重复的数对应的位置在每个表中是一样的。 - 此时我们就需要标识:00(一次都没出现的),01(只出现一次的),其他(出现两次及以上的)
template<size_t N>
class twobitset
{
public:
void set(size_t x)
{
//00->01
if (_bs1.test(x) == false && _bs2.test(x) == false)
{
_bs2.set(x);
}
else if (_bs1.test(x) == false && _bs2.test(x) == true)
{
_bs2.reset(x);
_bs1.set(x);
}
}
void Print()
{
for (size_t i = 0; i < N; ++i)
{
if (_bs2.test(i))
{
cout << i << endl;
}
}
}
private:
bitset<N> _bs1;
bitset<N> _bs2;
};
void test_twobitset()
{
int a[] = { 3, 45, 53, 32, 32, 43, 3, 2, 5, 2, 32, 55, 5, 53,43,9,8,7,8 };
twobitset<100> bs;
for (auto e : a)
{
bs.set(e);
}
bs.Print();
}
应用二:
因为只给了1个G的内存,我们首先想到的是用到两个位图,正好1个G。
解决办法:
在找交集之前我们一定要先做的是去重,对每个文件都要去重,不然就会找到重复交集。
- 方法一:分别将两个文件映射到两个位图中,挨个挨个比,利用对应算法找两个去重之后位图的交集
- 方法二:分别将两个文件映射到两个位图中,直接将两个位图与一下,剩下的就是交集了
应用三:
- 这题和应用一是一样的,只是多记录和判断了一次,因为不超过2次有:0、1、2。
- 分类一下:出现0次,出现1次,出现2次,出现两次以上的
- 同样是复用了两个位图
(二)布隆过滤器
在之前我们讲到了位图,他能迅速判断一个数是否在海量数据中。位图是直接映射,也不存在哈希冲突,空间消耗几乎没有,并且快,直接是O(1),但是位图只是适合于整形的查找,并不适用于浮点数字符串甚至是一些自定义类型
的查找。
1、概念
布隆过滤器是由 布隆(Burton Howard Bloom) 在1970年提出的一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
2、布隆过滤器的实现
比如说是10亿个字符串是否在一个文件中。
我们来判断一下能否用红黑树哈希表来存储这10亿个字符串呢?
- 这肯定是不能的,因为10亿个Byte就是1G
- 假设每个字符串是10个Byte,那么光这么多字符串就已经占了十几个G了
- 还有红黑树和哈希表自带的消耗,那几十个G就没了,所以肯定不行
此时一个叫布隆的人,正如概念中所提到的运用了位图的思想,将字符串转化成一个整数,然后映射到位图当中。
如图,我们通过某哈希函数,把字符串转成数值,然后通过位图标识:
但是,把字符串通过哈希函数转成数值这一过程可能会导致一种情况的发生:
不同字符串对应同一个比特位。如图中“美团”和“B站”字符串。
这种情况的发生我们是没有办法避免的,但是我们可以降低这种事件的发生:
由于上述情况的发生,我们可以得出结论:
- 如果给我们一个字符串通过哈希函数发现标识位是1,说明他可能之前不在
- 但是如果是0,说明他之前肯定不在!
- 有人就专门研究并统计了,误判率影响的因素--->布隆过滤器误判率影响报告
我们截取一下公式:
通过上述公式,我们哈希函数个数k取3得到:
4.35 * n = m
也就是说在3个哈希函数的时候,没插入一个元素,就需要5个比特位来标识。
布隆过滤器是复用位图的:
struct BKDRHash
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
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;
}
};
// N最多会插入key数据的个数
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 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);
//cout << hash1 << " " << hash2 << " " << hash3 << " " << endl << endl;
}
bool test(const K& key)
{
size_t len = N * _X;
size_t hash1 = Hash1()(key) % len;
if (!_bs.test(hash1))
{
return false;
}
size_t hash2 = Hash2()(key) % len;
if (!_bs.test(hash2))
{
return false;
}
size_t hash3 = Hash3()(key) % len;
if (!_bs.test(hash3))
{
return false;
}
// 在 不准确的,存在误判
// 不在 准确的
return true;
}
private:
static const size_t _X = 6;
bitset<N* _X> _bs;
};
3、布隆过滤器的测试
测试1:
void test_bloomfilter1()
{
BloomFilter<100> bs;
bs.set("sort");
bs.set("bloom");
bs.set("hello world hello bit");
bs.set("test");
bs.set("etst");
bs.set("estt");
cout << bs.test("sort") << endl;
cout << bs.test("bloom") << endl;
cout << bs.test("hello world hello bit") << endl;
cout << bs.test("etst") << endl;
cout << bs.test("test") << endl;
cout << bs.test("estt") << endl;
cout << bs.test("ssort") << endl;
cout << bs.test("tors") << endl;
cout << bs.test("ttes") << endl;
}
通过结果判断,面对一些短小的字符串,判断的准确率还是挺高的。
测试二:
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;
}
我们分别对相似字符串和不相似字符串的误判率进行测试:
综上,测试可见:布隆过滤器开的越大,误判率就越低。
4、为什么布隆过滤器无法实现数据的删除
因为布隆过滤器采用的是多组映射的方式,所以要是直接删除的话可能会影响其他的值存不存在的标识,所以布隆过滤器的删除是不能直接删除的。
但是通过其他一些方法改造,可以实现:
- 通过计数删除,映射一个值就在位图的标记位上计数
- 用到该位置就++,显然标识位不能再用比特位了
- 1bit标识一个位置
- 8bit标识一个位置 0 ~ 255
- 16bit标识一个位置 0 ~ 65535
- 还有溢出的风险
- 一个数删除之后,判断还在,说明是误判了。
此种方法的缺陷:
- 1.无法确认元素是否真正在布隆过滤器中
- 2.存在计数回绕
5、优缺点
布隆过滤器优点:
- 1.增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关
- 2.哈希函数相互之间没有关系,方便硬件并行运算
- 3.布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
- 4.在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
- 5.数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
- 6.使用同一组散列函数的布隆过滤器可以进行交、并、差运算
布隆过滤器缺点:
- 1.有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
- 2.不能获取元素本身
- 3.一般情况下不能从布隆过滤器中删除元素
- 4.如果采用计数方式删除,可能会存在计数回绕问题
6、应用
1.注册的时候,快速判断一个昵称是否使用过:
- 将系统所有的昵称都映射到布隆过滤器中
- 不在:说明没人有用过
- 在:再去数据库查确认一遍(因为在的话存在误判)
2.黑名单:
- 不在:通行
- 在:再次去系统确认(绝不放过一个坏人,但有可能误抓好人)
3.过滤层,提高查找数据效率:
(三)哈希切割 (思想)
哈希切割:
- 给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?
- 与上题条件相同,如何找到top K的IP?
问题:
- 位图和布隆是Key的模型,无法解决Key_ Value的模型
- 只能哈希表和红黑树,但是内存又不够
解决方案一:
- 如何统计次数,很显然100G的log中的IP肯定是不能放在红黑树或者哈希表中的。
- 这里我们采用将文件分割,假设将每个文件分成100份,每个文件就是1G就可以放在红黑树或者哈希表中了。
- 不过这里是存在问题的
- 会存在大量相同的IP会被分到不同的文件当中
- 如果要想统计个数,那么最后要合并还是会存在放不下的问题
解决方案二:
- 综合方案一的问题,我们就要将相同的IP放在同一个文件当中
然后再用红黑树和哈希表来统计:
注意:
- 不同的ip也有可能进入同一个文件,但是相同的ip一定是进入同一个文件。
存在问题:
某个文件太大了,哈希表和红黑树中放不下
- a. 某个相同的ip太多 - 这时候存储大概率是够的,因为相同的ip存在map中只是统计次数,不额外占用空间。
- b. 映射冲突到这个编号文件的ip太多 - 但是冲突的太多的话,还是会大量文件存在小文件中,依旧会存在一个小文件太大的情况。
解决办法:是针对小文件再分割,再用其他哈希函数,进行哈希分割,再切分成小文件。
- 大量相同时,都集中在一个小文件,再去切分是不起作用的,切分完之后还是在同一个文件里
- a不会抛异常(大量相同的ip) ,b会抛异常(大量冲突不同的ip)
布隆过滤器找交集:
给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法。
假设每个query平均30byte,100亿query就是300G。
近似算法:
- 将一个文件映射到布隆过滤器中,用另一个文件去找,利用布隆过滤器的快速性,不过可能存在误判的问题。
- 所以这叫不精确的,近似算法。
精确算法:
- 方法: 相同的小文件找交集,对应编号找交集
- 通过哈希分割,A、B文件中相同的数据肯定被分到了相同的小文件
- 对小文件找交集,那么就很容易了,用set就可以很快找到
感谢您的阅读!