如何使用散列表实现一个O(1)时间复杂度的LRU缓存算法

1.散列表

 

    什么是散列表呢?我举这样一个例子,记得小时候家里只有一个座机,但是这个座机不能存电话号码,于是只能将要联系的人的电话号码写在一个本子上。时间久了本子上的电话号码越来越多。然后这个时候要去找某一个指定的联系人的时候发现很难找到。如果是你你想想一下应该怎么样才能快速找到呢?

    其实我们每次新增一个联系人的时候可以将他的姓的首字母取出来,然后所以首字母相同的都在一个区间,也就是做一个目录。

    例如张三,我们就将他放在Z字母的地方,同时如果Z字母的联系人又在520页的话,我们要找张三时直接找到Z,然后知道在520页。这样是不是比一页一页去找要快很多。

    我们可以使用一个数组,首先通过Hash运算,也就是取姓的首字母得到Z,然后可以根据ASCII码计算Z是90,所以存放在下标为90的位置,然后要找张三时只需要通过Hash运算得到Z然后再找到ASCII的90也就是取下标为90的数据即可。

 

2.散列冲突

 

   首先散列表是作用于数组上的,因为数组支持随机访问,所以能够达到O(1)的时间复杂度,而散列表本身就是要达到O(1)的时间复杂度,可是如果散列冲突了怎么办呢?就像我们前面举例那样,比如张三首字母为Z,也就是ASCII的90,我们存放在一个数组下标90中,而郑立也是Z那么这种情况怎么办呢?如果直接存放是不是就把原来的张三给覆盖掉了呢。

 

 

2.1.开放寻址法

 

   开放寻址法的核心思想是,如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。那如何重新探测新的位置呢?当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。如果数组整个都没有空位置,这个时候就需要对数组进行扩容操作。

    而我们要获取数据的时候就需要先Hash运算,然后得到下标后再去拿值,拿到值后要比对是不是要拿的数据,因为有可能Hash冲突了,此时的值并不是你想要的,如果是就直接取出,不是的话就需要重新遍历数组,直到找到对应的数。

从上面可以明显的看出来开发寻址法并不是一种好的方案,当最好的情况时查询数据时间复杂度为O(1),而最坏的情况时就需要遍历整个数组从而退化为O(n),平均时间复杂度为O(1)。

 

2.2.链表法

 

    而链表法就是如果冲突的话直接形成一个链表,相当于挂在了上一个元素上,我们获取数据的时候只需要Hash运算后拿到下标,然后拿到链表比对是否为获取的数据即可,可能眨眼一看好像复杂度和开放寻址法也差不多。其实不然,首先Hash冲突并不是每次都会发生,其次因为会不断的进行动态扩容所以碰撞几率会减少,所以冲突的链表并不会像开放寻址法的数组那样长。

    像JDK1.7的HashMap就是采用的这种方式来解决冲突的,而到了JDK1.8以后则换成了红黑树,原因就是因为红黑树查询的时间复杂度是比链表要快的。所以实际上我们是说的链表法,实际上我们还可以采用红黑树或者把链表改为跳表。

    看到这儿你或许应该明白了为什么Java中的HashMap无论是负载因子还是2的n次方扩容,都是因为减少Hash冲突,而减少Hash冲突的原因就是让时间复杂度降低到O(1),因为一旦Hash冲突时间复杂度可能就不在是O(1)。

 

2.3.负载因子

 

    上面说过如果数据量到达一定量的时候我们是需要进行扩容的,而扩容实际上我们不需要等没有位置的时候才进行,我们只要插入数据达到一定数量时就可以提前扩容。比如我们我们默认数组为16,然后只要达到12时就就行扩容,然后我们可以算出其中比例是0.75,也就是负载因子。

    熟悉HashMap的人应该知道HashMap就是这样操作的,但是这个负载因子实际上是要取一个合适的值的,如果你取1的话实际上很容易发生冲突,相当于满了才进行扩容。而如果取太低的话又会出现空间的浪费,比如取0.5,实际上才一半就扩容了。

 

3.LRU缓存淘汰算法

 

   什么是LRU缓存淘汰算法呢?我举个例子,作为一个Java的开发人员,时常会买一些技术书籍来看,但是家里的书架只能放下10本,那么如果我现在已经有了10本,又重新买了一本,我应该怎么放呢?我这样子操作,我把最近最少使用的书给扔掉,然后把新的书放上去就行了,但是怎么看最近最少使用呢?我们只要每次看过的书都放在最上面,然后最下面的一本就是最近最少看的了。

   我们看一下LeetCode的第146题,对应的就是LRU缓存题目

 

如何使用散列表实现一个O(1)时间复杂度的LRU缓存算法

 

   实际上我们可以有很多种解法来实现LRU缓存,但是题目中要达到时间复杂度为O(1),如果使用链表或者数组都是不能实现的,这个时候就可以使用散列表了,每次get的时候如果存在此数据,那么我们就将它移动到链表的尾部,这样在淘汰时我们只需要删除链表的首地址就行了,而链表的删除操作时间复杂度也是O(1)的,所以采用散列表加链表就可以实现。

 

   下面我写了两个版本,第一个是采用了Java中自带的HashTable来作为散列,然后自定一个链表来实现,而另一个版本就是自定义一个散列表同时自定义一个链表来实现。使用自定义散列表和自定义链表的方案比较复杂实现图如下。

 

如何使用散列表实现一个O(1)时间复杂度的LRU缓存算法

 

   其中prer是指上一个的地址,而next就是下一个的地址,data为存放数据的,可能最难理解的就是hnext,其实hnext是为了解决hash冲突的,一旦冲突了我们就把他挂在与之对应冲突数据的hnext上,这样我们去获取数据时只需要先Hash运算,然后再去看data的值是不是我们要找的值,如果不是就再通过hnext去找即可。

 

   使用HashTable加双向链表实现代码如下

 

如何使用散列表实现一个O(1)时间复杂度的LRU缓存算法

 

 使用自定义HashMap加双向链表实现,前方高能

 

如何使用散列表实现一个O(1)时间复杂度的LRU缓存算法