哈希表介绍
哈希表是一种非常重要的数据结构,几乎所有的编程语言都有直接或者间接的应用这种数据结构。哈希表的结构就是数组 但是它神奇的地方在于对下标值的一种变换,这种变换我们称之为哈希函数,通过哈希函数可以获取到hashCode。相对于数组,它有很多优势:他可以提供非常快速的插入-删除-查找操作
1 数组进行插入操作时,效率比较低
2 数组查找操作的效率,如果基于索引进行查找操作效率非常高,基于内容去查找效率相对就低很多
3 数组进行删除操作 效率低
无论多少数据,插入和删除值需要接近常量的时间:即O(1)的时间级 实际上只需要几个机器指令即可完成。哈希表的速度比树还要快 基本可以瞬间找到想要的元素。哈希表相对于树来说编码要容易的多
哈希化:将大数字转化成数组范围内下标的过程,我们称之为哈希化
哈希函数:通常我们会将单词转成大数字,大数字在进行哈希化的代码实现放在一个函数中,这个函数我们称为哈希函数
哈希表:最终将数据插入到的这个数组,对整个结构的封装,我们称为哈希表
哈希表相对于数组的一些不足
哈希表的数据是没有顺序的 所以不能以一种固定的方式(比如从小到大)来遍历其中的元素。通常情况下,哈希表中的key是不允许重复的,不能放置相同的key 用于保存不同的元素。
使用哈希表的案例
1使用一种数据结构存储单词信息,比如有5万个单词,找到单词后每个单词有自己的翻译读音等等
以上三个案例最佳的实现方法就是给对应单词分配一个固定的哈希值索引,利用索引来检索就相对性能就好很多了
设计自己的编码系统
上面的案例我们该怎样将单词转成适当的下标呢?
其实计算机中有很多编码方案就是用数字代替单词的字符,就是字符编码。比如ASCII编码:a是97,b是98,依次类推122代表z。我们也可以设计一个自己的编码系统,比如a是1,b是2,c是3,以此类推z是26,加上空格27。
为了保证唯一性我们参考十进制的数字。比如7836 = 7*10^3 + 8*10^2 + 3*10 + 6
那么我们也将自己的编码系统使用幂的连乘方式来保证唯一性。
比如我们的系统有27位字符相当于十进制的10;得到以下公式:cats = 3*27^3+1*27^2+20*27+17 = 60335
但是我们这存在一个问题:如果一个单词是zzzzzzzzzz 那么得到的数字超过7000000000000,那么创建这么大的数组是没有意义的,而且会有很多单词都是无效的,那么我们应该如何解决这个问题,下面就用到了哈希化;
认识哈希化
以上的问题需要一种压缩方法,把幂的连乘方案系统中得到的巨大整数范围压缩到可接受的数组范围中。
对于英文字典,多大的数组才合适呢?
如果只有50000个单词,可能会定义一个长度为50000的数组。但实际情况下,往往需要更大的空间来存储这些单词,因为我们不能保证单词会映射到每一个位置。比如给一个数组两倍的大小100000。
如何压缩?
现在就找一种方法把0到超过7000000000000的单位,压缩为0到100000。有一种简单的方法就是使用取余操作符,他的作用是得到一个数被另一个数整除后的余数
取余操作的实现:
为了看到这个方法如何工作,我们先来看一个小点的数字范围压缩到小点的空间。假设把0-199的数字,比如使用largeNumber代表,压缩为从0-9的数字。下标值的结果= largeNumber%10。当一个数被10整除时,余数一定在0-9之间;
比如13%10=3,157%10=7
当然,这中间还是会有重复,不过重复的数量明显变小了,因为我们数组是100000,而只有50000个单词,就好比,你在0-199中间选取5个数字,放在这个长度为10的数组中,也会重复,但是重复的概率非常小
冲突(重复)
尽管50000个单词,我们使用了100000个位置来存储,并且通过一种相对比较好的哈希函数来完成,但是依然会有可能发生冲突
比如melioration这个单词,通过哈希函数得到它数组下标值后,发现那个位置上已经存在一个单词demystify
因为它经过哈细化后的melioration得到的下标实现相同
这种情况我们称之为冲突;解决冲突有两种办法:链地址法和开放地址法
链地址法
链地址法是一种比较常见的解决冲突的方案(也称为拉链法)。链地址法解决冲突的办法是每个数组单元中存储的不再是单个数据,而是一个链条。这个链条常见的使用数组或者链表。比如链表,也就是每个数组单元中存储着一个链表。一旦发现重复,将重复的元素插入到链表的首端或者末尾即可。当查询时,先根据哈希化后的下标值找到对应的位置,再取出链表,依次查询到要寻找的数据。
那我们到底选择数组还是链表呢?
数组或者链表在这里其实都可以,效率上也差不多。因为根据哈希化的下标值找出这个数组或者链表时,通常就会使用线性查找,这个时候数组和链表的效率差不多;当然在某些实现中会将新插入的数据放在数组或者链表的最前面,因为觉得新插入的数据取出的可能性更大
这种情况最好使用链表,因为数组在插入时要进行大量的元素位移,所以这个还是看业务需求。
开放地址法
开放地址法的主要工作方式是寻找空白的单元格来添加重复的数据
但是探索这个位置的方式不同有三种方法:线性探测,二次探测,再哈希法
线性探测
线性探测:线性的查找空白的单元。以图中插入32为例:
插入32:
经过哈希化得到的下标值等于2,但是在插入的时候,发现该位置已经有了82(如图)就从下标值+1的位置开始一点点查找空的位置来放置32
如何查询32?
首先经过哈希化得到下标值=2,如果2的位置结果和查询的数据相同就返回 不然就从下标值+1开始查找32;这里需要注意如果我们没有向数组插入32,那么数组里就不存在32,那么有一个约定就是直到查到空位置那么就停止查询,因为查询到这里有空位置,32之前不可能跳过空位置去别的位置
删除32?
删除32和插入查询比较类似但是有一个注意点:删除一个数据时不可以将这个位置下标的内容设置为null,因为将它设置为null可能会影响我们之后的查询操作,所以删除一个位置的数据项时我们可以将它进行特殊处理(比如设置为-1)。当我们之后看到-1位置的数据项时,就知道查询时要继续向下查询,但是插入时这个位置可以放置数据。
线性探测的问题:
线性探测有一个比较严重的问题,就是聚集,什么是聚集?
比如我在没有任何数据的时候插入的是22-23-24-25-26,那么意味着下标值2-3-4-5-6的位置都有元素,这种一连串填充单元就叫做聚集
聚集会影响哈希表的性能,无论是插入/查询/删除都会影响。
比如我们插入32,会发现连续的单元格都不允许我们放置数据,并且在这个过程中我们需要探测多次,二次探测可以解决一部分问题。
二次探测
二次探测主要优化的是探测时的步长。线性探测,我们可以看成是步长为1的探测,比如从下标值x开始,那么线性测试就是x+1,x+2,x+3依次探测。二次探测,对步长做了优化,比如从下标值x开始,x+1^2 , x+2^2, x+3^2,这样可以相对的优化线性探测所带来的的聚集问题。但是二次探测依然存在问题,比如我们连续插入的是32-112-82-2-192,那么他们依次累加的时候步长是相同的,因为上面我们提到了步长规律x+1^2 , x+2^2, x+3^2,x+4^2 = 1,4,9,16。那么他们依次累加的时候步长是相同的。也就是这种情况下会造成步长不一的一种聚集,还是会影响效率(当然这种可能性相对于连续的数字会小一些)。怎么根本解决这个问题呢?让每个步长不一样,那就引出了我们的再哈希法。
再哈希法
为了消除线性探测和二次探测中无论步长+1还是步长+平方中存在的问题,还有一种最常用的解决方案:再哈希法。
现在需要一种方法:产生一种依赖关键字的探测序列,而不是每个关键字都一样。那么,不同的关键字即使映射到相同的数组下标,也可以使用不同的探测序列。
再哈希法的做法就是:把关键字用另外一个哈希函数,再做一次哈希化,用这次哈希化的结果作为步长。对于指定的关键字,步长在整个探测中是不变的,不过不同的关键字使用不同的步长。
第二次哈希化需要具备如下特点:
1 和第一个哈希函数不同(不要再使用上一次的哈希函数了,不然结果还是原来的位置)
2 不能输出0(否则将没有步长,每次探测都是原地踏步,算法就进入了死循环)
计算机大牛已经设计出一种工作很好的哈希函数:
stepSize = constant - (key % constant)
其中 constant是质数,且小于数组的容量
例如:stepSize = 5 - (key % 5),满足需求,并且结果不可能为0
哈希化的效率
哈希表中执行插入和搜索操作效率非常高的,如果没有产生冲突,那么效率就会更高。如果发生冲突,存取时间就依赖后来的探测长度。平均探测长度以及平均存取时间,取决于填充因子,随着填充因子变大,探测长度也越来越长。随着填充因子变大,效率下降的情况在开放地址法方案中比链地址法更严重。
什么是填充因子?
填充因子表示当前哈希表中已经包含的数据项和整个哈希表长度的比值。
填充因子 = 总数据项 / 哈希表长度
开放地址法的填充因子最大是1,因为它必须寻找空白的单元才能将元素放入
链地址法的填充因子可以大于1,因为拉链法可以无限的延伸下去,只要你愿意(当然后面的效率会变低)
优秀的哈希函数
哈希表的主要优点是它的速度,所以在速度上不能满足,那么就达不到设计的目的了。提高速度的一个办法就是让哈希函数中尽量少的有乘法和除法。因为他们在计算机中性能是相对比较低的。
设计好的哈希函数应该具备哪些优点?
快速的计算
哈希表的优势在于效率,所以快速获取到对应的hashCode非常重要,我们需要通过快速计算来获取到元素对应的hashCode。
均匀分布
哈希表中无论是链地址法还是开放地址法,当多个元素映射到同一位置的时候都会影响效率所以优秀的哈希函数应该尽可能将元素映射到不同的位置,让元素均匀分布。在前面我们计算哈希值的时候使用的方式是cats = 327^3+127^2+20*27+17 = 60335,这种方式比较直观且计算的次数过多从而降低性能,那么我们这里就可以用到处理多项式的算法-秦九韶算法