目录
一、哈希概念
在以前我们所学习的数据结构,如顺序结构和平衡树,它们的元素关键码与其存储位置之间没有对应关系。因此在查找一个元素时,必要要对关键码进行多次比较。其中,顺序查找的时间复杂度为O(N),平衡树中为树的高度,即O(logN)。它们的搜索效率取决于搜素过程中元素的比较次数。
尽管如此,它们依然不是最为理想的搜索方法。最理想的搜索方法是可以不经过任何比较,一次直接从表中得到要搜索的元素。
既然如此,我们就可以通过构造一种存储结构,通过某种函数使元素的存储位置与它的关键码之间能够建立一一映射的关系,在查找时通过调用函数就能够很快找到对应的元素。
由此可以得到该数据结构的实现思路:
在插入元素时,根据待插入元素的关键码,通过特定函数计算出该元素的存储位置并存放
在搜索元素时,对元素的关键码通过同样的函数进行计算,找到该元素的存储位置并取出。此时就实现了常数次的搜索。
这种搜索数据的方法就好比,在图书馆中,当我们想要借一本书时,并不是自己去图书馆的书架上面一本一本的去查找。而是会去询问图书管理员,他会帮你查询对应书的位置,告诉你这本书在几楼几号区域的几号书架上的几号位置,此时你就可以拿着这个关键码,去对应的位置上取这本书。这其实就是一种“哈希”思想。
因此,“哈希”其实就是一种“映射思想”,并不是某种具体的数据结构。而通过哈希思想衍生出来的,例如“哈希桶”,这种才是具体的数据结构。
二、常见哈希函数
哈希函数是用于获取数据对应的关键码而诞生的。在这里就介绍两种比较常用的哈希函数。当然,实际中还存在大量的其他优秀哈希函数,有兴趣的话大家可以自行了解。
1.直接定址法
这种方法我们以前其实也用过。例如要找到一串小写英文字母中的最先出现的重复值。因为小写英文字母一共只有26个,所以就可以开一个具有26个空间的数组,然后将字符串中的每个字母都一一映射到数组中,例如‘a’映射到0,‘b’映射到1。映射完成后,再遍历字符串,根据字符串的次序在数组中查询对应字母的出现次数,找到第一个大于1的位置即可。这其实就是运用了哈希的直接定址法思想。
直接定址法,即取关键字的某个线性函数为散列地址:Hash(key) = A*key + B。
它的优点就是实现和理解起来都非常简单易懂。
但缺点也很明显。首先我们需要知道关键字的分布情况。即它们所处的区间。第二点也是最致命的缺点,那就是它只适合查找数据连续的情况。
例如有一组数字“1, 2, 10 ,6, 12, 8”,这种数字分布就比较均匀且连续,就比较适合使用直接定址法。但是,如果这组数据出现一个比较大的数字,如100,乃至1000。此时它的最小值为1,而最大值却是它的100倍千倍,此时如果使用直接定址法查找,仅仅7个的数据,就需要开大量的空间,空间浪费严重。
2.除留余数法
这种方法相较于直接定址法就有所优化。设散列表中允许的地址数为m,取一个不大于m,但最接近m或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key % p(p <= m),将关键码转为哈希地址进行插入。
但是,使用这种方法又会出现另一种问题,那就是“哈希冲突”。
2.1 哈希冲突
假设现在有如下一串数据:
为了存储串数据,采用除留余数法,p设置为10进行存储。这时就会出现一个问题,即10和20这两个数字,它们模10的值都为0,这就会导致在0下标位置上,会同时出现两个数字,0下标处要么存10要么存20,无论存储谁,都会导致数据丢失。
因此,“哈希冲突”的含义就是:不同的值映射到了不同的位置。有时也将“哈希冲突”称为“哈希碰撞”。
为了解决哈希冲突,又衍生出了两种解决方法,即“闭散列”和“开散列”。
2.2 闭散列——直接定址法
我们知道,在直接定址法中,因为每个数据映射唯一的位置,所以不会出现哈希冲突。因此,我们就可以将除留余数法与直接定址法结合起来,将数据以p为除数进行转换后,当出现哈希冲突时,就将数据向前挪动,直到找到为空的位置。
2.2.1 线性探测
将数据逐个向前挪动就是“线性探测”。但是这种方法还是有缺陷,在上图中,20被挪动到了位置3。但如果20后面还有一个数,例如3,13,它们的映射值为3,但是位置3已经被20占了,这就会导致它们也需要向前挪动。进而导致在需要查找数据时,导致查找时的命中率降低,查找效率降低。
但是线性探测有一个问题。就是如果我们删除了数组中的某一个值,该值所处的位置就会被清空。而查找一个数是从它映射的位置开始,如果遇到空,就是不存在。此时就可能影响到后续的数据查找。
注意,这里的