LRUCache算法实现

1.概念理解

        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的意义所在。


2.原题重现

        看一道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。


2.1.解法1

        根据上面的分析,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()操作可能会删除无用的表项。该方法行不通。


2.2.解法2(推荐)

       上述代码在capacity较小的时候可以使用,那Java中是否还有更高效的数据结构来实现LRUCache呢?答案是,有。

       注意到我上面的代码中,LinkedList是用来模拟key的行为,而key,value对是存储在另一个HashMap中的,如果这两者能合二为一就太好了,而Java中恰好有类似的数据结构,叫做LinkedHashMap。

       我们先来看一下其中的关键方法removeEldestEntry,在中文版的JDK API 1.6.0中的描述

LRUCache算法实现_第1张图片

      仔细阅读上图中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操作中会调用该方法进行检查。

你可能感兴趣的:(Codes)