哈希表的介绍
哈希表通常是基于数组进行实现的,但是相对于数组,它也很多的优势:
- 可以提供非常快速的插入-删除-查找操作
- 无论多少数据,插入和删除值都接近常量的时间:即O(1)的时间复杂度。实际上,只需要几个机器指令即可完成
- 哈希表的速度比树还要快,基本可以瞬间查找到想要的元素
- 哈希表相对于树来说编码要容易很多 哈希表相对于数组的一些不足
- 哈希表中的数据是没有顺序的,所以不能以一种固定的方式(比如从小到大)来遍历其中的元素
- 通常情况下,哈希表中的key是不允许重复的,不能放置相同的key,用于保存不同的元素
哈希表到底是什么呢?
结构就是数组,但是它神奇的地方在于对数组下标值的一种变换, 这种变换我们可以使用哈希函数,通过哈希函数可以获取到 HashCode
案例:
-
公司使用一种数据结构来保存所有员工。 为了快速找到员工,给员工信息中添加员工编号,编号就是员工的下标值,查找某一个员工信息时,通过员工编号快速定位到员工信息。但是如果只知道员工的姓名,不知道编号怎么办? 就需要名字和编号产生关系。也就是通过why这个名字,我们 就能获取到它的索引值,而再通 过索引值我就能获取到why的信息。
-
使用一种数据结构存储单词信息,给定一个单词迅速找到这个单词。如果是线性查找,效率非常低,可以把单词转成数组的下标,那么以后我们要查找某个单词的信息,直接按照下标值一步即可访问到想要的元素。
上述的两个场景都会遇到一个问题就是:将字符串转换成下标
- 方式1:将字符串的每一个单词的 ASCII 编码相加。
- 方式2:自己设计方案(字符串转换数字)
a:1,b:2,c:3 ....
cats:3+1+20+19 = 43
上述方式的缺点就是计算的数组下标值太小,很多单词最终的下标可能都是同一个值,一个下标值只能存储一个数据,存入后来的数据会导致数据的覆盖。 - 方式3:幂的连乘
我们平时使用的大于10的数字,可以用一种幂的连乘来表示它的唯一性:7654 = 710^3 + 610^2 + 510^1 + 410^0
单词也可以使用这种方案来表示:比如cats = 327³+127²+20*27+17= 60337
这样基本可以保证唯一性,不会和别的单词重复。但是有一个缺点是:如果一个字符串很长,得到的数字就很大,数组就很大。可以使用 压缩算法 处理。
下标压缩算法
现在需要一种压缩方法,把幂的连乘方案系统中得到的巨大整 数范围压缩到可接受的数组范围中。
对于英文词典,多大的数组才合适呢?
如果只有50000个单词,可能会定义一个长度为50000的 数组,但是实际情况中,往往需要更大的空间来存储这些单词。 因为我们不能保证单词会映射到每一个位置,比如两倍的大小:100000。
如何压缩呢?
找一种方法,把 0 到超过 7000000000000 的范围, 压缩为从 0 到 100000
取余操作
假设把从 0~199 的数字,比如使用 largeNumber 代表, 压缩为从 0 到 9 的数字,比如使用smallRange代表:index = largeNumber % smallRange
当一个数被10整除时,余数一定在0~9之间,13%10=3,157%10=7,当然,这中间还是会有重复,不过重复的数量明显变小了。
什么是冲突?
尽管50000个单词,我们使用了100000个位置来存 储,并且通过一种相对比较好的哈希函数来完成。但是依然有可能会发生冲突。
就像之前 0~199 的数字选取5个放在长度为10的单元格中,如果我们随机选出来的是33,82,11,45,90,那么最终它们的位置 会是3-2-1-5-0,没有发生冲突。但是如果其中有一个33,还有一个73呢?还是发生了冲突。
解决冲突
- 链地址法
- 开放地址法
链地址法
链地址法是一种比较常见的解决冲突的方案。(也称为拉链法)
链地址法解决冲突的办法是每个数组单元中存储的不再是单个数据,而是一个链条。
这个链条使用什么数据结构呢?
常见的是数组或者链表。每个数组单元中存储着一个链表。一旦发现重复,将重复的元素插入到链表的首端或者末端即可。当查询时,先根据哈希化后的下标值找到对应的位置,再取出链表,依次查询找寻找的数据。
开放地址法
从图片的文字中我们可以了解到:开放地址法其实就是要寻找 空白的位置来放置冲突的数据项。
但是探索这个位置的方式不同, 有三种方法:
1. 线性探测
原理:线性的查找空白的单元。
插入32
经过哈希化得到的index=2,但是在插入的时候,发现该位置已经有了82。线性探测就是从index位置+1开始一点点查找合适的位置来放置32,空的位置就是合适的位置,在我们上面的例子中就是index=3的位置,这个时候32就会放在该位置。
查询32
首先经过哈希化得到index=2,比如2的位置结果和查询的数值是否相同,相同那么就直接返回。不相同呢?线性查找,从index位置+1开始查找和32一样的。
这里有一个特别需要注意的地方:如果32的位置我们之前没有插入,是否将整个哈希表查询一遍来确定32存不存在吗?
当然不是,查询过程有一个约定,就是查询到空位置,就停止,因为查询到这里有空位置,32之前不可能跳过空位置去其他的位置。
删除32
删除操作和插入查询比较类似,但是也有一个特别注意点,注意:删除操作一个数据项时,不可以将这个位置下标的内容设置为null,为什么呢?因为将它设置为null可能会影响我们之后查询其他操作,所以通常删除一个位置的数据项时,我们可以将它进行特殊处理(比 如设置为-1)。当我们之后看到-1位置的数据项时,就知道查询时要继续查询,但是插入时这个位置可以放置数据。
线性探测的问题
线性探测有一个比较严重的问题,就是聚集。什么是聚集呢?
比如我在没有任何数据的时候,插入的是22-23-24-25-26,那么意味着下标值:2-3-4-5-6的位置都有元素。这种一连串填充单元就叫做聚集。聚集会影响哈希表的性能,无论是插入/查询/删除都会影响。比如我们插入一个32,会发现连续的单元都不允许我们放置数据,并且在这个过程中我们需要探索多次。二次探测可以解决一部分这个问题。
2. 二次探测
如果之前的数据是连续插入的,那么新插入的一个数据可能需要探测很长的距离。
二次探测在线性探测的基础上进行了优化:
二次探测主要优化的是探测时的步长,什么意思呢?
线性探测,我们可以看成是步长为1的探测,比如从下标值x开始,那么线性测试就是x+1,x+2,x+3依次探测。
二次探测,对步长做了优化,比如从下标值x开始,x+1²,x+2²,x+3²。
这样就可以一次性探测比较长的距离,比避免那些聚集带来的影响。
二次探测的问题: 二次探测依然存在问题,比如我们连续插入的是32-112-82-2-192,那么它们依次累加的时候步长的相同的。也就是这种情况下会造成步长不一的一种聚集。还是会影响效率。(当然这种可能性相对于连续的数字会小一些)怎么根本解决这个问题呢?让每个人的步长不一样,就要使用再哈希法吧。
3. 再哈希法
为了消除线性探测和二次探测中无论步长+1还是步长+平法中存在的问题, 还有一种最常用的解决方案: 再哈希法
二次探测的算法产生的探测序列步长是固定的: 1, 4, 9, 16, 依次类推,现在需要一种方法: 产生一种依赖关键字的探测序列, 而不是每个关键字都一样,那么, 不同的关键字即使映射到相同的数组下标, 也可以使用不同的探测序列,再哈希法的做法就是: 把关键字用另外一个哈希函数, 再做一次哈希化, 用这次哈希化的结果作为步长.对于指定的关键字, 步长在整个探测中是不变的, 不过不同的关键字使用不同的步长.
第二次哈希化需要具备如下特点:
和第一个哈希函数不同. (不要再使用上一次的哈希函数了, 不然结果还是原来的位置)
不能输出为0(否则, 将没有步长. 每次探测都是原地踏步, 算法就进入了死循环)
计算机专家已经设计出一种工作很好的哈希函数
stepSize = constant - (key % constant)
其中constant是质数, 且小于数组的容量,例如: stepSize = 5 - (key % 5), 满足需求, 并且结果不可能为0.
哈希化的效率
哈希表中执行插入和搜索操作效率是非常高的,如果没有产生冲突,那么效率就会更高。如果发生冲突,存取时间就依赖后来的探测长度。平均探测长度以及平均存取时间,取决于填装因子,随着填装因子变大,探测长度也越来越长。
装填因子
容量是 10,存 8 个元素:8 / 10 = 0.8
装填因子 = 总数居项 / 哈希表长度
开放地址法的装填因子最大是多少呢?1,因为它必须寻找到空白的单元才能将元素放入。
链地址法的装填因子呢?可以大于1,因为拉链法可以无限的延伸下去,当然后面效率就变低了\
线性探测效率
图片解析:
- 当填装因子是1/2时,成功的搜索需要1.5次比较,不成功的搜索需要2.5次
- 当填装因子为2/3时,分别需要2.0次和5.0次比较
- 如果填装因子更大,比较次数会非常大。
- 应该使填装因子保持在2/3以下,最好在1/2以下,另一方面,填装因子越低,对于给定数 量的数据项,就需要越多的空间。
- 实际情况中,最好的填装因子取决于存储效率和速度之间的平衡,随着填装因子变小,存储效率下降,而速度上升。
二次探测和再哈希化
二次探测和再哈希法的性能相当。它们的性能比线性探测略好。
图片解析:
- 当填装因子是0.5时,成功和不成的查找平均需要2次比较
- 当填装因子为2/3时,分别需要2.37和3.0次比较
- 当填装因子为0.8时,分别需要2.9和5.0次
- 因此对于较高的填装因子,对比线性探测,二次探测和再哈希法还是可以忍受的。
链地址法
链地址法的效率分析有些不同,一般来说比开放地址法简单。
假如哈希表包含arraySize个数据项,每个数据项有一个链表,在表中一共包含N个数据项。那么,平均起来每个链表有多少个数据项呢?非常简单,N / arraySize。其实就是装填因子。
成功可能只需要查找链表的一半即可:1 + loadFactor/2,不成功呢?可能需要将整个链表查询完才知道不成功:1 + loadFactor。经过上面的比较我们可以发现,链地址法相对来说效率是好于开放地址法的。
哈希函数
设计好的哈希函数应该具备哪些优点呢?
- 快速的计算 哈希函数中尽量少的有乘法和除法。因为它们的性能是比较低的。需要通过快速的计算来获取到元素对应的hashCode。
- 均匀的分布 哈希表中,无论是链地址法还是开放地址法,当多个元素映射到同一个位置的时候,都会影响效率。所以,优秀的哈希函数应该尽可能将元素映射到不同的位置,让元素在哈希表中均匀的分布。 选用质数(除了1和它本身以外不再有其他因数的自然数)
快速计算:霍纳法则
在前面,我们计算哈希值的时候使用的方式(幂的连乘):cats = 327³+127²+2027+17= 60337 类似:7654 = 710^3 + 610^2 + 510^1 + 4*10^0,乘 10 是因为数字就有10个
这个表达式其实是一个多项式:a(n)x^n+a(n-1)x^(n-1)+…+a(1)x+a(0)
乘法次数:n+(n-1)+…+1=n(n+1)/2
加法次数:n次
乘法+加法:O(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(N²)降到了 O(N)
均匀分布
在设计哈希表时,我们已经有办法处理映射到相同下标值的情况:链地址法或者开放地址法。但是无论哪种方案,为了提供效率,最好的情况还是让数据在哈希表中均匀分布。因此,我们需要在使用常量的地方,尽量使用质数。
质数的使用:\
- 哈希表的长度
- N次幂的底数 质数和其他数相乘的结果相比于其他数字更容易产生唯一性的结果,减少哈希冲突。Java中的N次幂的底数选择的是31,是经过长期观察分布结果得出的。
Java中的HashMap
Java中的哈希表采用的是链地址法。
HashMap中index的计算公式:index = HashCode(Key) & (Length - 1)
比如计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001
假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111
把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9
JavaScript中进行较大数据的位运算时会出问题,所以我的代码实现中还是使用了取模。