哈希表的本质是根据关键码值(Key value)而直接进行访问基于数组的一种数据结构,而哈希表的关键就是将字符串转换为数组下标,在这里我们先自定义一种编码方式,例如a是1,b是2,c是3...以此类推,z是26,空格是0。(此处的编码方式也可以为ASCII编码,也可以为任意的编码,编码不是重点,记录编码的方式才是我们所探寻的)我们尝试两种方案转换下标:
方案一:
数字相加
例如单词cats转换为数字:3+2+20+19=43,那么43就作为单词cats的下标存在数组中。那么问题来了,按照这种方案,有很多单词例如was/tin/give/tend等等,他们的最终下标都为43,显然这种方案是不合理的
方案二:
幂的连乘
我们平时使用大于10的数字,可以用一种幂的连乘来表示它的唯一性:例如7654=7x10^3 + 6x10^2 + 5^10 + 4
我们的单词也可以使用这种方案来表示:例如*cats = 3x27^3 + 1x27^2 + 20x27 + 17=60037
这样就保证了它的唯一性不会与别的单词重复
那么问题来了,如果一个单词是zzzzzzzzzz,那么得到的数超过7x10^10,数组无法表示这么大的值。就算可以表示,创建这么大的数组也是无意义的。
两种方案的总结:
方案一: 产生的组数下标太少,容易有重复下标
方案二: 产生的数组下标太多,可能数组无法表示这么大的数字
那么是否有一种折中的方案,可以将数组下标范围给到既不太多也不太少的区间呢?接下来我学习了哈希化能很好的实现这个折中方案。
哈希化
实现一种压缩算法,可以将上述方案二中得到的巨大整数范围压缩到可接受的数组范围中,那么如何压缩呢?有一种比较简单的压缩方案:
取余操作:
假设把0~199的数字用largeNumber表示,压缩为0~9的数字,用smallNumber表示,那么下标值index = largeNumber % smallNumber
当一个数被10整除时,余数一定在0~9之间
比如13 % 10 = 3,157 % 10 = 7
当然,取余操作肯定也会存在重复余数,我们也有相应的解决方案。
刚刚重复的这种情况我们称作冲突,虽然我们不可避免冲突,但是我们可以解决冲突
那么针对冲突,我们又有两种解决方法:
1.链地址法(拉链法)
2.开放地址法
链地址法:
如图,链地址法解决冲突的方案时每个数组单元中存储的不是单个数据,而是一个链条。
而这个链条是什么数据结构呢?一般用数组或者链表
在这里数组和链表都是可以的,效率都差不多,因为这里的数据不会太大,一般就三四个,但是如果说追求效率这里还是用链表比较好。因为考虑到可能从头部插入数据,这种情况下链表的效率会比数组高。
开放地址法:
开放地址法主要的工作方式是寻找空白的单元格来添加重复的元素。
也就是说如果有重复的下标需要存放,比如在2的位置存放了82,那么新插入的32也应该存放到2的位置,但是2的位置以及被82占了,所以32应该是找其他空余的位置进行存放。
但是在找这个空余位置的方法有所不同 有以下三种方法:
1.线性探测
2.二次探测
3.再哈希法
1⃣️.线性探测
线性探测法就是从数组的哈希化index下标开始,步长为1来进行探测
插入32:
理论上32应该插入哈希化得到的index=2的下标中,但是该位置已经被82占了,那么就从index+1的位置步长为1开始往下找,直到知道第一个空位置存放。
查询32:
查询与插入类似。首先查找index=2的位置是否为32,如果是则直接返回,如果不是就同样往下步长为1往下开始找,但并不是将整个哈希表查找一遍而是只要查找到一个空位置就停止查找。因为32之前不可能跳过空位置去其他的位置。
删除32:
当删除32时,不能将32所在的下标值的内容用null代替,这将导致查询其他的下标元素时造成问题,所以一般将它设置为-1。
总结:
虽然线性探测可以解决冲突的问题,但是又会造成一个新的问题,那就是聚集。什么是聚集呢?比如我们在没有任何数据的时候,插入的是22-23-24-25-26这样的连续的数字,那么这意味着下标2-3-4-5-6的位置都有元素,这种一连串的填充就叫聚集。聚集会影响哈希表的性能,无论是插入/删除/查询,都要探索多次才能进行正确的动作。例如插入32时,发现index=2的位置有22了,那么就继续一个一个往下查找,发现index=3,4,5,6都有元素了,直到找到index=7才插入,可能需要探测很长的距离才能插入,这样做是比较消耗性能的,同理删除和查询也是一样。那么有没有办法可以解决这个问题呢,二次探测可以解决部分这个问题。
2⃣️.二次探测
二次探测主要优化的是步长。线性探测我们可以看做是步长为1的探测,比如从下标x开始,依次探测x+1,x+2,x+3...
而二次探测从下标x开始,依次探测x+1^2 + x+2^2 + x+3^2...这样就可以一次性探测比较远的距离,避免聚集带来的影响。
总结
二次探测依然存在问题,比如连续插入32-112-82-2-192这些数字,步长都是2+1^2 2+2^2 2+3^2...他们的步长依然是相同的,只不过是一种步长不一的一种聚焦,还是会影响效率(只不过这种连续的数字可能性会小一点),怎样根本性的解决这种问题呢?让每个数字的步长完全不一样,一起来看看再哈希法。
3⃣️.再哈希法
再哈希化的做法就是:把关键字用另外一个哈希函数再做一次哈希化,将这次哈希化的结果作为步长。而这次的步长就是永不重复的步长了。(注意这次的哈希函数不能使用与上次哈希函数一样的函数,不然还是原来的位置,也不能为0,不然就是原地踏步,算法进入死循环。)
这次的哈希化函数,计算机专家已经设计出来了:
stepSize = constant - (key % constant)
其中constant是质数,且小于数组容量,key是关键字
例如stepSzie = 5 - (key % 5)满足要求,并且结果不可能为0.
哈希化的效率
如果没有产生冲突,那么效率会更高。
如果产生了冲突,存取的时间就依赖于探测的长度。
平均探测长度以及平均时间,取决于装填因子,随着装填因子变大,探测长度也越来越长,导致效率越来越低。那么在此之前,我们先来了解一下什么是装填因子。
装填因子
装填因子表示当前哈希表中已经包含的数据项和整个哈希表长度的比值
即装填因子 = 总数据项 / 哈希表长度\
开放地址法的装填因子最大为多少呢?为1。因为哈希表中若要插入一个元素,那么必须至少要有一个单位的长度来存放这个元素。也就是 1(总数据项) /1(哈希表长度) = 1(装填因子)。
链地址法的装填因子最大为多少呢?可以大于1。因为插入的数据可能是无限的,甚至可能大于哈希数组的长度,所以∞(总数据项) / ∞(哈希表长度) 的结果可能大于1。\
对于哈希化的效率这个问题,也就是对比链地址法和开放地址法的效率,在这里不我过多的展开讨论链地址法和开放地址法的效率,大概总结一下:开放地址法它的效率会随着添加元素的增多,也就是装填因子的增大,平均探测长度会以指数形式上升,导致查找/插入/删除元素的效率会越来越低。而链地址法它的平均探测长度也会随着装填因子的增大而降低,但并不会呈指数形式上升,而是线性。所以在真实开发中,一般选用链地址法的情况比较多,比如Java中的HashMap就是使用的链地址法。
优秀的哈希函数
好的哈希函数应该尽可能让计算变的简单,提高计算的效率。
那么设计好的哈希函数应该具备哪些优点呢?
1. 快速的计算
哈希表的优势在于效率,所以快速的获取到对应的hashCode很重要
2. 均匀的分布
无论是链地址法或者开放地址法,当多个元素映射到同一位置时,都会影响效率。所以,优秀的哈希函数应该要使元素均匀的分布。\
快速计算:霍纳法则
我们之前计算哈希值的方式:cats = 3x27^3 + 1x27^2 + 20x27 + 17 = 60337
这是很直观的计算结果,那么以这种方式计算要进行几次乘法几次加法呢?将它化成一个多项式:a(n)x^n + a(n-1)x^(n-1) + ... + a(1) + a(0)
乘法次数:n + (n-1) + ... + 1 = n(n-1) / 2 = (n^2 - n) / 2次
加法次数:N次\
通过霍纳法则我们可以获得一种快得多的算法,在中国也叫秦九韶算法。即Pn(x) = anx^n + a(n-1)x^(n-1) + ... + a1x + a0 = ((...(((anx + an -1)x + an-2)x + an -3)...)x + a1)x +a0。那么变化后,我们需要多少次乘法和加法呢?
乘法次数:N次
加法次数:N次\
如果用大O表示时间复杂度的话,直接就从O(N^2)降到了O(N),这是非常可观的效率提升。
均匀分布
在设计哈希表时,我们已经有了办法处理映射到形同下标值的情况:链地址法或者开放地址法
但是无论哪种解决方案,总是避免不了效率的降低,所以为了提高效率,我们最好还是让数据在哈希表中均匀分布。
因此在我们需要使用常量的地方(如哈希表的长度,N次幂的底数,之前使用的是27),尽量使用质数。
为什么要使用质数
假设表的容量不是质数,表长是15(坐标 0 - 14),有一个特别关键字映射到0,步长为5,探测序列为0、5、10、0、5……,一直循环下去,算法只会尝试这三个单元,不可能找到其它空白单元,算法崩溃。
如果数组容量是13,即一个质数,那么探测序列会访问到所有单元。即0、5、10、2、7、12、4、9、1、6、11、3,一直下去,只要表中有一个空位,就可以探测到它。用质数作为数组容量使得任何数想整除它是不可能的,因此探测序列最终会检查到所有单元。但是针对链地址法,质数就显得不那么重要了。Java中的链地址法中的数组长度甚至特地采用偶数。至于原因,我个人也不深入探究了。接下来的一章节就是实现哈希表。