定义就不复制粘贴了。这道题目之所以有区分度,是因为考察了面试者对数据结构的应用,而不是什么计算机底层的“缓存”知识。
我们的任务,就是在有限的空间中存储<K, V>形式的数据。如果“进来”的数据太多以至于存不下,那么就删掉最不常被访问的数据。所以,需要记录数据本身,以及被访问的顺序。如果这么简单的话,实现的方法很多。但问题的关键在于:
既然是高速缓存,则需要在固定的空间内,满足增删改查O(1)。
熟悉数据结构的小伙伴都知道,没有一种数据结构满足这样变态的要求。那么,答案是什么呢?
我在快手二面的时候,就被难住了。看到这里,小伙伴不妨自己思考一下,怎么解决这个问题。
好吧,不卖关子了。
既然一种数据结构无法满足,那就两个!
答案就是,HashMap + 双链表。
感谢这张图的作者。如果没有这张图,我恐怕还要半个月才能彻底理解LRU的核心思想:-)
废话不多说,直接上代码。
package interview;
import java.util.HashMap;
public class LRUCache {
/*
* Least Recent Used
* 淘汰掉最不经常使用的
* 使用HashMap + 双链表,实现CRUD时间复杂度为O(1)
*/
/*
* 使用内部类定义一个双链表
* 至于为什么是这四个成员变量呢?
* LRU最大的特点,就是一个双链表的节点,同时也是HashMap的value
* 那么HashMap的key从哪里来呢?
* 没错,HashMap中的key和双链表节点中的key是同一个东西。
* 这样说起来好像很奇怪,很啰嗦,很不优雅。然而最好,甚至必须这样做。解释如下:
* 想象两个情形:
* 1. 只在双链表中存value。那么——
* 我们要弹出双链表最后一个元素,需要在双链表和HashMap中进行两次删除操作。
* 但在HashMap中的删除操作,要借助双链表中的信息。
* 废话,因为只有双链表知道谁是“最不常使用的元素”啊。
* 而如果双链表中没有key,那岂不是没法在HashMap中以O(1)时间找到这个元素。
* 2. 只在双链表中存key。那么——
* 请问你value打算放哪里?你仿佛在搞笑哦。= =
*/
class DLinkedNode {
int key;
int value;
DLinkedNode pre;
DLinkedNode next;
}
/*
* 这里是成员变量,private可以去掉,没什么影响
* 重要的事情再说一遍:
* LRU最大的特点,就是一个双链表的节点,同时也是HashMap的value
* cnt is short for count,就是目前的元素数量,作为判断。
* cap is short for capacity,就是最大容量
* hh和tt,是head和tail的卖萌版。虚拟头结点和尾节点。
* 此时,他们是LRU数据结构的成员变量。
*/
private HashMap<Integer, DLinkedNode> h = new HashMap<>();
private int cnt;
private int cap;
private DLinkedNode hh, tt;
/*
* 初始化,没什么好讲的。
*/
public LRUCache(int cap) {
this.cnt = 0;
this.cap = cap;
hh = new DLinkedNode();
tt = new DLinkedNode();
hh.pre = null;
tt.next = null;
hh.next = tt;
tt.pre = hh;
}
/*
* 在某一元素被访问到的时候,先调用这个toHead()方法.
* 这样,链表中从前到后的顺序,刚好就是被访问到的顺序。
* 很幸运,对链表当中顺序的改动,并不影响HashMap。
* 因为,改变链表的顺序,只需要改变它的前后指向,
* 而不需要变动它自身的内存地址。
* 何况,链表本身在内存中的存储也并不是一块连续的空间。
* 这个方法本身的实现很简单:
* 先让这个节点跟它的前后节点断开,然后头插。
*/
private void toHead(DLinkedNode node) {
node.pre.next = node.next;
node.next.pre = node.pre;
this.push(node);
}
/*
* 头插,不解释~
*/
private void push(DLinkedNode node) {
node.pre = this.hh;
node.next = this.hh.next;
this.hh.next.pre = node;
this.hh.next = node;
}
/*
* 很不幸,优先级最低的节点要说再见了。
* 那篇点击量最高的文章,拜托,你都没有维护链表诶!
* 虽然你那样也可以运行。哈哈~
*/
private void popTail() {
DLinkedNode tmp = tt.pre;
tmp.pre.next = tt;
tt.pre = tmp.pre;
h.remove(tmp.key);
tmp = null;
}
/*
* 通过Key获取元素,同时把这个被访问的元素设为表头的元素。
* 刚刚被访问,优先级自然是最高。
* 如果元素不存在,返回-1。
*/
public int get(int key) {
DLinkedNode tmp = h.get(key);
if (tmp == null) return -1;
this.toHead(tmp);
return tmp.value;
}
/*
* 这里就是最重要的set()方法,即“创建或修改”操作。
* 一般的题目,就是要求set()和get()方法。
* 也只有这个方法中,包含了缓存的容量这一参数。
* 虽然前面看似是铺垫,但个人认为,前面的操作才是核心思想。
* 前面的弄懂了,这里自然懂。
* 实现起来也没难度,只要理顺逻辑就好。
* 千万别把else写到里面那层代码里去。
* 更不要自作聪明,把对cnt的操作放在前面的函数里面,把自己绕进去了。
*/
public void set(int key, int value) {
DLinkedNode tmp = h.get(key);
if (tmp == null) {
tmp = new DLinkedNode();
tmp.key = key;
tmp.value = value;
this.h.put(key, tmp);
this.push(tmp);
cnt ++ ;
if (cnt > cap) {
this.popTail();
cnt -- ;
}
} else {
tmp.value = value;
this.toHead(tmp);
}
}
/*
* 测试一下,输出3 2 -1。成功!
*/
public static void main(String[] args) {
LRUCache lruCache = new LRUCache(3);
lruCache.set(1, 2);
lruCache.set(2, 3);
lruCache.set(3, 4);
System.out.println(lruCache.get(2));
lruCache.set(2, 2);
lruCache.set(4, 5);
System.out.println(lruCache.get(2));
System.out.println(lruCache.get(1));
}
}