java–基础–9.9–集合–Map–哈希表(散列表),哈希冲突
1、哈希表(散列表)
- 简单来说就是给一个key,就可以找到对应的key的存储位置,就像身份证对应一个人一样
- 存储位置 = f(key)
- hashMap的key就是用到散列表
2、哈希冲突
我们时常会碰到两个关键字key1 ≠ key2,但是却有f(key1) = f(key2),这种现象我们称为冲突(collision),
打个比方说:加上f的函数是这样的f(key)=key%10,那么f(1)=f(11),这就是哈希冲突
3、解决哈希冲突的方法
3.1、直接定址法
如果我们现在要对0-100岁的人口数字统计表,那么我们对年龄这个关键字就可以直接用年龄的数字作为地址。
此时f(key) = key。
这个时候,我们可以得出这么个哈希函数:f(0) = 0,f(1) = 1,……,f(20) = 20。
这个是根据我们自己设定的直接定址来的。人数我们可以不管,我们关心的是如何通过关键字找到地址。
如果我们现在要统计的是80后出生年份的人口数,那么我们对出生年份这个关键字可以用年份减去1980来作为地址。
此时 f(key) = key-1980。
假如今年是2000年,那么1980年出生的人就是20岁了,此时 f(2000) = 2000 - 1980,可以找得到地址20,地址20里保存了数据"人数500万"。
也就是说,我们可以取关键字的某个线性函数值为散列地址,即:
f(key) = a × key + b
3.1.1、优缺点
这样的散列函数优点就是简单、均匀,也不会产生冲突,但问题是这需要事先知道关键字的分布情况,适合査找表较小且连续的情况。
由于这样的限制,在现实应用中,直接定址法虽然简单,但却并不常用。
3.2、除留余数法
3.2.1、公式
f( key ) = key mod p
本方法的关键就在于选择合适的p,p如果选得不好,就可能会容易产生同义词
3.2.2、举例
有一个关键字,它有12个记录,现在我们要针对它设计一个散列表。如果采用除留余数法,那么可以先尝试将散列函数设计为f(key) = key mod 12 的方法。
这个方法有点极端,因为关键字对应的下标都为0,因为所有值都是12的整数倍
但是我们如果不选用p=12来做除留余数法,而选用p=11,则结果如下:
这个时候就只有12和144有冲突,相对来说,就要好很多了。
3.2.3、如何合理选取p值
使用除留余数法的一个经验是,若散列表表长为m(上一个案例表长为12),通常p为小于或等于表长(最好接近m)的最大质数或大于20 质因子的合数。
举个例子:某散列表的长度为100,散列函数H(k)=k%P,则P通常情况下最好选择哪个呢?A、91 B、93 C、97 D、99
实践证明,当P取小于哈希表长的最大质数时,产生的哈希函数较好。我选97,因为它是离长度值最近的最大质数。
大于20 质因子的合数:23,29,31,37,41等等某2个组合:如2323,2329
3.3、开放定址法
3.3.1、定义
就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
3.3.2、公式为
fi(key) = (f(key)+di) MOD m (di=1,2,3,......,m-1)
3.3.3、解决冲突的做法
- 当冲突发生时,使用某种探测技术在散列表中形成一个探测序列。
- 沿此序列逐个单元地查找,直到找到给定的关键字,或者碰到一个开放的地址(即该地址单元为空)为止(若要插入,在探查到开放的地址,则可将待插入的新结点存人该地址单元)。
- 查找时探测到开放的地址则表明表中无待查的关键字,即查找失败。
3.3.3.1、开放定址法:线性探测法
案例
我们的关键字集合为{12,67,56,16,25,37,22,29,15,47,48,34},表长为12。 我们用散列函数 f(key) = key mod 12
当计算前S个数{12,67,56,16,25}时,都是没有冲突的散列地址,直接存入:
计算key = 37时,发现f(37) = 1,此时1位置已经存在25了,发生位置冲突。
于是我们应用上面的公式f(37) = (f(37)+1) mod 12 = 2。于是将37存入下标为2的位置。
接下来22,29,15,47都没有冲突,正常的存入:
到了 key=48,我们计算得到f(48) = 0,此时0位置存在12了,发生位置冲突,不要紧,我们f(48) = (f(48)+1) mod 12 = 1,此时又1位置也有内容了,发生位置冲突。于是f(48) = (f(48)+2) mod 12=2,还是冲突……一直到 f(48) = (f(48)+6) mod 12 = 6时,才有空位,机不可失,赶快存入:
堆积
定义:案例中48和37这种本来都不是同义词却需要争夺一个地址的情况,我们称这种现象为堆积。
缺陷:堆积的出现,使得我们需要不断处理冲突,无论是存入还是査找效率都会大大降低。
3.3.3.2、开放定址法:二次探测法
考虑深一步,如果发生这样的情况,当最后一个key=34,f(key)=10,与22所在的位置冲突,可是22后面没有空位置了,反而它的前面有一个空位置,尽管可以 不断地求余数后得到结果,但效率很差。
因此我们可以改进di = 1^2, -1^2, 2^2, -2^2,……, q^2, -q^2 (q <= m/2),这样就等于是可以双向寻找到可能的空位置。
对于34来说,我 们取di即可找到空位置了。另外增加平方运算的目的是为了不让关键字都聚集在 某一块区域。我们称这种方法为二次探测法。
fi(key) = (f(key)+di) MOD m ( di = 1^2, -1^2, 2^2, -2^2,……, q^2, -q^2 (q <= m/2))
3.3.3.3、开放定址法:随机探测法
还有一种方法是,在冲突时,对于位移量 di 采用随机函数计算得到,我们称之为随机探测法。
此时一定会有人问,既然是随机,那么查找的时候不也随机生成办吗?如何可以获得相同的地址呢?这是个问题。这里的随机其实是伪随机数。
伪随机数是说,如果我们设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,我们在査找时,用同样的随机种子,它每次得到的数列是相同的,相同的 di 当然可以得到相同的散列地址。
fi(key) = (f(key)+di) MOD m (di是一个随机数列)
总之,开放定址法只要在散列表未填满时,总是能找到不发生冲突的地址,是我们常用的解决冲突的办法。
3.4、链地址法(拉链法,hashMap就是这种结构)
3.4.1、定义
将所有关键字为同义词的记录存储在一个单链表(同义词子表)中,在散列表中只存储所有同义词子表的头指针。
3.4.2、案例
对于关键字集合{12,67,56,16,25,37, 22,29,15,47,48,34},我们用前面同样的12为除数,进行除留余数法:
3.4.3、做法
- 将所有关键字为同义词的结点链接在同一个单链表中。
- 若选定的散列表长度为m,则可将散列表定义为一个由m个头指针组成的指针数组T[0…m-1]。
- 凡是散列地址为i的结点,均插入到以T[i]为头指针的单链表中。
- T中各分量的初值均应为空指针。在拉链法中,装填因子α可以大于 1,但一般均取α≤1。
3.4.4、优点
拉链法处理冲突简单,且无堆积现象,即非同义词决不会发生冲突,因此平均查找长度较短
由于拉链法中各链表上的结点空间是动态申请的,故它更适合于造表前无法确定表长的情况;
开放定址法为减少冲突,要求装填因子α较小,故当结点规模较大时会浪费很多空间。而拉链法中可取α≥1,且结点较大时,拉链法中增加的指针域可忽略不计,因此节省空间;
在用拉链法构造的散列表中,删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。对开放地址法构造的散列表,删除结点不能简单地将被删结 点的空间置为空,否则将截断在它之后填人散列表的同义词结点的查找路径。这是因为各种开放地址法中,空地址单元(即开放地址)都是查找失败的条件。因此在用开放地址法处理冲突的散列表上执行删除操作,只能在被删结点上做删除标记,而不能真正删除结点。
对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保障。当然,这也就带来了査找时需要遍历单链表的性能损耗,不过性能损耗在很多场合下也不是什么大问题。
3.4.5、缺点
和开放定址法对比:
- 结点规模较小时,开放定址法较为节省空间
- 结点规模大时,扩大开放定址法散列表的规模(容量),将装填因子变小,这会减少开放定址法中的冲突,从而提高开放定址法平均查找速度。