之前在写链表那一篇文章的时候写过基于链表来实现LRU缓存淘汰算法,
这是链接https://mp.csdn.net/mp_blog/creation/editor/120435048
下面来看一看linkedHashMap中的哈希表和链表是如何组合实现LRU缓存淘汰算法的。
LRU缓存淘汰算法先来回顾一下基于链表实现的
我们维护一个有序的链表,越靠近链表尾部的结点存储越早访问的数据(头结点存储最新访问的数据,尾结点存储最早访问的数据),并且记录头指针和尾指针,分别指向链表的头结点和尾结点。因为缓存大小有限,当缓存空间不够,需要淘汰数据的时候,我们就直接将链表尾部的结点删除。
当要缓存某个数据的时候,现在链表中查找这个元素,如果没找到,则直接将数据放到链表的头部。如果找到了,就直接放到链表的头部。此时因为要遍历查找数据,时间复杂度就会为O(n)。
概况的说,一个缓存系统主要包含下面几个操作
在缓存中添加一个数据
在缓存中删除一个数据
在缓存中查找一个数据
这三个操作都涉及在链表中查找数据,但是如果适用链表的话,时间复杂度就会为O(n),我们可不可以这样,在原有链表的基础上假设一个哈希表作为索引,哈希表中的每个结点额外存储一个指向有序链表的指针。通过哈希表就能快速查找到需要删除的有序链表的结点。如图。
下面通过代码来看看如何在缓存中查找,删除,添加一个数据。
package com.example.demo;
import java.util.HashMap;
import java.util.Map;
public class LRUCache {
private class DlinkedNode{
public int key;
public int value;
public DlinkedNode pre;
public DlinkedNode next;
public DlinkedNode(int key,int value){
this.key = key;
this.value = value;
}
}
private Map cache = new HashMap<>();
private int size;
private int capacity;
private DlinkedNode head;
private DlinkedNode tail;
public LRUCache(int capacity){
this.size = 0;
this.capacity = capacity;
this.head = new DlinkedNode(-1,-1);
this.tail = new DlinkedNode(-1,-1);
this.head.next = tail;
this.tail.pre = head;
this.head.pre = null;
this.tail.next = null;
}
private void removeNode(DlinkedNode node){
node.next.pre = node.pre;
node.pre.next = node.next;
}
private void addNodeAtHead(DlinkedNode node){
node.next = head.next;
tail.pre = node;
head.next = node;
node.pre = head;
}
//查找数据,并移到表头
public int get(int key){
if(size == 0){
return -1;//缓存中没有数据
}
//要查找结点
DlinkedNode dlinkedNode = cache.get(key);
if(dlinkedNode == null){
return -1;
}
removeNode(dlinkedNode);
addNodeAtHead(dlinkedNode);
return dlinkedNode.value;
}
//删除
public void remove(int key){
DlinkedNode dlinkedNode = cache.get(key);
if(dlinkedNode != null){
removeNode(dlinkedNode);
cache.remove(key);
return;
}
}
//插入
public void put(int key,int value){
DlinkedNode dlinkedNode = cache.get(key);
//如果数据已经存在,直接移到到表头
if(dlinkedNode != null){
dlinkedNode.value = value;
removeNode(dlinkedNode);
addNodeAtHead(dlinkedNode);
return;
}
//大小等于哈希表容量时,删除尾结点
if(size == capacity){
cache.remove(tail.pre.key);
removeNode(tail.pre);
size--;
}
DlinkedNode dlinkedNode1 = cache.get(key);
addNodeAtHead(dlinkedNode1);
cache.put(key,dlinkedNode1);
size++;
}
}
Java linkedHashMap
前面我们讲了两个散列表和链表结合的例子,现在我们再来看另外一个,Java 中linkedHashMap 这种容器。
我们之前讲过,HashMap 底层是通过散列表这种数据结构实现的。而 linkedHashMap 前面比 HashMap 多了一 个“linked”,这里的“linked”是不是说,linkedHashMap 是一个通过链表法解决散列冲突的散列表呢?
实际上,linkedHashMap 并没有这么简单,其中的“linked”也并不仅仅代表它是通过链表法解决散列冲突的。
我们先来看一段代码。你觉得这段代码会以什么样的顺序打印 3,1,5,2 这几个 key
呢?原因又是什么呢?
1 HashMap我先告诉你答案,上面的代码会按照数据插入的顺序依次来打印,也就是说,打印的顺序就 是 3,1,5,2。你有没有觉得奇怪?散列表中数据是经过散列函数打乱之后无规律存储 的,这里是如何实现按照数据的插入顺序来遍历打印的呢? 你可能已经猜到了,linkedHashMap 也是通过散列表和链表组合在一起实现的。实际 上,它不仅支持按照插入顺序遍历数据,还支持按照访问顺序来遍历数据。你可以看下面这 段代码: m = new linkedHashMap<>(); 2 m.put(3, 11); 3 m.put(1, 12); 4 m.put(5, 23); 5 m.put(2, 22); 6 7 for (Map.Entry e : m.entrySet()) { 8 System.out.println(e.getKey()); 9 }
// 10 是初始大小,0.75 是装载因子,true 是表示按照访问时间排序 2 HashMap这段代码打印的结果是 1,2,3,5。我来具体分析一下,为什么这段代码会按照这样顺序 来打印。 每次调用 put() 函数,往 linkedHashMap 中添加数据的时候,都会将数据添加到链表的 尾部,所以,在前四个操作完成之后,链表中的数据是下面这样:m = new linkedHashMap<>(10, 0.75f, true); 3 m.put(3, 11); 4 m.put(1, 12); 5 m.put(5, 23); 6 m.put(2, 22); 7 8 m.put(3, 26); 9 m.get(5); 10 11 for (Map.Entry e : m.entrySet()) { 12 System.out.println(e.getKey()); 13 }
在第 8 行代码中,再次将键值为 3 的数据放入到 linkedHashMap 的时候,会先查找这个 键值是否已经有了,然后,再将已经存在的 (3,11) 删除,并且将新的 (3,26) 放到链表的尾 部。所以,这个时候链表中的数据就是下面这样:
当第 9 行代码访问到 key 为 5 的数据的时候,我们将被访问到的数据移动到链表的尾部。 所以,第 9 行代码之后,链表中的数据是下面这样:
所以,最后打印出来的数据是 1,2,3,5。从上面的分析,你有没有发现,按照访问时间 排序的 linkedHashMap 本身就是一个支持 LRU 缓存淘汰策略的缓存系统?实际上,它们 两个的实现原理也是一模一样的。我也就不再啰嗦了。 我现在来总结一下,实际上, linkedHashMap 是通过双向链表和散列表这两种数据结构 组合实现的。linkedHashMap 中的“linked”实际上是指的是双向链表,并非指用链表 法解决散列冲突 。 部分引用《数据结构与算法之美》



