Word文档编辑器大家应该经常使用吧,大家有没有留意到它编辑功能,当我们输入一个错误的单词时,单词单面就会标红提示“拼写错误”,这个功能是怎么实现的呢?其实啊,它是通过散列表实现的,学习了散列表原理后你就懂得这个功能的实现方式了。
散列表
散列表的英文名叫Hash Table,一般叫散列表或哈希表,散列表用的是数组支持按照下标随机访问数据的特性,所以散列表就是数组的一种扩展,由数组演化而来,可以说,如果没有数组就没有散列表。
我用一个列子解释一下,我们去游泳馆游泳时一般都会寄存衣物,这时前台就会登记我们名字后分配一个储物柜编号卡,后面我们通过这个编号卡就能快速地找到柜子存储衣物,回去时也能快速找到柜子取回衣物。
这里储物柜是按照编号顺序排列,就相当于一个数组,由于每天去游泳的人都各不相同,就不能每个柜子都贴上对应人的名字了,所以储物前就会先去前台分配一个编号,再根据编号的下标存储在数组的下标位置。
这就是典型的散列思想。每个去游泳的人的名字我们叫做键(key)或者关键字。我们把前台通过名字分配储物柜号的对应过程叫作散列函数,而通过散列函数计算得到的储物柜号码叫作散列值。
散列函数
散列函数,顾名思义,它就是一个函数,我们可以把它定义为hash(key),其中key就是元素的键,hash(key)就是通过散列函数计算得到的散列值。
刚刚举的例子中,散列函数其实就是前台工作人员将名字和号码牌对应起来的一个对应关系,这个例子比较不恰当,并没有一个固定的公式。那么,实用场景中,要怎么设计构造散列函数呢,我总结了三点基本的要求:
- 散列函数计算得到的散列值必须是一个非负整数;
- key1 = key2,那hash(key1) = hash(key2);
- key1 ≠ key2,那hash(key1) ≠ hash(key2);
第一点很容易理解,散列值最后是作为数组的下标的,数组下标是从0开始的;第二点,相同的key,得到的散列值也应该是相同的。
第三点看起来合情合理,但是在真实场景中,要想找到一个不同的键得出的散列值都不一样的散列函数几乎是不可能的,即便像业界著名的MD5、SHA、CRC等哈希算法也无法避免散列冲突,因为数组的空间有限,函数计算得到的值还必须在数组个数范围内,因此就会有很大概率出现冲突。
散列冲突
再好的散列函数也无法避免散列冲突,那怎么办呢?只能通过其他方式解决,一般散列冲突的解决办法有两类:开放寻址法和链表法。
1.开放寻址法
开放寻址法的核心思想是,如果出现了散列冲突,就寻找下一个空闲位置,插入新的数据。开放寻址法也有多种方式,将介绍一个简单的探测方法,线性探测(Linear Probing)。
线性探测
当我们往散列表插入数据时,如果经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置依次往后查找,将数据插入到找到的空闲位置,如果遍历到尾部仍没有空闲位置,我们就从表头开始找,直到找到为止。如图所示
通过线性探测要查找数据时,和插入数据类似,也是通过散列函数得到对应位置的元素,和要查询的数据作对比,如果一致,则取出该值,如果不一致,则从该位置往下到散列表的空闲位置一个个查找,如果找到,取出对应值,如果没有找到,则数据不存在。
而但通过线性探测法删除一个元素时就比较麻烦,如果查找到对应的元素时直接将该元素对应的位置置空的话,那按照上面说的线性探测查找方法,遇到一个空的位置时就停止查询,那这个空位置如果是刚刚被删除的元素,这时候这个查找方法就失败了。所以,删除一个元素时并不是直接删除,而是在要删除的位置标记deleted。在查找一个元素时如果遇到deleted标记的元素,则继续往下查找,如下图所示。
通过上面的介绍,我们可以知道,线性探测法有一个弊端。就是散列表剩余空间不足时,就会频繁地出现散列冲突,导致效率不高,极端情况下插入一个元素会时间复杂度为O(n)。
对于开放寻址的冲突解决方法,除了线性探测方法外,还有另外两种比较经典的探测方法,分别为二次探测和双重散列。
二次探测
二次探测法,和线性探测法类似,线性探测每次的探测步长是1步,它探测的下标序列是hash(key)+0,hash(key)+1,hash(key)+2,hash(key)+3...而二次探测的步长是原来的“二次方步长”,它的探测下标序列是列是hash(key)+0,hash(key)+1,hash(key)+4,hash(key)+9...
双重散列
双重散列,意思是不仅要使用一个散列函数,我们要使用一组散列函数hash1(key),hash2(key),hash3(key)...先使用第一个散列函数计算散列位置,如果出现冲突,再使用第二个散列函数,依次类推,直到找到空闲位置。
不管使用哪种线性冲突解决方法,当空闲位置较少的时候,出现冲突的概率就会加大,为了保证散列表的操作效率,一般会保证散列表有一定比例的空闲位置,我们用装载因子来表示散列表的空闲比例,它的计算公式如下
散列表的装载因子 = 填入表中的元素个数 / 散列表长度
2.链表法
链表法是一种更加普遍的散列表冲突解决方法,相比线性探测法,它更简单更容易理解。如图所示,散列表的元素就是一个“桶”或“槽”,每个桶都放入一个链表,将散列值相同的元素都放在同一个链表中。
当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应的链表中即可,时间复杂度为O(1)。当要查询或删除时,即通过同样的方法找到对应的槽位,再遍历链表查询或删除,那查询和删除的时间复杂度是多少呢?
查询和删除的时间复杂度和每个槽位链表的长度成正比,假设链表平均长度为k,那时间复杂度则为O(k)。对于散列比较均匀的散列函数,理论上k = n / m,其中n为散列表中数据的个数,m为散列表的长度。
解答开篇
有了上面的散列表的介绍,我们再来回顾下开篇提到的Word文档编辑器的拼写错误提示是怎么实现的?
我们常用的英文单词大约有20万个左右,假设平均每个单词有10个字母,那每个单词就大约有10个字节,20万个单词就有差不多2M左右的大小,对于现代的计算机来说,完全可以将20万个单词放在内存中,存储在散列表,每次输入一个单词时,就通过散列表查找,如果能找到就是拼写正确的,如果找不到则提示拼写错误。