从一道Leetcode题目说起
首先,来看一下Leetcode里面的一道经典题目:146.LRU缓存机制,题目描述如下:
运用你所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制。它应该支持以下操作: 获取数据 get 和 写入数据 put
。获取数据 get(key) - 如果密钥 (key) 存在于缓存中,则获取密钥的值(总是正数),否则返回 -1。 写入数据 put(key,
value) -
如果密钥不存在,则写入其数据值。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。进阶:
你是否可以在 O(1) 时间复杂度内完成这两种操作?
示例:
LRUCache cache = new LRUCache( 2 /* 缓存容量 */ );
cache.put(1, 1);
cache.put(2, 2);
cache.get(1); // 返回 1
cache.put(3, 3); // 该操作会使得密钥 2 作废
cache.get(2); // 返回 -1 (未找到)
cache.put(4, 4); // 该操作会使得密钥 1 作废
cache.get(1); // 返回 -1 (未找到)
cache.get(3); // 返回 3
cache.get(4); // 返回 4
存取key对应的value,很自然地可以想到HashMap。但是,题目的难点很明显:当到达LRUCache的最大capacity之后,需要移除当前保存的key-value中最久没有使用的,而且还有时间复杂度要求。也就是说,需要设计一种数据结构,去维护HashMap里面不同地key按照最近访问时间排列的顺序,当元素个数超过LRUCache的上限时,删除最久没用的元素。
这道题的官方题解提供了两种方法,有序字典和哈希表+双向链表,这里着重说第一种。在Java里面的有序字典就是LinkedHashMap,之所以采用LinkedHashMap呢是因为LinkedHashMap在HashMap基础之上维护了map里面元素的顺序,符合题目的需求,但原生的LinkedHashMap需要对一些方法进行改写才行,如下:
class LRUCache extends LinkedHashMap<Integer, Integer>{
private int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75F, true);
this.capacity = capacity;
}
public int get(int key) {
return super.getOrDefault(key, -1);
}
public void put(int key, int value) {
super.put(key, value);
}
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > capacity;
}
}
代码本身看上去比较简单,但如果对Java的LinkedHashMap类不熟悉,要充分理解代码还是比较有困难的。下面就基于源码对LinkedHashMap的原理做一个剖析。注意,LinkedHashMap是基于HashMap的,如果对HashMap原理完全不了解,建议看看HashMap的源码解析,关于HashMap的文章网上相当多了。
关于LinkedHashMap为什么能够解决这个问题,关键点就是如何维护Map中的元素的顺序,同时,当LRUCache满了时,如何保证删除的元素是最近最久未使用的。我们可以从以下三个操作入手:
1. 当LRUCache未满时的put
2. 当LRUCache满了时的put
3. get
下面对这三个步骤的源码进行剖析。
源码解析
1. 当LRUCache未满时的put
此时,题目中LRUCache的put操作和JDK提供的的LinkedHashMap的put操作完全一致,我们来看看源码。结果发现,源码跳转到的是HashMap的put,而LinkedHashMap并没有重写put方法,但是,我们接着看HashMap的put方法:
熟悉HashMap原理的同学对上面的代码应该不陌生,注意631行,642行的newNode方法,点进去:
再点:
由此可见这里的newNode对象的作用就是确定元素位置后插入元素的最后一步,最终生成Node对象。(当然是在HashMap中的链表没有treeify的时候)看到这里肯定有人有意见了,特么这不是LinkedHashMap原理解析么,怎么又绕到HashMap上了…好吧,就当是回顾HashMap的原理了。注意到一开始调用的newNode()方法,其实在LinkedHashMap中,这个方法被重写了,这也就是LinkedHashMap和HashMap的put方法的一大重要差异,代码如下:
可以看出,LinkedHashMap中存储的元素是封装为LinkedHashMap.Entry这一对象,而不是HashMap的Node。继续点进LinkedHashMap.Entry:
发现LinkedHashMap.Entry是继承了HashMap的Node。在这基础上增加了before和after两个成员。再点进newNode()方法的258行linkNodeLast方法:
原来LinkedHashMap.Entry就是在HashMap的Node的基础之上,增加的before和after两个成员,作用是维护一个双向的链表结构!调用put后,新增加的元素会自动进入链表的尾部。到此可以概括地说:LinkedHashMap就是HashMap+双向链表!而双向链表的存在,目的就是维护Map元素的有序性。
那么问题来了,在LRU缓存机制这道题里,添加元素不是无限的,下面来康康当元素个数达到上限时,删除最久未使用的元素时如何实现的。
2. 当LRUCache已满时的put
继续顺着HashMap中put过程的源码往下看,在put方法返回前会调用这么一个方法:
点进去会发现这个方法和另外两个方法一样,都是专门为LinkedHashMap保留的:
那么来看看LinkedHashMap里面afterNodeInsertion的实现:
我们会发现,该方法会先做一个判断,调用removeEldestEntry(first)方法,判断成功,调用removeNode方法,删除链表中的元素。removeEldestEntry方法在原生LinkedHashMap里面是这么写的:
它总是返回false,所以题目中需要重写这个方法,从而在元素数量到达上限时删除最久未使用的元素!
而下面的removeNode方法执行的是HashMap里面的方法,在方法的末尾,有这么一行:
去LinkedHashMap里面瞅一眼:
很显然,方法的目的就是删除双向链表中的头节点。那么我们的第二个问题就解决了。最后一个问题就是,当我们使用get方法访问LRUCache中的key时,还需将那个key对应的entry放到链表的尾部,那么这就是我们的第三个部分要说的。
3. get
LinkedHashMap里面重写了get方法,如图:
里面的accessOrder是哪儿来的呢?我们可以看到LinkedHashMap的一个构造方法:
从注释上可以看出,accessOrder变量是类实例化的时候赋值的,作用是指定排序的模式,换句话说,就是指访问map中的元素后要不要把访问的元素放到链表的尾部,根据题目要求,显然是要的。所以会执行afterNodeAccess:
仔细看就能明白,在get访问元素之后会做一个将访问的元素放到链表末尾的操作。
至此,就把LinkedHashMap的原理和这道经典的编程题如何借助LinkedHashMap来实现讲完了。我在前面提到,这是官方的题解中两种方法的一种,那么另一种方法,即哈希表 + 双向链表是怎么做的呢,看下面:
import java.util.Hashtable;
public class LRUCache {
class DLinkedNode {
int key;
int value;
DLinkedNode prev;
DLinkedNode next;
}
private void addNode(DLinkedNode node) {
/**
* Always add the new node right after head.
*/
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
private void removeNode(DLinkedNode node){
/**
* Remove an existing node from the linked list.
*/
DLinkedNode prev = node.prev;
DLinkedNode next = node.next;
prev.next = next;
next.prev = prev;
}
private void moveToHead(DLinkedNode node){
/**
* Move certain node in between to the head.
*/
removeNode(node);
addNode(node);
}
private DLinkedNode popTail() {
/**
* Pop the current tail.
*/
DLinkedNode res = tail.prev;
removeNode(res);
return res;
}
private Hashtable<Integer, DLinkedNode> cache =
new Hashtable<Integer, DLinkedNode>();
private int size;
private int capacity;
private DLinkedNode head, tail;
public LRUCache(int capacity) {
this.size = 0;
this.capacity = capacity;
head = new DLinkedNode();
// head.prev = null;
tail = new DLinkedNode();
// tail.next = null;
head.next = tail;
tail.prev = head;
}
public int get(int key) {
DLinkedNode node = cache.get(key);
if (node == null) return -1;
// move the accessed node to the head;
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
DLinkedNode node = cache.get(key);
if(node == null) {
DLinkedNode newNode = new DLinkedNode();
newNode.key = key;
newNode.value = value;
cache.put(key, newNode);
addNode(newNode);
++size;
if(size > capacity) {
// pop the tail
DLinkedNode tail = popTail();
cache.remove(tail.key);
--size;
}
} else {
// update the value.
node.value = value;
moveToHead(node);
}
}
}
代码虽然挺长的,但看了LinkedHashMap源码再来看上面的代码,**这不就是LinkedHashMap的底层实现吗!**所以这里面官方题解的两种解法,其实归根结底,原理都是完全一致的!