哈希思想的应用
哈希(也叫 散列) 是一种 映射 的思想
哈希表:解决问题的思想(算法思想):哈希表通过映射这种思想,实现了哈希表这种数据结构
还有其他的数据结构:位图、布隆过滤器。小众点的,基数树(多阶哈希)
位图
位图概念
经典面试题
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。
- 排序+二分
- set + find
1G=1024MB;1MB=1024KB;1KB=1024byte 。( 1024=2^ 10. )
则 1G=1024 * 1024 * 1024=(2^ 10)^ 3 = 2^ 30
byte 。
由此得出,1G就能存放1,000,000,000
(10亿)个数据。每一个数据存放的单位是1byte。
42亿 (即 2^32 ) 则需 4G 的数据大小(基本单位为 byte)。通过位图,一个bit
位便能 映射表示一个数(1byte=8bit),因此 开一个 能够映射42亿数据 的 位图 所需的内存空间大小为 4G / 8bit = 0.5G (512M)
。
位图所开的空间大小
位图 开的大小,是按范围去开。无符号整数 的范围是 0~4,294,967,295
,因此我们需要开0~4,294,967,295
大的范围 。即使是100亿个数据,不管开多大还是开多小,都开Unsigned_int
大小的位置, 除这42亿9千万个数外,其他的都是重复的。
开 INT_MAX
大小是不够的才 2,147,483,647
(因为是有符号整型,还有另一半 INT_MIN
,所以为 2,147,483,647
)
2*INT_MAX
:默认给出INT_MAX
的时候识别是整型(int
) * 2 以后会溢出 。因此要用UINT_MAX
(unsigned_int_max)范围就是从0~4,294,967,295
bitset<> bs;
(size_t)-1 将-1强转为无符号整形
UINT_MAX (unsigned_intMAX)
0xffffffff(16进制:8个f)
pow(2,32)-1
倘若是通过数据结构来存储的, 4G(个数据) X 一个整数4byte
= 总共16G-
的内存大小 。排序需要连续的物理地址空间大小。平时普通的电脑内存根本不够用。像红黑树一个节点中存放三指针和一个数据,其所消耗的内存大小就更不用说了。
对于这道题目而言,一个数据只有两种状态:在/不在。如果我们想要标识两种状态,其实 只需要一个比特位就够了,0表示不存在,1表示存在。 通过哈希的映射思想,我们可以把每一个数据映射到一个比特位中,这就是位图的概念。
- 开2^32个bit位【 按范围去开:
Unsigned_int
42亿9千万 】
表示一个数在或者不在,用一个bit比特位来表示
STL库中的 bitset
位图
简单了解bitset
的最常用接口,明白其的功能有哪些,后面实现 。
类模板:
非类型模板参数N
,位图中要开多少个比特位。
接口 | 功能 |
---|---|
operator[] | 返回对应位置的引用 |
count | 计算所有比特位中1的个数 |
size | 返回比特位的个数 |
test | 检测某一个位,是1返回true,是0返回false |
set | 把某一个位的值改为1 |
reset | 把某一个位的值改为0 |
位图实现
大框架
template<size_t N>
class bitSet
{
public:
private:
vector<int> _bits;
};
模板参数N
:用于传参,代表传过来的有多少个数需要判断。N
就是要开的bit
位的数量。
但位图的 底层结构是vector< int >
( int
类型的数组 ),而 一个int
有32个bit
位,一个位置能容下判断32个数。那么我们应开的空间为 N / 32
个 int
空间大小的vector<>
。
但由于C++的除法会向下取整,所以我们要额外+1
,避免开出来的位不够。如63/32=1,那剩下的31位怎么办?
这样我们就可以写一个构造函数 。
template<size_t N>
class bitSet
{
public:
bitSet()
{
_bits.resize(N / 32 + 1, 0);
}
private:
vector<int> _bits;
};
位运算符<<左移 和 >>右移 移动的方位
左移还是右移,是由大小端来决定的吗?大小端在内存窗口中查看。小端(低位在地址低位,高位在地址高位),大端(高位在地址低位,低位位在地址高位)
不是的,也没有说在小端机中右移是往高位移,左移往低位移这样的说法。
左移和右移并不是单纯的方向上的移动,而是统一 左移是往高位移,右移是往低位移 。而 硬件层 会单独判断处理数值是小端机还是大端机,(cpu)硬件体系中的指令体系 相当于 封装 了左移和右移,直接执行左移所对应的往高位移的操作,右移所对应的往低位移的操作。
set():把x映射的位标记成1
大体逻辑:
- 先定位( 一个元素有32个bit位 )
- 确定是vector中的第
i
个元素 - 第
i
个元素的第j
个比特位
- 确定是vector中的第
size_t i = x / 32; // vector的第i个元素
size_t j = x % 32; // 第i个元素的第j个比特位
- 先将1左移
<<
(往高处移)到要标记成的位置( 除了该位置为1,其他位置都是0 ) - 按位或等
|=
:有1则为1,( 除了该位置为1,其他位置都是0 )则能控制j
位修改为1,而其他位置不受影响。
如把11001100
的第4位变为1:
11001100 // 待修改数据
00000001 // 数字1
00010000 // 数字1左移4位
------------
11001100
|= 00010000 // 按位或等
------------
11011100
set() 接口实现
// 把x映射的位标记成1
void set(size_t x)
{
assert(x <= N);
size_t i = x / 32;
size_t j = x % 32;
_bits[i] |= (1 << j);
}
reset:把x映射的位标记成0
将改为设置成0:
- 定位
- 将除了该位置设置成0,其他都设置成1
这要怎么做到呢?对上面将该位置标记成1的( 1<<j )
进行 取反操作~
,就得到该位置是0,其他位置都为1 的底部 - 然后
&=
按位与等:有0则为0,将该为修改为0。(其他位置都是1,则不会受到影响)
11011100 // 待修改数据
00000001 // 数字1
00010000 // 数字1左移4位(1<<j)
-------------
11011100
~ 00010000 // ~取反
-------------
11011100
&= 11101111 // 按位与&=: 0来修改1
-------------
11001100
reset() 接口
// 把x映射的位标记成0
void reset(size_t x)
{
assert(x <= N);
size_t i = x / 32;
size_t j = x % 32;
_bits[i] &= ~(1 << j);
}
test():检测x位是1还是0
- 左移
1<<j
( 让1与其按位与判断 ) &
按位与判断 就行了,不进行修改
最后 return 该_bits[i]
位的 整型值 转化成bool
值:0就是假,非0就是真。
bool test(size_t x)
{
assert(x <= N);
size_t i = x / 32;
size_t j = x % 32;
return _bits[i] & (1 << j); // & 按位与判断,不进行修改
}
#include"BitSet.h"
展开在.cpp中,展开后BitSet.h里的内容也就只能向上查找
【链接】
效率
把数都遍历一遍,全都set到位图中。
剩下的查找就直接根据O(1),就能映射到要找的数据
海量面试题
- 给定100亿个整数,设计算法找到只出现一次的整数
-
思路1:用map 内存存不下这些值
10,000,000,000
10亿多byte是1G,100亿多是10G,是整数因此还要x4
byte,因此这里需要40G-的内存 -
思路2:还是通过位图来映射找到对应数据出现的次数
1次 和 2次及以上,需要2个比特位,只需要判断3中状态就可以了"00,01,10"(00 - 0次,01 - 1次,10 - 2次及以上)- 连开两个bit位,映射关系处理的有点麻烦
- 直接开两个位图 ,两个位图组合起来就是其统计的次数
template<size_t N>
class two_bit_set
{
public:
// 00 -> 01 1次
// 01 -> 10 2次及以上(10以后就不变)
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)
{
// 01 -> 10
_bs1.set(x);
_bs2.reset(x);
}
}
void test(size_t x)
{
// 00
if (_bs1.test(x) == false
&& _bs2.test(x) == false)
{
return 0;
}
else if (_bs1.test(x) == false
&& _bs2.test(x) == true)
{
// 01
return 1;
}
else
{
return 2; // 2次及以上
}
}
private:
bitSet<N> _bs1;
bitSet<N> _bs2;
};
也可以按照自己的需求自己造轮子:
// 根据需求自己造轮子:只需判断出只出现一次的
bool test(size_t x)
{
if (_bs1.test(x) == false
&& _bs2.test(x) == true)
{
// 01
return true;
}
return false;
}
- 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
思路:分别set到两个位图,两个位图同时为1,按位与&
后为1,则为交集
void test_bitset2()
{
int a1[] = { 4,9,5 };
int a2[] = { 9,4,9,8,4 };
bitSet<100> bs1;
bitSet<100> bs2;
for (auto e : a1)
{
bs1.set(e);
}
for (auto e : a2)
{
bs2.set(e);
}
for (size_t i=0;i<100;i++)
{
if (bs1.test(i) && bs2.test(i)) // 都存在
{
cout << i << endl;
}
}
}
-
位图应用变形:1个文件有100亿个int,只有
512M
内存,设计算法找到出现次数不超过2次的所有整数处理
Unsigned_int_max
无符号整型(范围为0~42亿9千万
),所需开的位图大小为0.5G
。
这里有100亿个int整型,则大概需要1G多的内存大小来处理数据,内存不够,怎么办?位图所开的空间大小,是取决于需要映射的数的范围 。 若只有
512M
的内存,则每次只映射一半范围的数据,因此则能一次只使用256M
的内存(直接映射) 。第二次值统计,则统计后半部分(通过间接映射x-2^31
)。将分开遍历的两组值,一个位图对这分开的两组值,分别都进行遍历 。
布隆过滤器
布隆过滤器提出
我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那些已经存在的记录。 如何快速查找呢?
数据在内存中查找
-
排序 + 二分查找:
排序只需要排一次,后面的查找。
不足:主要的问题是在 数组 上,插入在头部、中间都要挪动数据,消耗很大 。 -
搜索树
按照搜索树规则,搜索的时间复杂度为O(log_2 N)
不足: 该数据结构 在内存大小上的消耗很大,一个节点存放三指针一数据。 -
哈希表
映射规则,
不足:浪费空间,一个空间存储数据的大小根据要存储的数据通过Hash
哈希函数 转化成整型来进行哈希映射存储。整型大小的位为int
。 -
位图(应用了哈希映射思想 + 位运算
0
1
标记,仅需bit
位的大小)
( 局限:只是要简单判断这个值在不在 -> 只需要 位映射 来进行0
1
标记,仅需bit
位的大小 )-
特点:1. 快 ; 2. 节省空间
-
不足:只能解决整型
只是要简单判断这个值在不在,就能用位图 位映射标记,直接查找该位置标记的值是
0
还是1
来直接判断是否存在 的思想,来解决问题。
-
布隆过滤器的引入
若想将位图这样,只是想简单判断这个值在不在 的思想,同样应用在其他自定义类型上。
要是是其他类型,(如自定义类型,string类型,怎么办?)通过字符串来判断
若仍采用位图来解决string类 有可能会存在哈希冲突:
不同的字符串(无限) —> 整型(有限 42亿9千万) —> 映射存储位置 ,字符串的多少是无限多(假设开的位图大小是10个位置,这能组合出来的字符串是已经256^10个了,这远超 有限个整型个数),即一定会存在整型重复,而造成误判的情况存在。
误判:
- 在:有误判的可能(本来不在,映射位置冲突了,误判成在了)
- 不在:准确的
存在误判,但如果将要将这样的映射位置冲突的概率降低 。
布隆过滤器
一个数据插入多个位置,这多个位置,可能会存在映射和其他位置存在冲突,只要遍历到的该位置 不存在,则进行映射插入,则大大降低了映射冲突的概率。
布隆过滤器 实现
大框架
template<size_t N, // 要插入数据的个数
class K= string, // 常用于布隆过滤器的key值是string
class Hash1 = HashFuncBKDR, // Hash1,Hash2,Hash3 通过不同的Hash哈希,分别映射到不同的三个位置
class Hash2 = HashFuncAP,
class Hash3 = HashFuncDJB>
class BloomFilter
{
public:
private:
bitSet<5*N> _bs;
};
模板参数
class K = String
缺省参数传string,因为最常传的类型是string 。K
是指需要位图进行标识是否存在的数据的类型: 传 自定义类型(如 日期类),只要对应传对应自定义类型的仿函数(将其转化成整型) 就行
size_t N
需要进行存储在位图中,需要进行Hash哈希函数映射处理的数据个数:想要通过建立映射关系来达到O(1)
的查找时间复杂度 。
而 布隆过滤器 的之所以能实现不同字符串之间不冲突,来实现自定义类型也能使用位图来达到O(1)
查找的时间复杂度的直接映射,是通过实行多个哈希函数在位图中进行多个位置同时映射,来降低哈希冲突的概率,从而让非整型类型也能采用哈希思想O(1)
的时间复杂度直接判断出该数据是否存在。
但这样的实行 多个哈希函数 进行的 多个位置同时映射,来判断该数据是否存在的做法,对空间消耗非常大,因此 对于因为要建立这样多个映射关系,要开多大的布隆过滤器 也是有讲究的,下面让我们来进行一下讨论分析。
讨论1:哈希函数个数 的选择 —— 开3
个哈希函数(一个值映射3
个位)
哈希函数的个数(一个值映射几个位 的) 也需要权衡:
- 个数越多 则布隆过滤器 bit 位置位 1 的速度越快,且布隆过滤器的效率越低;
- 但是如果太少的话,那我们的误报率会变高
k
为哈希函数个数(一个值映射几个位),m
为布隆过滤器长度(有多少个位),n
为插入的元素个数(数据个数),p
为误报率
由图可看出,当k==3
时,就已经把 误报率p
由原来的1
降低到接近0.01
了。则映射位置再拉大,对误报率的降低也没差多少了。得出结论:一个值的映射位数,一般取3位,因此需三个映射函数。
讨论2:布隆过滤器长度 —— 开要进行插入元素个数的5
倍大小
x
为哈希函数的个数,m
是布隆过滤器的长度,n
是插入元素的个数。经过研究发现,三者满足以下关系式时,布隆过滤器的误判率最低:
x = m / n ∗ l n 2 x=m/n * ln2 x=m/n∗ln2
由上得出的 哈希函数个数x
的 最优数量大小是3
,那么我们所开的布隆过滤器的长度大小m
大约是 插入元素的个数n
的 4.3
倍 。因此我们自己模拟实现布隆过滤器我们取整数5
倍:bitset<5 * N> _bs;
template<size_t N, // 要插入数据的个数
class K= string, // 常用于布隆过滤器的key值是string
class Hash1 = HashFuncBKDR, // Hash1,Hash2,Hash3 通过不同的Hash哈希,分别映射到不同的三个位置
class Hash2 = HashFuncAP,
class Hash3 = HashFuncDJB>
class BloomFilter
{
public:
private:
const size_t M = 5 * N; // 【★】经研究得:布隆过滤器的长度最好是 开 要插入数据大小N 的 5倍
bitSet<M> _bs; // 后面扩容,直接将bitSet的容器大小设置成M,方便后面扩容变动
};
reset() 删除 不能实现
删除了百度,则腾讯有一个位被reset()为0
,则一个位为0
,则直接判断腾讯不存在,即出现了误判的情况 。
布隆过滤器 码源实现
#pragma once
#include<set>
#include<string>
using namespace std;
#include"bitset.h"
struct HashFuncBKDR
{
// BKDR
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
ch*=131;
hash += ch;
}
return hash;
}
};
struct HashFuncAP
{
// AP
size_t operator()(const string& s)
{
size_t hash = 0;
for (size_t i=0;i<s.size();i++)
{
if ((i & 1) == 0) // 偶数位字符
{
hash ^= ((hash << 7) ^ (s[i]) ^ (hash >> 3));
}
else // 奇数位字符
{
hash ^= (~(hash << 11) ^ (s[i]++) ^ (hash >> 5));
}
}
return hash;
}
};
struct HashFuncDJB
{
// DJB
size_t operator()(const string& s)
{
size_t hash = 5381;
for (auto ch : s)
{
hash = hash * 33 ^ ch;
}
return hash;
}
};
template<size_t N, // 要插入数据的个数
class K= string, // 常用于布隆过滤器的key值是string
class Hash1 = HashFuncBKDR, // Hash1,Hash2,Hash3 通过不同的Hash哈希,分别映射到不同的三个位置
class Hash2 = HashFuncAP,
class Hash3 = HashFuncDJB>
class BloomFilter
{
public:
bool Set(const K& key)
{
size_t hash1 = Hash1()(key) % M;
size_t hash2 = Hash2()(key) % M;
size_t hash3 = Hash3()(key) % M;
_bs.set(hash1);
_bs.set(hash2);
_bs.set(hash3);
}
bool Test(const K& key)
{
size_t hash1 = Hash1()(key) % M;
if (_bs.test(hash1) == false) // 只要判断出一个位不在,那就是(准确的)不在
return false;
size_t hash2 = Hash2()(key) % M;
if (_bs.test(hash2) == false)
return false;
size_t hash3 = Hash3()(key) % M;
if (_bs.test(hash3) == false)
return false;
return true; // 存在误判(有可能判断到这3个位都是在的,但可能这3个位都冲突了)
}
// 布隆过滤器不好实现reset(),
private:
static const size_t M = 5 * N; // 经研究得:布隆过滤器的长度最好是 开 要插入数据大小N 的 5倍 // static是被放到静态区,不独属于对象,而是属于整个类
bitSet<M> _bs; // 后面扩容,直接将bitSet的容器大小设置成M,方便后面扩容变动
};
为避免误判,先经过布隆过滤器判断:
- 在,有可能昵称未注册过,位置被其他数据映射到,而发生误判
- 不在,昵称没注册过 布隆过滤器的价值:(准确的,直接返回结果,提速,减少了数据库的访问压力)【以空间换时间】
误判的,再到数据库中进行验证 。
经典面试题
- 给两个文件,分别有100亿个query(查询),我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法
- 近似算法:把一个文件放到布隆过滤器里面,另外一个文件 再去看,在就是交集,不在就不是交集
【为什么说是近似的:因为布隆过滤器筛选出来的值,存在误判,】
- 精确算法:
哈希切分:都放到内存(数据结构set
)里面,去重,找交集
-
内存大小
假设一个query 平均50byte,100亿个query占用多少空间?1G 约等于 10亿字节,那么一共需要500G。 -
将数据存储到一个一个文件(细分一千份)中(文件的存储,就是单纯的字节流的存储):
依次每一个query,i = HashFunc(query) % 1000
,这个query就进入Ai
文件中:
存放的是这个query本身,而HashFunc计算出来的hash值,只是用来得出query内容要放入的文件的编号;★ A和B相同的query 一定会进入 编号相同的
Ai
和Bi
小文件;只需要 遍历编号相同的文件 找交集 就可以了。
平均切 找交集O(N^2)
(平均切:每个Ai文件都去遍历一遍Bi的每个文件),哈希切 找交集O(N)
。
对于 切分的文件数量多少 的讨论
按理说,1G内存找切500份,这里切1000份,平均每份文件就是500M,因为哈希切分是有的文件是冲突的有点多,有的文件大,有的文件小,避免出现因单个文件的内存存储的空间不够了,而存到下一个文件中,则不能做到哈希切分,找到对应标号的文件。
- 而后 将文件中的内容读到内存当中,运用内存当中的 哈希表
unordered_set
、红黑树 进行处理,处理完了再把它清掉再读下一组文件:
Ai的query放 哈希表set<string> seta
,Bi的query放 哈希表set<string> setb
,seta
和setb
找交集即可
极端情况:某个文件冲突很多,导致Ai
或者Bi
太大了,比如超过1G,
那就 再走一层递归就可以了,再换一个哈希函数,再进行一次哈希切分。
还是解决不了,某个文件还是特别特别的大,那就是 因为文件中存放的大部分 都是 相同的值 。
但 文件存入到 哈希表unordered_set
中,有去重功能,重复的值不会再次插入。
因此不会出现 文件中冲突的值很多,是因为 重复的很多 而出现的 文件过大 的情况 ,在存放进内存数据结构set
中,就已经解决了这个问题。
+ 重复出现的值很多的情况,set
就已经解决了
+ 因此,只可能是因为其本身经过Hash函数后,哈希冲突还是很多 而造成的 。
query 插入set
里面时,倘若抛异常,则代表query太多,且重复不多 。
解决办法:进行二次处理:换个哈希函数,对Ai和Bi文件再进行哈希切分 。
- 如何扩展BloomFilter使得它支持删除元素的操作
引用计数:每个位置 改成 多个位的引用计数 就可以支持 。比如一个映射位置给8个bit位标记,但是这样空间消耗太大了。
以前位图只需要标记0
,1
,只需要标记 在 或者 不在;而启用计数后,在位图中,最起码得 额外开八个bit
位( 八个位能表示出255个数 ) (在原来位图的基础上开 )来进行映射 。
i=HashFunc(query)%100
哈希切分出各Ai
文件 + map<string, int>
统计次数 + topK
priority_queue
小堆 :统计出topK的IP
地址
给一个超过100G大小的log file, log中存着IP地址,设计算法 找到出现次数最多的IP地址 ?
与上题条件相同,如何找到 top K的IP ?如何直接用Linux系统命令实现 ?
分析:
- 用map和set:100G大小的数据 内存占用太大了,内存搞不定。
- 平均切分:这是个IP地址,平均切会*被分到不同的文件中。
哈希切分:
- 依次读取每个
ip
,i = HashFunc(ip) % 100
(一个文件开多大,取决于你设备的性能,最好是一个文件能放下 差不多两三百个IP地址 就好) - 每个
ip
就 进入对应算出的Ai
小文件:能进入同一个Ai
小文件的,要么是相同的IP,要么是哈希冲突的IP 。 - 依次使用
map<string,int> countMap
:统计每个文件ip
的出现次数 。
( 如果map抛异常,则说明冲突很多,小文件很大 => 必须得换 哈希函数,二次切分处理;相同的文件ip
就只是++int
并不会对内存上开辟新的内存空间占用 ) - 然后 用
pair<string, int> maxIP
来依次遍历每个文件找出 出现次数最大的文件ip
,比其大就进行更新,直到每个文件都遍历完了为止 。 topK
就建立一个 小堆priority_queue< pair<string, int>, __?__ > minHeap
,priority_queue
默认是大堆,因此此处若要实现 小堆 就需要自己实现 仿函数,根据需求 “取pair<string, int>中的second 进行比较” 。
i=HashFunc(query)%100
哈希切分成各Ai
文件 + map<int, int>
逐一遍历哈希切分出的各Ai
文件:统计出现的次数
重新看回【 海量面试题 】:给定100亿个整数,设计算法找到只出现一次的整数
原本采取的措施:
- 开双位图:来记录出现的次数
00 - 0次
,01 - 1次
,10 - 2次即以上
- 哈希切分:这里的类型是 整型,直接用 整型 去
%
取模 就好了
海量数据处理 问题的特征
- 数据量大,内存存不下
如 哈希表unordered_map、红黑树 这样的数据结构 - 先考虑具有特点的数据结构能否解决?
位图、堆、布隆过滤器(节省空间) - 大事化小 思路
文件指针,磁盘中的一个磁头来定位,真的效率太慢了
哈希切分 [ 不能平均切分 ]:相同的值进入相同的文件
找交集、统计次数 - 文件当中有很多数据,这个时候只是想找这个数据 单纯的在不在
B树/B+树