LRU是Least Recently Used(最近最少使用)的首字母组合,最早接触是在操作系统课程中,关于Cache的hit和miss的一种算法,可以极大地提高访问命中率。
网上有许多关于LRU算法解释的例子,这里简单说一下:
1、该算法的运行过程就像一个特殊的栈;
2、“栈”容量大小cacheSize固定;
3、当要访问的表项不在“栈”中时,是一个cache miss的过程:
如果"栈"还有剩余空间(即使用量小于cacheSize),则将该表项置于栈顶;
如果"栈"已被填满(即使用量等于cacheSize),这时必须先将栈底(即最早被使用过,但是最近最少使用)的表项弹出,然后将待访问的表项压入"栈"中;
4、当要访问的页面在“栈”中时,是一个cache hit的过程,就能直接获得该页面的地址,并且将该地址置于栈顶(每个表项在cache中只出现一次,可以理解为将“栈”中原表项先删除,再将其压入栈顶),表示最新访问过的表项。
算法理解,还是来看个例子:栈容量为3,页面访问序列为3、0、2、0、1、3,“栈”的表现如下:
3(访问3,miss,压入栈顶)
3 0(访问0,miss,压入栈顶)
3 0 2(访问2,miss,压入栈顶)
3 2 0(访问0,hit,将该表项0置于栈顶)
2 0 1(访问1,miss,栈满,弹出栈底表项3,新表项1压入栈顶)
0 1 3(访问3,miss,栈满,弹出栈底表项2,新表项3压入栈顶)
容易观察到,当要访问的不同页面数量(这里是4,即页面0,1,2,3)大于栈容量时,就会出现栈满需要进行表项替换的过程,这也是使用cache的意义所在。
看一道LeetCode上的原题(题目网址https://oj.leetcode.com/problems/lru-cache/)。题目的意思就是要实现LRUCache的数据结构,主要是get和set两个操作:
get(key): 如果键key存在,则返回键key对应的值value(始终为正数),否则返回-1(表示不存在该表项)
set(key, value):如果键key不存在,则将key, value插入该Cache。如果Cache的使用量已经达到Cache限定容量了,则需要先弹出最近最少使用的key, value。
根据上面的分析,LRUCache确实是一个特殊的“栈”,但是需要有弹出栈底(miss而且栈满的情况)的操作,和将栈中某个表项置于栈顶(hit但表项不在栈顶)的操作,因此普通的栈不适合实现此算法。在Java中很自然想到和List相关的类,LinkedList和ArrayList。LinkedList是用双向链表实现的,相对于ArrayList来说添加和删除元素的效率要高,ArrayList是用数组实现的,适合于随机访问,但是添加和删除元素的代价太高(可能要频繁而且大量地移动数组元素);在这里需要频繁地插入和删除,因此选用LinkedList更加合适一些,代码如下:
public class LRUCache {
private final int capacity;
private final Map keyValue;
private final LinkedList cache;
public LRUCache(int capacity) {
this.capacity = capacity;
this.keyValue = new HashMap(capacity);
this.cache = new LinkedList();
}
public int get(int key) {
return this.keyValue.containsKey(key) ? this.keyValue.get(key) : -1; // cache hit or miss
}
public void set(int key, int value) {
if(this.keyValue.containsKey(key)) { // Cache hit
if(this.cache.peekFirst() == key) { // and is the head of the list, do nothing
return;
}
this.cache.removeFirstOccurrence(key); // but isn't the head of the list, so remove the first occurrence in the list
} else { // Cache miss
this.keyValue.put(key, value); // put the key,value pair first
if(this.cache.size() == this.capacity) { // if reached the capacity of list
this.keyValue.remove(this.cache.removeLast()); // remove the least recently used one
}
}
this.cache.addFirst(key); // put the key in the head of the list anyway.
}
}
这个实现方法被LeetCode的OJ判为TLE(Time Limit Exceeded),分析原因在于使用的LinkedList是一个双向链表,当要删除链表中的一个指定元素时(LinkedList的removeFirstOccurrence方法),其时间复杂度是O(n),显然容易超时。
找到了超时原因,首先想到的是,如果不执行这个操作的话,会有什么结果。显然这是不能解决问题的,因为如果在Cache hit时,如果不删除列表中hit的原表项的话,那么在Cache miss的时候this.cache.removeLast()操作可能会删除无用的表项。该方法行不通。
上述代码在capacity较小的时候可以使用,那Java中是否还有更高效的数据结构来实现LRUCache呢?答案是,有。
注意到我上面的代码中,LinkedList是用来模拟key的行为,而key,value对是存储在另一个HashMap中的,如果这两者能合二为一就太好了,而Java中恰好有类似的数据结构,叫做LinkedHashMap。
我们先来看一下其中的关键方法removeEldestEntry,在中文版的JDK API 1.6.0中的描述
仔细阅读上图中LinkedHashMap中的removeEldestEntry方法描述,我们可以知道,如果要实现LRUCache,需要override该方法,并且需要在读取模式下(如果是插入模式则按照插入顺序删除表项),每次执行put或putAll操作时将会触发该检查,如果返回值为true则需要删除最近最少读取的那个key,value对,这正是我们想要的结果。
好了,我们需要的是LinkedHashMap中可以指定accessOrder的构造方法,触发删除条件和上图示例类似,代码如下:
import java.util.LinkedHashMap;
import java.util.Map;
public class LRUCache {
private static final float mapLoadFactor = 0.75f; // default load factor
private LinkedHashMap map;
private int capacity;
public LRUCache(int capacity) {
this.capacity = capacity;
int mapInitialCapacity = (int)Math.ceil(capacity / mapLoadFactor) + 1; // initial map capacity
this.map = new LinkedHashMap(mapInitialCapacity, mapLoadFactor, true) { // true for access-ordered
private static final long serialVersionUID = 1L;
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return this.size() > LRUCache.this.capacity;
}
};
}
public int get(int key) {
return this.map.containsKey(key) ? this.map.get(key) : -1;
}
public void set(int key, int value) {
this.map.put(key, value);
}
}
mapInitialCapacity中加一操作是防止LinkedHashMap进行扩容(初始化时即固定好最大的大小,避免了后期因为map中数据量增加导致的扩容,降低操作效率),因为在任意时刻map中的键值对个数最多是capacity+1。
构造函数中accessOrder设置为true,表示按照读取顺序进行删除,而不是默认的按照插入顺序进行删除。
覆写removeEldestEntry方法,当map中的键值对个数比cache的capacity多的时候即触发删除最近最少使用键值对,即在set操作中会调用该方法进行检查。