Hash表也称散列表,Hash表是一种根据关键字值(key - value)而直接进行访问的数据结构。它基于数组,通过把关键字映射到数组的某个下标来加快查找速度,但是又和数组、链表、树等数据结构不同,在这些数据结构中查找某个关键字,通常要遍历整个数据结构,也就是O(N)的时间级,但是对于哈希表来说,只是O(1)的时间级。
注意,这里有个重要的问题就是如何把关键字转换为数组的下标,这个转换的函数称为哈希函数(也称散列函数),转换的过程称为哈希化。
1、哈希函数的引入
我们都知道,字典的优点是可以通过目录快速定位到所要查找的单词。如果想把一本英文字典的每个单词,从 a 到 zyzzyva,都写入计算机内存,以便快速读写,那么就可以选择哈希表。
如果想在内存中存储5000个英文单词,假设每个单词会占用一个数组单元,那么数组的大小是5000,同时可以用数组下标存取单词,但是数组下标和单词怎么建立联系呢?
首先要建立单词和数字(数组下标)的关系:
我们知道 ASCII 是一种编码,其中 a 表示97,b表示98,以此类推,一直到122表示z,而每个单词都是由这26个字母组成,可以不用 ASCII 编码那么大的数字,自己设计一套类似 ASCII的编码,比如a表示1,b表示2,依次类推,z表示26。
那么如何把单个字母的数字组合成代表整个单词的数字呢?
①、把数字相加
比如单词 cats 转换成数字:cats = 3 + 1 + 20 + 19 = 43
那么单词 cats 存储在数组中的下标为43,所有的英文单词都可以用这个办法转换成数组下标。但是这个办法真的可行吗?
假设约定一个单词最多有 10 个字母,那么字典的最后一个单词为 zzzzzzzzzz ,其转换为数字:zzzzzzzzzz = 26*10 = 260
可以得到单词编码的范围是从1-260。很显然,这个范围是不够存储5000个单词的,那么肯定有一个位置存储了多个单词,每个数组的数据项平均要存储192个单词(5000除以260)。
对于上面的问题,我们如何解决呢?
②、幂的连乘
将单词表示的数拆成数列,用适当的 27 的幂乘以这些位数(因为有26个可能的字符,以及空格,一共27个),然后把乘积相加,这样就得出了每个单词独一无二的数字。
比如把单词cats 转换为数字:cats = 3*273 + 1*272 + 20*271 + 19*270 = 59049 + 729 + 540 + 19 = 60337
这个过程会为每个单词创建一个独一无二的数,但是注意的是我们这里只是计算了 4 个字母组成的单词,如果单词很长,比如最长的10个字母的单词 zzzzzzzzzz,仅仅是279 结果就超出了7000000000000,这个结果是很巨大的,在实际内存中,根本不可能为一个数组分配这么大的空间。
所以这个方案的问题就是虽然为每个单词都分配了独一无二的下标,但是只有一小部分存放了单词,很大一部分都是空着的。那么现在就需要一种方法,把数位幂的连乘系统中得到的巨大的整数范围压缩到可接受的数组范围中。
③、压缩可选值
对于英语字典,假设只有5000个单词,这里我们选定容量为10000 的数组空间来存放(后面会介绍为啥需要多出一倍的空间)。那么我们就需要将从 0 到超过 7000000000000 的范围,压缩到从0到10000的范围。
第一种方法:取余,得到一个数被另一个整数除后的余数。首先我们假设要把从0-199的数字(用largeNumber表示),压缩为从0-9的数字(用smallNumber表示),后者有10个数,所以变量smallRange 的值为10,这个转换的表达式为:
smallNumber = largeNumber % smallRange
当一个数被 10 整除时,余数一定在0-9之间,这样,我们就把从0-199的数压缩为从0-9的数,压缩率为 20 :1。
也可以用类似的方法把表示单词唯一的数压缩成数组的下标:arrayIndex = largerNumber % smallRange
这也就是哈希函数。它把一个大范围的数字哈希(转化)成一个小范围的数字,这个小范围的数对应着数组的下标。使用哈希函数向数组插入数据后,这个数组就是哈希表。
2、解决冲突的方法
把巨大的数字范围压缩到较小的数字范围,那么肯定会有几个不同的单词哈希化到同一个数组下标,即产生了冲突。
主要有两种解决冲突的方法:1、开发地址法 2、链地址法
1、开发地址法
开发地址法中,若数据项不能直接存放在由哈希函数所计算出来的数组下标时,就要寻找其他的位置。分别有三种方法:线性探测、二次探测以及再哈希法。
①、线性探测
在线性探测中,它会线性的查找空白单元。比如如果 5421 是要插入数据的位置,但是它已经被占用了,那么就使用5422,如果5422也被占用了,那么使用5423,以此类推,数组下标依次递增,直到找到空白的位置。这就叫做线性探测,因为它沿着数组下标一步一步顺序的查找空白单元。
需要注意的是,当哈希表变得太满时,我们需要扩展数组,但是需要注意的是,数据项不能放到新数组中和老数组相同的位置,而是要根据数组大小重新计算插入位置。这是一个比较耗时的过程,所以一般我们要确定数据的范围,给定好数组的大小,而不再扩容。
另外,当哈希表变得比较满时,我们每插入一个新的数据,都要频繁的探测插入位置,因为可能很多位置都被前面插入的数据所占用了,这称为聚集。数组填的越满,聚集越可能发生。
②、二次探测
二测探测是防止聚集产生的一种方式,思想是探测相距较远的单元,而不是和原始位置相邻的单元。
线性探测中,如果哈希函数计算的原始下标是x, 线性探测就是x+1, x+2, x+3, 以此类推;而在二次探测中,探测的过程是x+1, x+4, x+9, x+16,以此类推,到原始位置的距离是步数的平方。二次探测虽然消除了原始的聚集问题,但是产生了另一种更细的聚集问题,叫二次聚集:比如讲184,302,420和544依次插入表中,它们的映射都是7,那么302需要以1为步长探测,420需要以4为步长探测, 544需要以9为步长探测。只要有一项其关键字映射到7,就需要更长步长的探测,这个现象叫做二次聚集。
2、链地址法
在开放地址法中,通过再哈希法寻找一个空位解决冲突问题,另一个方法是在哈希表每个单元中设置链表(即链地址法),某个数据项的关键字值还是像通常一样映射到哈希表的单元,而数据项本身插入到这个单元的链表中。其他同样映射到这个位置的数据项只需要加到链表中,不需要在原始的数组中寻找空位。
3、总结
哈希表基于数组,类似于key-value的存储形式,关键字值通过哈希函数映射为数组的下标,如果一个关键字哈希化到已占用的数组单元,这种情况称为冲突。用来解决冲突的有两种方法:开放地址法和链地址法。在开发地址法中,把冲突的数据项放在数组的其它位置;在链地址法中,每个单元都包含一个链表,把所有映射到同一数组下标的数据项都插入到这个链表中。