大白话讲解--哈希表的原理

1、简单介绍哈希表

哈希表也叫散列表,哈希表是一种数据结构,它提供了快速的插入操作和查找操作,无论哈希表总中有多少条数据,插入和查找的时间复杂度都是为O(1),因为哈希表的查找速度非常快,所以在很多程序中都有使用哈希表,例如拼音检查器。

哈希表也有自己的缺点,哈希表的实现是基于数组的,我们知道数组创建后扩容成本比较高,所以当哈希表被填满时,性能下降的比较严重

哈希表采用的是一种转换思想,其中一个中要的概念是如何将「键」或者「关键字」转换成数组下标?在哈希表中,这个过程通过哈希函数(可以理解为一种抽象算法,将复杂的数据通过计算得到唯一的数值)来完成。

我们上学的时候,大家都会有一个学号「1-n号」中的一个号码,如果我们用哈希表来存放班级里面学生信息的话,我们利用学号作为「键」或者「关键字」,这个「键」或者「关键字」就可以直接作为数据的下标,不需要通过哈希函数进行转化。如果我们需要安装学生姓名作为「键」或者「关键字」,这时候我们就需要哈希函数来帮我们转换成数组的下标。

2、哈希碰撞

虽然我们有了哈希函数,但是哈希函数不能保证每个数字都映射到数组空白处,这是因为准备存储的数据量过大,而底层开辟的数组空间过小,导致很多数据经过哈希计算之后变成了相同的数值,这里可以举例说明,比如我们的哈希函数运算规则是 n % 3,输入n为3,6,9;则这三个数据就会产生哈希碰撞。

3、解决哈希碰撞

哈希冲突是不可避免的,我们常用解决哈希冲突的方法有两种「开放地址法」和「链表法」

开放地址法

在开放地址法中,若数据不能直接存放在哈希函数计算出来的数组下标时,就需要寻找其他位置来存放。在开放地址法中有三种方式来寻找其他的位置,分别是「线性探测」、「二次探测」、「再哈希法」

线性探测
线性探测的插入

在线性探测哈希表中,数据的插入是线性的查找空白单元,例如我们将数88经过哈希函数后得到的数组下标是16,但是在数组下标为16的地方已经存在元素,那么就找17,17还存在元素就找18,一直往下找,直到找到空白地方存放元素。

线性探测的删除

线性探测哈希表的删除相对来说比较复杂一点,我们不能简单的把这一项数据删除,让它变成空,为什么呢?

线性探测哈希表在查找的时候,一旦我们通过线性探测方法,找到一个空闲位置,我们就可以认定哈希表中不存在这个数据。但是,如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效。本来存在的数据,会被认定为不存在?所以我们删除可以实现逻辑删除

因此我们需要一个特殊的数据来顶替这个被删除的数据,假设我们的学生学号都是正数,则我们用学号等于-1来代表被删除的数据。

线性探测的查找

线性探测哈希表的查找过程有点儿类似插入过程。我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在哈希表中。

但是这样线性查找会带来一种聚集问题:

在线性探测哈希表中,数据会发生聚集,一旦聚集形成,它就会变的越来越大,那些哈希函数后落在聚集范围内的数据项,都需要一步一步往后移动,并且插入到聚集的后面,因此聚集变的越大,聚集增长的越快。这个就像我们在逛超市一样,当某个地方人很多时,人只会越来越多,大家都只是想知道这里在干什么。

二次探测

二次探测是防止聚集产生的一种尝试,思想是探测相隔较远的单元,而不是和原始位置相邻的单元。在线性探测中,如果哈希函数得到的原始下标是x,线性探测就是x+1,x+2,x+3......,以此类推,而在二次探测中,探测过程是x+1,x+4,x+9,x+16,x+25......,以此类推,到原始距离的步数平方

二次探测的问题

二次探测消除了线性探测的聚集问题,这种聚集问题叫做原始聚集,然而,二次探测也产生了新的聚集问题,之所以会产生新的聚集问题,是因为所有映射到同一位置的关键字在寻找空位时,探测的位置都是一样的。

