Design and implement a data structure for Least Recently Used (LRU) cache. It should support the following operations: get
and set
.
get(key)
- Get the value (will always be positive) of the key if the key exists in the cache, otherwise return -1.
set(key, value)
- Set or insert the value if the key is not already present. When the cache reached its capacity, it should invalidate the least recently used item before inserting a new item.
实现LRU需要使用一个hash map和一个双向链表,map用于O(1)时间内查早指定元素是否在cache里,而双向链表则允许在O(1)时间内将一个指定节点移到表头和删除尾部节点。如果使用数组的话,那么把一个元素移到表头,或者在表头插入新元素,都会用O(N)时间。
我之前还想过用另一种方法,记录每个元素上次被使用(包括get和set)的时间戳,然后根据时间戳排序。不过这样排序的key就成了时间戳,跟hash map中指定的整形的key有冲突,而且插入的时间复杂度不会是O(1)了。
注意在链表里一定要同时存储key和value,不能够只存储value,因为在删除list尾部节点时,需要同时将这个键值对在map中删除,而删除map中的元素是需要对应的key的。
我一开始比较懒,第一次尝试用了现成的LinkedList类,虽然模拟正确,但是超时了。每次刷新链表的时候,我会将链表中的指定元素先删除,然后再加到表头。
private LinkedList<SimpleEntry<Integer, Integer>> entries = new LinkedList<SimpleEntry<Integer, Integer>>();
private Map<Integer, SimpleEntry<Integer, Integer>> map = new HashMap<Integer, SimpleEntry<Integer, Integer>>();
private int capacity = 0;
public LRUCache(int capacity) {
this.capacity = capacity;
}
public int get(int key) {
if (map.containsKey(key)) {
SimpleEntry<Integer, Integer> entry = map.get(key);
entries.remove(entry);
entries.addFirst(entry);
return entry.getValue();
} else {
return -1;
}
}
public void set(int key, int value) {
if (map.containsKey(key)) {
SimpleEntry<Integer, Integer> entry = map.get(key);
entry.setValue(value);
entries.remove(entry);
entries.addFirst(entry);
} else {
if (entries.size() < capacity) {
map.put(key, new SimpleEntry<Integer, Integer>(key, value));
entries.addFirst(map.get(key));
} else {
// remove the last item
int lastKey = entries.getLast().getKey();
map.remove(lastKey);
entries.removeLast();
// add the new item
map.put(key, new SimpleEntry<Integer, Integer>(key, value));
entries.addFirst(map.get(key));
}
}
}
由于Java里的Map.Entry只是一个抽象的接口,不能构造实例,所以我这里用了SimpleEntry类。当然,使用2个整数代替也是可以的,不过这样节点里就不是存的键值对的地址了,更新时需要多次赋值。
超时的原因是因为我用remove方法在删除链表中一个指定元素的时候,这个时候程序会遍历真个链表直到找到该元素。显然,如果容量为N,那么最坏情况是会需要O(N)时间。
这个问题也是这题的难点所在,在将某一个指定元素移到表头时,只允许用常数时间。要做到这点,就必须的自己动手构造双向链表了。因为如果使用现成的LinkedList,每次的实际操作起始地点都必须是链表表头。如果自己构造链表元素,可以手动插入prev和next指针,然后提取出指定元素并插入到表头。代码如下:
class DoublyLinkedListNode {
DoublyLinkedListNode prev;
DoublyLinkedListNode next;
int key;
int val;
DoublyLinkedListNode(int key, int val) {
this.key = key;
this.val = val;
}
}
private Map<Integer, DoublyLinkedListNode> map = new HashMap<Integer, DoublyLinkedListNode>();
private DoublyLinkedListNode head = null;
private int capacity;
// Detach the given node from the list.
private void detach(DoublyLinkedListNode node) {
// A corner case for detach.
if (node == this.head)
this.head = node.next;
node.prev.next = node.next;
node.next.prev = node.prev;
}
// Attach the given node to the beginning of the list.
private void attach(DoublyLinkedListNode node) {
if (this.head != null) {
DoublyLinkedListNode last = this.head.prev;
this.head.prev = node;
node.next = this.head;
last.next = node;
node.prev = last;
} else {
node.next = node;
node.prev = node;
}
this.head = node;
}
public LRUCache(int capacity) {
assert (capacity > 0);
this.capacity = capacity;
}
public int get(int key) {
if (map.containsKey(key)) {
// Refresh the list.
DoublyLinkedListNode node = map.get(key);
detach(node);
attach(node);
return node.val;
} else {
return -1;
}
}
public void set(int key, int value) {
if (!map.containsKey(key)) {
// If the capacity is reached, remove the last node and its
// corresponding key-value pair.
if (map.size() == this.capacity) {
DoublyLinkedListNode last = this.head.prev;
detach(last);
map.remove(last.key);
}
// Add a new node and its corresponding key-value pair.
DoublyLinkedListNode newHead = new DoublyLinkedListNode(key, value);
attach(newHead);
map.put(key, newHead);
} else {
// Update the value.
DoublyLinkedListNode newHead = map.get(key);
newHead.val = value;
// Refresh the list.
detach(newHead);
attach(newHead);
}
}
注意这里链表的刷新和删除某元素,我都是通过attach和detach方法进行,以给出的指定元素为起点来进行操作,只需要常数时间。
另外网上有人指出可以直接扩展Java的LinkedHashMap这个类库,代码可以非常精简。
详细的API在这里可以找到http://docs.oracle.com/javase/7/docs/api/java/util/LinkedHashMap.html
这个类在支持基本的hash map功能的同时,另外将所有的键值对用一个双向链表连接起来,连接的顺序可以是元素访问的顺序(AccessOrder),也可以是元素插入的顺序(InsertionOrder)。根据题意,这里可以设置AccessOrder为true,通过使用现有的removeEldestEntry方法,可以顺利将cache中最不常用的一项移除。
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private int capacity;
private static final long serialVersionUID = 1L;
public LRUCache(int capacity) {
super(capacity, 0.75f, true); // 'true' for accessOrder.
this.capacity = capacity;
}
@Override
public boolean removeEldestEntry(Entry<K, V> entry) {
return (size() > this.capacity);
}
}
使用了一个带参数的构造函数:
public LinkedHashMap (int initialCapacity, float loadFactor, boolean accessOrder)
其中第一个参数表示初始容量,第二个参数表示加载因子,一般是0.75f。这两个参数值不是太重要,设成其它值一般关系也不大。注意这里的容量只是初始容量,跟其它常用容器一样,当需要更多容量的时候会自动扩展。
不过严重怀疑这种方法在面试的时候是否被允许使用。。。
BTW,这个题目让我联想到了老师上课讲的,LRU已经不再是最好的data replacement方案了,而是他研究出的ARU Cache,现在已经融入了最新的Linux内核。需要用hash map和两个栈实现。不知道是否也可以设计个StackHashMap类去搞搞。