本篇文章将介绍一种查找算法------哈希查找。
何为哈希查找?
先看定义:
哈希查找是通过计算数据元素的存储地址进行查找的一种方法。
哈希查找通过给定的哈希函数构造哈希表(也叫散列表),然后通过计算存储地址进行元素查找。
所以我们先来聊聊散列表。
散列表
散列是一种新的存储方式,它既不是按给定形式顺序存储,也不是毫无规律地进行存储,而是通过计算元素的存储地址实现存储。
计算元素存储地址的基本思想是:记录的存储位置与关键字之间存在对应关系,这个对应关系称为一个hash函数。
举个例子,现有一个数据元素序列,{1,3,5,7,9},若规定每个元素k的存储地址H(k) = k,则其存储结构如下: 这样的存储方式在查找元素的时候是非常方便的,比如想要查找元素值7,就把7代入hash函数,得到存储地址为7,此时比较下标为7的元素值,若相等,查找成功;或不相等,查找失败。
由此可以发现,哈希查找法的查找效率是非常高的,甚至可以达到O(1), 但是缺点也很明显,比较浪费存储空间。
冲突
散列表在构造过程中难免会产生一些冲突,何为冲突?
冲突指的是不同的元素值被映射到了同一个散列地址。
举个例子,有一个数据元素序列,{25,21,39,9,23,11},其hash函数为H(k) = k mod 7,则其存储结构为: 该元素序列中的每个元素求模7,其结果均在0~6之间,先看第一个元素25,因为25 % 7 = 4
,所以25存放在下标为4的位置: 因为21 % 7 = 0
,所以21存放在下标为0的位置: 因为39 % 7 = 4
,按理说39应该存放在下标为4的位置,但你会发现,下标为4的位置已经有元素值了,而你第二次准备存入的元素值又跟其不同,所以冲突便产生了。
这些具有相同函数值的关键字被称为同义词。
构造散列函数
我们说冲突是不同的元素映射到了同一个散列地址,而地址是由hash函数决定,所以想要避免冲突,就需要设计完美的hash函数。
但这是无法实现的,在散列查找方法中,冲突是不可避免的,只能说是尽可能地避免冲突的产生。
为了尽可能减少冲突,hash函数的设计就有一些讲究:
- 所选函数尽可能简单,以便提高转换速度
- 所选函数对元素值计算出的地址,应在散列地址中均匀分布,以减少空间浪费
构造hash函数需要考虑的因素有:
- 元素长度
- 散列表的大小
- 元素的分布情况
- 查找频率
这里介绍两种构造hash函数的方式:
- 直接定址法
- 除留余数法
直接定址法
先看第一种方式,直接定址法。
<math xmlns="http://www.w3.org/1998/Math/MathML"> H a s h ( k e y ) = a ∗ k e y + b ( a 、 b 为常数 ) Hash(key) = a * key + b(a、b为常数) </math>Hash(key)=a∗key+b(a、b为常数)
它的优点是:以关键码key的某个线性函数值为散列地址,不会产生冲突。
除留余数法
这种方法较直接定址法更为常用一些。
<math xmlns="http://www.w3.org/1998/Math/MathML"> H a s h ( k e y ) = k e y m o d p ( p 为整数 ) Hash(key) = key mod p(p为整数) </math>Hash(key)=keymodp(p为整数)
该方法的关键是如何能够取到合适的p值?
若表长为m,则p应取小于等于m的质数。
解决冲突的方式
前面说过了,冲突是无法避免的,那么在产生冲突时该如何解决如何处理呢?
这里介绍两种常见的方式:
- 开放地址法
- 链地址法
开放地址法
开放地址法的基本思想为:有冲突时就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将数据元素存入。
这下一个散列地址该如何找呢?也可以分为三种方式:
- 线性探测法:即以线性序列(如1、2、3、...、m - 1)为间隔寻找下一个散列地址
- 二次探测法:即以二次序列(如1^2^、-1^2^、2^2^、-2^2^、...、q^2^)为间隔寻找下一个散列地址
- 伪随机探测法:即以一个伪随机数为间隔寻找下一个散列地址
举个例子,比如有一个序列{47,7,29,11,16,92,22,8,3},散列表的表长为m = 11,hash函数为Hash(key) = key mod 11,我们试着用线性探测法来解决一下存储过程中的冲突。 现在准备存入元素47,因为47 % 11 = 3
,所以将47存放在下标为3的位置: 因为7 % 11 = 7
,所以将7存放在下标为7的位置: 因为29 % 11 = 7
,所以将29存放在下标为7的位置,但该位置已经有了元素值,且两者不相同,此时产生冲突。现在我们利用线性探测法,即在该位置上加上一个序列值,7 + 1 = 8
,所以将其存放到下标为8的位置: 接下来是11、16、92,它们在存放过程中均没有产生冲突,直接存入即可: 下一个元素是22,因为22 % 11 = 0
,而下标0的位置已经有了元素值,所以采用线性探测法,在此位置的基础上加上一个序列值,0 + 1 = 1
,所以将其存放到下标为1的位置: 元素8也是如此,它应该存放到下标为9的位置: 最后看元素3,因为3 % 11 = 3
,所以存放位置应为下标3,但下标3位置已有元素,于是采用线性探测法,加上一个序列值,3 + 1 = 4
,但下标4的位置仍然有元素,那就加第二个序列值,3 + 2 = 5
,下标5的位置还是有元素,那就再加第三个序列值,3 + 3 = 6
,直至找到空的散列地址。
此时下标为6的位置是空的,所以存入元素3: 查找过程也是一样的,比如11,让其与11求模,余数为0,所以到下标为0的位置找出元素值比较,相等则查找成功;
或者8,让其与11求模,余数为8,所以到下标为8的位置找出元素值比较,发现不相等,根据线性探测法,你就需要到下一个序列值对应的位置查找,也就是下标9,此时元素值相等,查找成功。
还有两种方式,二次探测法和伪随机探测法,原理是一样的,无非就是地址间隔不一样,就不重复讲解了。
链地址法
第二种处理冲突的方法就是链地址法。
其基本思想为:相同散列地址的记录链成一个单链表,m个散列地址就有m个单链表,然后用一个数组将m个单链表的表头指针存储起来,形成一个动态的结构。
比如一个元素序列{19,14,23,1,68,20,84,27,55,11,10,79},其hash函数为 <math xmlns="http://www.w3.org/1998/Math/MathML"> H a s h ( k e y ) = k e y m o d 13 Hash(key) = key mod 13 </math>Hash(key)=keymod13
这里面很多元素就会产生冲突,比如元素14、1、27、79求模13的结果都是1,所以我们可以将这些同义词链成一个单链表,然后存储在数组中对应的位置,其函数结果为1,就将单链表的表头指针存储在数组下标为1的位置。 其建立散列表的方式也非常简单:
- 取数据元素的关键字key,计算其散列函数值。若该地址对应的链表为空,则将该元素插入此链表,否则执行步骤2解决冲突
- 根据选择的冲突解决方案,计算关键字key的下一个存储地址。若该地址对应的链表不为空,则利用链表的前插法或后插法将该元素插入到此链表中
查找效率分析
若使用平均查找长度ASL来衡量查找算法,ASL取决于:
- hash函数
- 处理冲突的方法
- 散列表的装填因子α
其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> α = 表中填入的记录数 哈希表的长度 α = \frac{表中填入的记录数}{哈希表的长度} </math>α=哈希表的长度表中填入的记录数
α越大,表中记录数越多,说明表装得越满,发生冲突的可能性就越大,查找时比较次数就越多;反之,相反。
所以哈希查找的效率只是理论上能达到O(1),根据解决冲突的方式不同,查找效率也有所不同:
- <math xmlns="http://www.w3.org/1998/Math/MathML"> A S L ≈ 1 + α 2 ASL ≈ 1 + \frac{α}{2} </math>ASL≈1+2α (链地址法)
- <math xmlns="http://www.w3.org/1998/Math/MathML"> A S L ≈ 1 2 ( 1 + 1 1 − α ) ASL ≈ \frac{1}{2}(1 + \frac{1}{1 - α}) </math>ASL≈21(1+1−α1) (线性探测法)
- <math xmlns="http://www.w3.org/1998/Math/MathML"> A S L ≈ − 1 α I n ( 1 − α ) ASL ≈ -\frac{1}{α}In(1 - α) </math>ASL≈−α1In(1−α) (随机探测法)