比如讲1、11、21、31、41依次插入到哈希表中,它们映射的位置都是1,那么11需要以一为步长探测,21需要以四为步长探测,31需要为九为步长探测,41需要以十六为步长探测,只要有一项映射到1的位置,就需要更长的步长来探测,这个现象叫做二次聚集。

二次聚集不是一个严重的问题,但是二次探测不怎么使用,因为「双哈希」是一种更加好的解决办法。

4、双哈希

双哈希是为了消除原始聚集和二次聚集问题,不管是线性探测还是二次探测,每次的探测步长都是固定的双哈希是除了第一个哈希函数外再增加一个哈希函数用来根据关键字生成探测步长,这样即使第一个哈希函数映射到了数组的同一下标,但是探测步长不一样,这样就能够解决聚集的问题。

第二个哈希函数必须具备如下特点

  • 和第一个哈希函数不一样
  • 不能输出为0,因为步长为0,每次探测都是指向同一个位置,将进入死循环。
  • 表的容量需要是一个质数

双哈希的哈希表写起来来线性探测差不多,就是把探测步长通过「关键字」来生成

为什么双哈希需要哈希表的容量是一个质数?

假设我们哈希表的容量为15,某个「关键字」经过双哈希函数后得到的数组下标为0,步长为5。那么这个探测过程是0,5,10,0,5,10,一直只会尝试这三个位置,永远找不到空白位置来存放,最终会导致崩溃。

如果我们哈希表的大小为13,某个「关键字」经过双哈希函数后得到的数组下标为0,步长为5。那么这个探测过程是0,5,10,2,7,12,4,9,1,6,11,3。会查找到哈希表中的每一个位置。

使用开放地址法,不管使用那种策略都会有各种问题,开放地址法不怎么使用,在开放地址法中使用较多的是双哈希策略。

5、链表法

开放地址法中,通过在哈希表中再寻找一个空位解决冲突的问题,还有一种更加常用的办法是使用「链表法」来解决哈希冲突。「链表法」相对简单很多,「链表法」是每个数组对应一条链表。当某项关键字通过哈希后落到哈希表中的某个位置,把该条数据添加到链表中,其他同样映射到这个位置的数据项也只需要添加到链表中,并不需要在原始数组中寻找空位来存储。

「链表法」解决哈希冲突代码比较简单,但是代码比较多,因为需要维护一个链表的操作,我们这里采用有序链表,有序链表不能加快成功的查找,但是可以减少不成功的查找时间,因为只要有一项比查找值大,就说明没有我们需要查找的值,删除时间跟查找时间一样,有序链表能够缩短删除时间。但是有序链表增加了插入时间,我们需要在有序链表中找到正确的插入位置。

哈希表的效率

在哈希表中执行插入和搜索操作都可以达到O(1)的时间复杂度,在没有哈希冲突的情况下,只需要使用一次哈希函数就可以插入一个新数据项或者查找到一个已经存在的数据项。

如果发生哈希冲突,插入和查找的时间跟探测长度成正比关系,探测长度取决于装载因子,装载因子是用来表示空位百分比

装载因子的计算公式:

❝ 装载因子 = 表中已存的元素 / 表的长度

装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。

开放地址法和链表法的比较

如果使用开放地址法,对于小型的哈希表,双哈希法要比二次探测的效果好,如果内存充足并且哈希表一经创建,就不再修改其容量,在这种情况下,线性探测效果相对比较好,实现起来也比较简单,在装载因子低于0.5的情况下,基本没有什么性能下降。

如果在创建哈希表时,不知道未来存储的数据有多少,使用链表法要比开放地址法好,如果使用开放地址法,随着装载因子的变大,性能会直线下降。

当两者都可以选时,使用链表法,因为链表法对应不确定性更强,当数据超过预期时,性能不会直线下降。

哈希表在JDK中有不少的实现,例如HahsMapHashTable等,对哈希表感兴趣的可以阅读本文后去查看JDK的相应实现,相信这可以增强你对哈希表的理解。

  • 29
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值