练武不练功,到头一场空。从事任何一个行业都需要修炼好基本功,才能成为一个专业的从业者。作为一名软件开发,算法和数据结构就是基本功。
面对这种修炼,我们需要的信念就是:日拱一卒,功不唐捐。
今天要分享的是工作当中最常用的一种数据结构:散列表也称哈希表。
大家都用过字典,我们知道一个汉语一定会对应着一个英文单词,这种对应关系,在程序中有时也是非常需要的。因此,在程序世界里往往也会在内存中存放一个“字典”,方便我们存储数据。
比如说一个学生管理系统,我们通过一个学生的学号就可以得到这个学生的姓名班级成绩等等一系列信息。
因此,我们不用每次都去数据库查学号以外的信息,直接在内存里建立一个内存表,就可以实现查询,并且大大地提升了查询效率。
哈希表就是用来存储这样的数据的,它的结构是由键(key)和值(value)组成的,并且每个Key和value之间都有一种映射关系,只要提供key,就可以得到value的值,像查字典一样方便,这样查询的时间复杂度接近于O(1)。
那么问题来了,哈希表为何可以实现如此高效率的查询呢?不知道你是否还记得我们之前分享过的数组查询,它通过下标就可以实现快速查找的效果。
实际上,散列表本质上也是一个数组,只不过每个元素是由Key和value组成的。那它的下标是如何计算的呢?
这就要引入另外一个概念,哈希函数。
我们通过哈希函数把key和下标进行转换,也就是说通过哈希函数把key计算一下,就得到了这个元素的下标,然后通过下标快速定位到这个元素。
不同语言中的哈希函数实现方式是不一样的,接下来我们以Java举例,在Java中,每一个对象都有属于自己的hashcode, hashcode是区分不同对象的重要标识。无论对象自身的类型是什么,它们的hashcode都是一个整数类型。
因此我们可以把这个Hashcode转换为我们需要的下标,即:
index = Hashcode (key) % Array.length.
实际上,JDK中的哈希函数并没有直接采用取模运算,而是利用了位运算的方式来优化性能。通过哈希函数,我们可以把字符串或其他类型的key,转化为数组的下标Index。
那么哈希表的读写操作又是怎样的呢?
写操作:就是在散列表中插入新的键值对(在JDK中叫做Entry)。比如 hashMap.put("001","Joe");具体的实现方式,就是对“001”,通过哈希函数,转换为一个数组下标,比如说得到一个5;
然后,如果下标5对应的位置没有元素,那么就把这个entry填充到数组下标为5的位置。
但由于数组的长度是有限的,随着entry的逐渐增多,会出现不同的key计算出来的哈希函数得到的值是相同的。
比如hashMap.put("002","Mary"),假如002经过哈希函数计算之后,也得到了一个下标是5,这个时候就出现了哈希冲突现象。
该如何解决哈希冲突现象呢?
一般有两种方法,一是开放寻址法,即发现5这个下标被别人占了,那么再去寻找其它没有被占的位置,在Java中,ThreadLocal所使用的就是开放寻址法。
另外一个方法就是链表法,这种方法被应用到了Java集合类HashMap当中。
HashMap数组的每一个元素不仅是一个Entry对象,同时也是一个链表的头节点。每一个Entry对象,通过next指针指向它的下一个Entry节点。
当新来的Entry映射到与之冲突的数组位置时,只需要插入到对应的链表即可。这就是HashMap在处理哈希冲突的应对方式。
读操作:即通过哈希函数,把key转换为数组下标,然后根据数组下标去找对应的元素,如果发现第一个元素并不是对应与之匹配的key,那么可以顺着链表继续往下找,直到找到Key和实际Key相匹配的那个元素再返回。
另外一个不得不提的点就是哈希表的扩容,因为哈希表是基于数组实现的,所以当数组的长度不够用的时候,哈希表就需要进行扩容。
一般扩容需要两个步骤,首先先要知道影响扩容的两个因素。
一是Capacity,即HashMap的当前长度,另一个是LoadFactor,即HashMap的负载因子,默认值为0.75f。
衡量HashMap需要进行扩容的条件是:
HashMap.size >=Capacity X LoadFactor.
具体扩容的两个步骤为,一是创建一个新的Entry空数组,长度是原来数组的2倍;二是重新hash,即遍历原先Entry数组,把所有的Entry重新Hash到新数组中。
简单总结一下就是:哈希表可以说是数组和链表的结合,它在算法中的应用非常普遍,是一种非常重要的数据结构,所以值得我们认真学习和研究。
关于其它更多的数据结构,我们以后再说~
Time!