哈希
概念
哈希(hash)又称为散列,是一种组织数据的方式。从别名来看,有散乱排列的意思。本质上就是通过哈希函数把关键字 key 跟储存位置建立一个映射关系,查找时通过这个哈希函数计算出 key 存储的位置,进行快速查找。
直接定址法
当关键字的范围比较集中时,直接定址法就是最简单高效的方法,比如一组关键字都在 0-99 之间,那么我们开一个100个数的数组,每个关键字的值直接就是存储位置的下标。再比如一组关键字值都在[ a,z ]的小写字母,那么我们开一个26个数的数组,每个关键字ascii码就是存储位置的下标。也就是说直接定址法本质上是用关键字计算出一个绝对位置或者相对位置。这个方法我们在计数排序部分已经使用过了,其次在string章节的OJ题中也有讲解:
代码演示:
class Solution {
public:
int firstUniqChar(string s) {
int arr[26] = {0};
for(auto ch : s)
{
arr[ch-'a']+=1;
}
for(int i = 0;i < s.size(); i++)
{
if(1 == arr[s[i]-'a'])
{
return i;
}
}
return -1;
}
};
哈希冲突
直接定址法的缺点也非常明显,当关键字的范围比较分散时,就很浪费内存甚至内存不够用。假设我们只有数据范围是[0,9999]的N个值,我们要映射到一个M个空间的数组中(一般情况下M>=N),那么就要借助哈希函数hf(hash function),关键字 key 被放到数组的h(key)位置,这里要注意的是h(key)计算出的值必须在[0,M)之间。
这里存在的一个问题就是:两个不同的 key 可能会映射到同一个位置去,这种问题我们叫做哈希冲突,或者哈希碰撞。理想情况是找出一个好的哈希函数来避免冲突,但是实际场景中冲突是不可避免的,所以我们需要尽可能设计出优秀的哈希函数,来减少冲突的次数,同时也要去设计出解决冲突的方案。
负载因子
假设哈希表中已经映射存储了N个值,哈希表的大小为M,那么负载因子 == N/M,负载因子(load factor)在有些地方也被翻译为载荷因子或装载因子。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。
将关键字转换为整数
我们一般使用整数进行映射计算,将关键字映射到数组中,如果关键字类型不为整数,我们需要想办法转换成整数,这个细节我们后面代码实现中再进行演示。下面我们讨论哈希函数部分时,如果关键字不是整型,那么我们就默认key时关键字转换成整形之后的整数。
哈希函数
一个好的哈希函数应该让 N 个关键字被等概率的散列分布到哈希表的 M 个空间中,但是实际却很难做到,因此我们只能尽量往这个方向去考量设计。
除留余数法/除留散列法
- 除法散列法也叫做除留余数法,顾名思义,假设哈希表的大小为M,那么通过key除以M的余数作为映射位置的下标,也就是哈希函数为: h(key) = key% M。
- 当使用除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。如果是2,那么key% 2^X 本质相当于保留 key 二进制的后X位,那么后x位相同的值,计算出的哈希值都是一样的,就冲突了。如:{63,31}看起来没有关联的值,如果M是16,也就是2,那么计算出的哈希值都是15,因为63的二进制后8位是00111111,31的二进制后8位是00011111。如果是10,就更明显了,保留的都是10进值的后x位,如:{112,12312},如果M是100,也就是10?,那么计算出的哈希值都是12。
- 当使用除法散列法时,建议M取不太接近2的整数次幂的一个质数(素数)。
- 需要说明的是,实践中也是八仙过海,各显神通,Java的HashMap采用除法散列法时就是2的整数次幂做哈希表的大小M,这样玩的话,就不用取模,而可以直接位运算,相对而言位运算比模更高效一些。但是他不是单纯的去取模,比如M是2^16次方,本质是取后16位,那么用key’=key>>16,然后把key和key'异或的结果作为哈希值。也就是说我们映射出的值还是在[0,M)范围内,但是尽量让kev所有的位都参与计算,这样映射出的哈希值更均匀一些即可。所以我们上面建议M取不太接近2的整数次幂的一个质数的理论是大多数数据结构书籍中写的理论吗,但是实践中,灵活运用,抓住本质,而不能死读书。(了解)
乘法散列法(了解)
- 乘法散列法对哈希表大小M没有要求,他的大思路第一步:用关键字K乘上常数A(0<A<1),并抽
取出 k*A的小数部分。第二步:后再用M乘以k*A的小数部分,再向下取整。 - h(key) = floor(M x((A x key)%1.0)),其中floor表示对表达式进行下取整,A∈(0,1),这里最重要的是A的值应该如何设定,Knuth 认为 A=(V5-1)/2=0.6180339887.. (黄金分割点])比较好。
- 乘法散列法对哈希表大小M是没有要求的,假设M为1024,key为1234,A =0.6180339887,
A*key=762.6539420558,取小数部分为.6539420558,
M x ((Axkey)%1.0)=0.6539420558*1024=669.6366651392,那么h(1234)=669。
全域散列法(了解)
- 如果存在一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,比如,让所有关键字全部落入同一个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列。
- h(key) = ((a x key + b)%P)%M ,P需要选一个足够大的质数,a可以随机选[1,P-1]之间的任意整数,b可以随机选[0,P-1]之间的任意整数,这些函数构成了一个P*(P-1)组全域散列函数组。
假设P=17,M=6,a=3,b=4,则h(8) = ((3 x 8 + 4) % 17 ) % 6 = 5。 - 需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数使用,后续增删查改都必须固定使用这个散列函数,否则每次哈希都是随机选一个散列函数,那么插入是一个散列函数,查找又是另一个散列函数,就会导致找不到插入的key了。
其他方法(了解)
上面的几种方法是《算法导论》书籍中讲解的方法。《殷人昆 数据结构:用面向对象方法与C++语言描述(第二版)》和 《[数据结构(C语言版)].严蔚敏 吴伟民》等教材型书籍上面还给出了平方取中法、折叠法、随机数法、数学分析法等,这些方法相对更适用于一些局限的特定场景,有兴趣可以去看看这些书籍。
处理哈希冲突
实践中哈希表一般还是选择除留余除法作为哈希函数,当然哈希表无论选择什么哈希函数也避免不了冲突,那么插入数据时,如何解决冲突呢?我们分为开放定址法和链地址法。
开放定址法
在开放定址法中所有的元素都放到哈希表里,当一个关键字key用哈希函数计算出的位置冲突了,则按照某种规则找到没有存储数据的位置进行存储,开放定址法中负载因子一定是小于1的。这里的规则有三种:线性探测、二次探测、双重探测。
线性探测
- 从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置。
- h(key)= hash0 = key % M,hash0位置冲突了,则线性探测公式为:
hc(key,i)=hashi=(hash0+i)%M,i={1,2,3,…,M-1},因为负载因子小于1则最多探测M-1次,一定能找到一个存储key的位置。 - 线性探测的比较简单且容易实现,线性探测的问题假设,hash0位置连续冲突,hash0,hash1,hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位置,这种现象叫做群集/堆积。下面的二次探测可以一定程度改善这