万书网 > 文学作品 > 漫画算法:小灰的算法之旅 > 2.4神奇的散列表

2.4神奇的散列表





2.4.1 为什么需要散列表



说起学习英语,小灰上学时可没有那么丰富的学习资源和工具。当时有一款很流行的电子词典,小伙伴们遇到不会的单词,只要输入到小小的电子词典里,就可以查出它的中文含义。

当时的英语老师强烈反对使用这样的工具,因为电子词典查出来的中文资料太有限,而传统的纸质词典可以查到单词的多种含义、词性、例句等。

但是,同学们还是倾向于使用电子词典。因为电子词典实在太方便了,只要输入要查的单词,一瞬间就可以得到结果,而不需要像纸质词典那样烦琐地进行人工查找。

在我们的程序世界里,往往也需要在内存中存放这样一个“词典”,方便我们进行高效的查询和统计。

例如开发一个学生管理系统,需要有通过输入学号快速查出对应学生的姓名的功能。这里不必每次都去查询数据库,而可以在内存中建立一个缓存表,这样做可以提高查询效率。

再如我们需要统计一本英文书里某些单词出现的频率,就需要遍历整本书的内容,把这些单词出现的次数记录在内存中。

因为这些需求,一个重要的数据结构诞生了,这个数据结构叫作散列表  。

散列表也叫作哈希表  (hash  table),这种数据结构提供了键(Key)  和值(Value)  的映射关系。只要给出一个Key,就可以高效查找到它所匹配的Value,时间复杂度接近于O(1)  。

那么,散列表是如何根据Key来快速找到它所匹配的Value呢?

这就是我下面要讲的散列表的基本原理。



2.4.2 哈希函数

小灰,在咱们之前学过的几个数据结构中,谁的查询效率最高?

当然是数组喽,数组可以根据下标,进行元素的随机访问。

说得没错,散列表在本质上也是一个数组。

可是数组只能根据下标,像a[0]、a[1]、a[2]、a[3]、a[4]这样来访问,而散列表的Key则是以字符串类型为主的。

例如以学生的学号作为Key,输入002123,查询到李四;或者以单词为Key,输入by,查询到数字46……

所以我们需要一个“中转站”,通过某种方式,把Key和数组下标进行转换。这个中转站就叫作哈希函数  。

这个所谓的哈希函数是怎么实现的呢?

在不同的语言中,哈希函数的实现方式是不一样的。这里以Java的常用集合HashMap为例,来看一看哈希函数在Java中的实现。

在Java及大多数面向对象的语言中,每一个对象都有属于自己的hashcode,这个hashcode是区分不同对象的重要标识。无论对象自身的类型是什么,它们的hashcode都是一个整型变量。

既然都是整型变量,想要转化成数组的下标也就不难实现了。最简单的转化方式是什么呢?是按照数组长度进行取模运算。

index  =  HashCode  (Key)  %  Array.length

实际上,JDK(Java  Development  Kit,Java语言的软件开发工具包)中的哈希函数并没有直接采用取模运算,而是利用了位运算的方式来优化性能。不过在这里可以姑且简单理解成取模操作。

通过哈希函数,我们可以把字符串或其他类型的Key,转化成数组的下标index。

如给出一个长度为8的数组,则当

key=001121时,

index  =  HashCode  ("001121")  %  Array.length  =  1420036703  %  8  =  7

而当key=this时,

index  =  HashCode  ("this")  %  Array.length  =  3559070  %  8  =  6



2.4.3 散列表的读写操作

有了哈希函数,就可以在散列表中进行读写操作了。

1.  写操作(put)

写操作就是在散列表中插入新的键值对(在JDK中叫作Entry)。

如调用hashMap.put("002931",  "王五"),意思是插入一组Key为002931、Value为王五的键值对。

具体该怎么做呢?

第1步,通过哈希函数,把Key转化成数组下标5。

第2步,如果数组下标5对应的位置没有元素,就把这个Entry填充到数组下标5的位置。

但是,由于数组的长度是有限的,当插入的Entry越来越多时,不同的Key通过哈希函数获得的下标有可能是相同的。例如002936这个Key对应的数组下标是2;002947这个Key对应的数组下标也是2。

这种情况,就叫作哈希冲突  。

哎呀,哈希函数“撞衫”了,这该怎么办呢?

