哈希___2023-08-20
哈希
目录
哈希函数和哈希表
哈希函数的特点
对于一个哈希函数out f(in)
:
- 可以认为输入域是无穷的, 输出域是有穷的
- 同一个输入, 得到的输出是唯一的, 即不含有随机性
- 不同的输入可能会得到相同的输出, 即含有冲突(术语叫哈希碰撞, 概率极其小)
- 映射是均匀的, 即输入域的每个值都能几乎等概率地映射到输出域的每个值(能保证离散性和均匀性)
MD5的输出范围是$[0,2^{64}-1]$
SHA1的输出范围是$[0,2^{128}-1]$
可以对输出结果取模, 其结果照旧是均匀的
哈希表的原理
所谓"增删改查"实质是一样的, 以增加为例:
- 计算哈希函数得到哈希值
out
- 哈希表包含
m
个链表, 将out
与m
取模得到index
, 将数据接入到index
处的链表末尾 - 假设设定每个链表最大长度为
k
, 当一个链表长度达到k
时, 就需要扩容(一般扩容为原来的两倍), 重新计算所有数据的哈希值, 重新组成新的哈希表中
如果需要查找, 先计算哈希函数得到哈希值, 然后遍历哈希表中的index
处的链表, 找到哈希值对应的数据即可
时间复杂度分析
假定要置入N
个数据, 初始哈希表包含1个链表, 每个链表的最大长度为k
, 那么:
- 对于查找, 先调用哈希函数获得哈希值, 这是常数时间(srds是个大常数), 然后遍历链表
由于k
是常数, 因此遍历也视作常数时间, 因此查找时间复杂度为$O(1)$ - 对于增加, 不同的在于可能触发扩容,
- 由于每次扩容会翻两倍, 因此
k
为2, 哈希值均匀的情况下,N
个数的增加会触发logN
次扩容 - 每次扩容要重新处理所有已有的数据, 其时间复杂度为$O(N)$
- 而对于每次操作, 这个时间可以被视为平均的, 因此每一次时间复杂度为$O(N*logN/N) = O(logN)$
- 值得注意的是, 这里的$log$的底数是
k
, 因此当k
取较大的值时, 实际时间会被缩小(遍历不会耗费过多时间), 可以视作$O(1)$
- 由于每次扩容会翻两倍, 因此
总的来说, 哈希表的时间复杂度, 理论上是 $O(1)$, 实际上可以看做是 $O(1)$
以上是经典的哈希表实现, 根据语言的不同会有不同优化方案
布隆过滤器
作用
简单来说, 布隆过滤器用于处理黑名单查询或者爬虫去重问题, 只有两种操作:
- 把元素加入集合
- 查询一个不确定的元素是否在集合中
注意不存在删除的行为
关键是数据量会非常庞大, 而且要求反馈够快
布隆过滤器借助哈希函数能实现极大程度的内存节省, 但伴随的代价是误判率
如果要查询黑名单, 布隆过滤器可能会 “白判黑”
但是不会出现"黑判白"的情况
失误率可以通过设计压到很低, 但仍旧不可避免
实现
补充知识: 位图
通常一个100
位的int
数组要占据400
字节的内存
而我们现在只需要记录是或不是的信息, 那么完全不需要用int
来记录, 为了将内存使用率提高到极致, 我们可以使用位图, 即只用1bit
来记录是否存在
假定我们将信息记录在一个int arr[]
中, 并且我们的信息对象是第i
位bit
- 该位置对应
arr
中的第i/32
个int
- 好理解:
int numIndex = i / 32;
- 位运算:
int numIndex = i >> 5;
- 好理解:
- 该位置对应
arr[numIndex]
中的第i%32
个bit
- 好理解:
int bitIndex = i % 32;
- 位运算:
int bitIndex = i & 0x1F;
- 好理解:
- 该位置的状态就可以用位运算来提取了
int state = (arr[numIndex] >> bitIndex) & 1;
前面的右移是把该位右侧的数全部挤出去, 保留该位, 然后和
1
做与运算, 就只保留个位, 也就是目标i
位的状态 - 若要将该位置状态设置为
1
arr[numIndex] = arr[numIndex] | (1 << bitIndex);
(1 << bitIndex)
就是第i
为1
, 其余位全为0
, 再和arr[numIndex]
做或运算, 就可以把i
位的状态设置为1
- 若要将该位置状态设置为
0
arr[numIndex] = arr[numIndex] & ~(1 << bitIndex);
这里
~(1 << bitIndex)
就是第i
为0
, 其余位全为1
, 再和arr[numIndex]
做与运算, 就相当于其他位不发生改变, 只把i
位的状态设置为0
实现布隆过滤器
布隆过滤器需要两个关键参数:
- 一个很大的位图, 大小记为
m
k
个哈希函数
建立布隆版本的集合流程如下:
- 对于输入的元素
e
, 计算k
个哈希函数的哈希值, 记为hash1
,hash2
, … ,hashk
- 哈希值对
m
取模, 记为m1
,m2
, … ,mk
- 将位图的第
m1
位,m2
位, … ,mk
位通过位运算设置为1
查询的流程如下:
- 对于输入的元素
e
, 计算k
个哈希函数的哈希值, 记为hash1
,hash2
, … ,hashk
- 哈希值对
m
取模, 记为m1
,m2
, … ,mk
- 如果位图的第
m1
位,m2
位, … ,mk
位有任意一位为0
, 则e
一定不在集合中, 返回false
- 如果位图的第
m1
位,m2
位, … ,mk
位全部为1
, 则e
有可能在集合中, 返回true
分析
为什么只会白判黑的失误而不存在黑判白的失误
如果e
确实之前加入了集合, 那么那k
个位置一定被设置为了1
, 因此查询一定会返回true
而如果e
之前没有加入集合, e
对应的k
个哈希值有可能误打误撞与其他元素冲突, 因此对应的k
个位置有可能被设置为1
, 因此查询有可能返回true
一个很显然的情况是
m
取1, 那么所有元素的哈希值都会被映射到同一个位置, 那么无论谁来查都会被误判为嫌疑人
误判率的影响因素
我们称误判率为p
显然, 位图开的越大m
越大, 冲突的概率越小, p
越小
但m
大到一定程度, p
的减少也会趋于缓慢, 因此可以视为是反比关系
每一个哈希函数就好比采集输入值的一个特征
k
越大, 用于判断的特征越多, 那么误判率就会越低
但是! 这也意味着需要的位图尺寸m
就会越大, 内存占用更快, 整个位图可能很快就被撑爆了
因此p
随k
先减小后增大
计算公式
参数有:
n
: 样本量m
: 位图尺寸(所需空间还要除以8转化为字节)p
: 理想误判率k
: 哈希函数数量
公式:
- $m = -\frac{n * ln(p)}{ln(2)^2}$
- $k = ln(2) * \frac{m}{n} ≈ 0.7 * \frac{m}{n}$
- $p = (1 - e^{-\frac{k * n}{m}})^k$
注意:
- 如遇小数, 向上取整
- 第一个公式算出的是理论值, 如果实际情况内存更大, 那么就将实际内存作为
m
代入第三个公式 - 第二个公式算出的是理论值, 向上取整后作为实际
k
的值带入第三个公式 - 第三个公式接收了实际的
k
和m
, 算出的是实际的误判率
一致性
问题背景:
用取模的方式可以将数据分给多台数据服务器存储数据
问题在于, 当数据量增大, 打算扩容时该怎么办?
如果要重新为所有数据重排的话, 代价是极大的
有什么办法可以使扩容行为的代价减小?
哈希环
我们可以把数据服务器要接管数据的哈希值围成一个环, 称为哈希环
我们再根据服务器的唯一标识(比如序列号或者MAC地址等), 获得其对应的哈希值
操作流程
那么如果有数据要做操作, 其归属就是比这个数据哈希值大的第一个服务器(如果比最大值还大, 那么由于是一个环, 就归哈希值最小值的那个管)
例如, 在下图中, 蓝色线段上的数据都归m2
服务器管, 如果哈希值为0
或者最大的$2^{64}-1$, 那么就归m1
服务器管
如果遇到了要扩容的情况, 例如m4
服务器的加入, 那么只需要将m3
服务器所管理的绿色线段上的数据归m4
服务器管就行了, 其他不变, 代价相当小
问题
但是这样做有两个很明显问题:
- 服务器数量很少的时候, 数据分布很不均匀
- 如何保证负载均衡
解决方法
使用虚拟节点来解决这两个问题
简单理解: 让每个服务器影分身, 分出1000
个虚拟节点, 这每个虚拟节点都通过哈希值对应到哈希环上, 那么每个服务器就可以管多个小线段了, 数据分布就均匀了
而且, 如果有新服务器加入, 再添加1000
个对应的虚拟节点即可, 数据交换非常容易, 而且新服务器所管的数据也是比较均匀地来自之前的服务器, 因此负载均衡
更巧妙的是, 藉由比例这个思想, 我们还能做到负载的动态调整
即, 如果有一台服务器性能强悍, 那么可以相对地给它分配更多的虚拟节点
相应地, 较弱的机器可以少一些节点
通过管理负载, 从而达到整体上效率最大化