数据结构入门教程-散列表

关于散列表想必大家也不陌生,散列表又称哈希表,在实际的开发中,我们经常用到它,比如一个学生管理系统中,我们想要查询学生的姓名时,可以通过输入学号来查询(如图),在比如我们要统计一本英语书中一些频繁出现的单词的出现的次数,同样利用哈希表是最快的,正是由于这些需求的出现,我们的散列表数据结构诞生了,接下来我们来看看关于它的一些操作.

散列表一.png
散列表的介绍

散列表也称哈希表(hash table),这种数据结构提供了键(key)和值(value)的映射关系,我们只需要提供一个key就能得到key对应的value,所以它的查询效率跟我们的数组类似,时间复杂度为o(1),如图是哈希表的存储结构:

哈希表结构.png

那么问题出现了,既然我们是只需要通过key就能查询到对应的value,它是如何找到的呢?带着这个疑问我们来看看一个函数(哈希函数)

哈希函数

想必大家对我们的数组是很熟悉的,我们都知道想要在数组中查询一个元素时,我们只需要一个下标就能快速定位到对应的值,好巧啊,哈哈哈,我们的哈希表的本质也是数组,不同的是我们哈希表去查询的时候,带的是key(key为任意类型的),一般key 的值为String类型的,在试着想一下,有没有这样一种可能,我们通过某种操作,将key计算成类似于数组下标一样的整型值,不就是数组取值的方式?这便是我们哈希函数所做的事情了.

哈希函数.png

就类似于上图的操作,我们带着key3去查询,首先函数计算出它的下标为2,然后就是数组array[2]取值,将value3返回给我们,既然我们现在知道哈希表是如何快速通过key来定位value的过程了,那么问题又来了,这个哈希函数是怎么实现的呢?

在我们的java中,很容易被我们忽略的问题,那就是Object的hashCode方法,我们都知道,只要我们创建一个对象,该对象就会有hashCode的方法,这里默认是继承自Object的,hashCode也是区分不同对象之间的一个重要标识,无论对象是什么类型的,其对应的hashCode的是一个整型的值,既然是整型的值,转换成数组的下标就不是那么复杂了,常见的转换方式为数组取模的方式进行转化公式如下:

index = HashCode(key)%Array.length

通过上述的公式,我们就可以将任何类型的key转化为对应数组的下标index,比如数组的长度为8,则当key=00112时,
计算结果:index = HashCode("00112")%array.length = 1420036703%8=7

至于这个值如何算出的我也不知,所以这里大家不要钻牛角尖哈,知道其原理即可,看完了散列表是如何定位value的过程,我们来看看散列表的读和写操作

散列表的读和写操作过程

前面我们知道了哈希函数的计算过程,那么对于读和写的操作就简单多了,首先来看写操作

  • 写操作(Put)

写操作也就是我们往哈希表中保存数据的过程,也就是插入新的键值对,比如:map.put("002931","小黑"),这个过程就是将key为002931和value为小黑的减值对插入我们的哈希表中的过程,究竟如何我们通过图解的方式来学习哈希表的写操作过程

  • 第1步,我们通过哈希函数将key转化成数组下标为5.
  • 第2步,如果数组下标为5的位置是空的,就可以将该值Entry进行填充,如图:
put操作1.png

Entry就是我们的键值对,这里就不要纠结了,看过HashMap集合的源码的就知道,它有个内部类就是Entry.接着看

我们也知道,数组的大小是固定好的,当我们put操作越来越多时,不同的key计算得到的index可能会相同,如图中的002936这个key对应数组的下标为2,002947对应数组下标的值可能也为2.

hash冲突.png

就像图中entry1和entry6一样,这样会造成函数冲突,在哈希表中哈希冲突是无法避免的,既然无法避免,我们就要有对应的解决办法,常见的为开放寻址法和链表法,我们分别来看下:

开放寻址法

开放寻址法实际上就是如果当key的下标已经存在,我们就继续寻找下一个空档的位置,直到找到位置为止,具体的实现过程我们可以看图:

开放寻址步棸1.png

可以从图中看到,数组下标3处已经存在键值对entry5,那么我们将entry6往后移动一位去寻找空档的位置,如图:

开放寻址步骤2.png

从图中发现,数组下标为4的位置处为null,那么键值对entry6可以存放在这个空档的位置处,如图:

开放寻址步骤3.png

这就是开放寻址法的基本思路,在我们java中有一个很强大的类ThreadLocal类用的就是开放寻址的思路,感兴趣的可以去看看,但是开放寻址法法的时间复杂度为o(1)or o(n),大家应该很清楚的,如果是这样的话,不符合我们hash表特性,所以在实际的开发中我们会常用的链表法,这也是HashMap中常见的,我们都知道HashMap中的内部类Entry不仅仅是一个键值对对象,也是链表的头结点,每一个Entry对象都可以通过next指针指向下一个entry节点,假如发生哈希冲突,我们只需要将它插入到对应的链表即可,来看图:

链表法1.png

这样就解决了哈希冲突,到这里关于散列表的写操作我们完成了,接着我们看看读操作(get)

  • get操作

读操作就是我们通过指定的key在散列表中查找对应的value的过程,如:map.get("002936"),意思是查找key为002936的entry在散列表中所对应的值,具体的实现过程看如下图:

get操作.png

上图过程中,我们需要查找的是key为002936的所对应的value,具体的步骤为:

  • 首先将key通过哈希函数转化为数组对应的下标2
  • 接着是找到下标为2的位置,如果正好是我们要找的,那么恭喜你找到了,如果不是,通过链表去找,直到找到为止,接着返回对应的value即可

这就是get操作的整个过程,既然我们的散列表是数组实现的肯定会涉及到数组扩容的问题,关于数组扩容的实现过程我们都知道,那么散列表的如何?

散列表扩容

首先我们需要知道下,散列表在什么情况下才会扩容,答案是肯定的就是当我们进行多次的写入操作时,由于数组的自身的原因,导致我们散列表key映射的位置发生哈希冲突的几率增大,这样一来的话,导致大量的元素都在同一个entry的链表中,对我们后面的get操作会产生很大的效率问题,那么此刻最好的解决办法是我们来扩展散列表的长度,也就是扩容.

既然分析了扩容的原因,我们来看看散列表的扩容有何区别跟数组的,这里我们还是以HashMap的扩容机制来说,在HashMap有两个影响扩容的因素分别是:

  • Capacity:表示的是当前HashMap的长度
  • LoadFactor:HashMap的负载因子,默认是0.75f

如果HashMap需要扩容,需要满足如下的条件

HashMap.Size >= Capacity x LoadFactor

那么具体的过程如何了,我们来看看

  • 首先我们需要创建一个新的空的Entry数组,长度是原来数组的2倍
  • 重新进行hash计算,遍历原来的数组,将所有重新hash后的entry进行添加到新的Entry数组中,这样做的目的是,当我们数组的长度扩大后,hash的规则随之改变了,我们来看图:
扩容1.png

上图为扩容前的情况

扩容2.png

扩容3.png

上图为扩容后的散列表的结构,到这里关于散列表的重要的知识也就完事了,说明一点,本篇中图是按照<漫画算法>中的散列表画的,通过自己的理解总结的一篇文章,建议猿友们可以看看此书......

你可能感兴趣的:(数据结构入门教程-散列表)