哈希冲突是无法避免的,既然不能避免,我们就要想办法来解决。解决哈希冲突的方法主要有两种,一种是开放寻址法,一种是链表法。

开放寻址法的原理很简单,当一个Key通过哈希函数获得对应的数组下标已被占用时,我们可以“另谋高就”,寻找下一个空档位置。

以上面的情况为例,Entry6通过哈希函数得到下标2,该下标在数组中已经有了其他元素,那么就向后移动1位,看看数组下标3的位置是否有空。

很不巧,下标3也已经被占用,那么就再向后移动1位,看看数组下标4的位置是否有空。

幸运的是,数组下标4的位置还没有被占用,因此把Entry6存入数组下标4的位置。

这就是开放寻址法的基本思路。当然,在遇到哈希冲突时,寻址方式有很多种,并不一定只是简单地寻找当前元素的后一个元素,这里只是举一个简单的示例而已。

在Java中,ThreadLocal所使用的就是开放寻址法。

接下来,重点讲一下解决哈希冲突的另一种方法——链表法。这种方法被应用在了Java的集合类HashMap当中。

HashMap数组的每一个元素不仅是一个Entry对象,还是一个链表的头节点。每一个Entry对象通过next指针指向它的下一个Entry节点。当新来的Entry映射到与之冲突的数组位置时,只需要插入到对应的链表中即可。

2.  读操作(get)

讲完了写操作,我们再来讲一讲读操作。读操作就是通过给定的Key,在散列表中查找对应的Value。

例如调用  hashMap.get("002936"),意思是查找Key为002936的Entry在散列表中所对应的值。

具体该怎么做呢?下面以链表法为例来讲一下。

第1步,通过哈希函数,把Key转化成数组下标2。

第2步,找到数组下标2所对应的元素,如果这个元素的Key是002936,那么就找到了;如果这个Key不是002936也没关系,由于数组的每个元素都与一个链表对应,我们可以顺着链表慢慢往下找,看看能否找到与Key相匹配的节点。

在上图中,首先查到的节点Entry6的Key是002947,和待查找的Key  002936不符。接着定位到链表下一个节点Entry1,发现Entry1的Key  002936正是我们要寻找的,所以返回Entry1的Value即可。

3.  扩容(resize)

在讲解数组时,曾经介绍过数组的扩容。既然散列表是基于数组实现的,那么散列表也要涉及扩容的问题。

首先,什么时候需要进行扩容呢?

当经过多次元素插入,散列表达到一定饱和度时,Key映射位置发生冲突的概率会逐渐提高。这样一来,大量元素拥挤在相同的数组下标位置,形成很长的链表,对后续插入操作和查询操作的性能都有很大影响。

这时,散列表就需要扩展它的长度,也就是进行扩容  。

对于JDK中的散列表实现类HashMap来说,影响其扩容的因素有两个。

Capacity  ,即HashMap的当前长度

LoadFactor  ,即HashMap的负载因子,默认值为0.75f

衡量HashMap需要进行扩容的条件如下。

HashMap.Size  >=  Capacity×LoadFactor

散列表的扩容操作,具体做了什么事情呢?

扩容不是简单地把散列表的长度扩大,而是经历了下面两个步骤。

1.扩容  ,创建一个新的Entry空数组,长度是原数组的2倍。

2.重新Hash  ,遍历原Entry数组,把所有的Entry重新Hash到新数组中。为什么要重新Hash呢?因为长度扩大以后,Hash的规则也随之改变。

经过扩容,原本拥挤的散列表重新变得稀疏,原有的Entry也重新得到了尽可能均匀的分配。

扩容前的HashMap。

扩容后的HashMap。

以上就是散列表各种基本操作的原理。由于HashMap的实现代码相对比较复杂,这里就不直接列出源码了,有兴趣的读者可以在JDK中直接阅读HashMap类的源码。

需要注意的是,关于HashMap的实现,JDK  8和以前的版本有着很大的不同。当多个Entry被Hash到同一个数组下标位置时,为了提升插入和查找的效率,HashMap会把Entry的链表转化为红黑树这种数据结构。建议读者把两个版本的实现都认真地看一看,这会让你受益匪浅。

基本明白了,散列表还真是个神奇的数据结构!

散列表可以说是数组和链表的结合,它在算法中的应用很普遍,是一种非常重要的数据结构,大家一定要认真掌握哦。

这一次就讲到这里,咱们下一章再见。