LRU
小编最近面试碰到让手撕 LRU 算法,面试官注重思路以及纯手写,不能用任何辅助类完成,思考再三,总结以下几点
- 用什么数据结构存储
- 怎么达到增删改时间复杂度 O(1)
- 怎么实现 LRU 特性
1 什么是 LRU
它属于一种内存管理算法,在内存中但又不用的数据块叫 LRU,操作系统会根据哪些数据属于 LRU 而将其移出内存腾出空间来加载另外的数据,按照时间序排序,最近常用数据具备更高的留存,淘汰那些不常被访问的数据,Redis 的内存淘汰算法中也包含到了 LRU 算法,包含三个特性:
- 最近被使用或访问的数据放置最前面
- 每当缓存命中则将数据移到头部
- 当缓存数量达到最大值时,将最近最少访问的数据剔除
2 代码设计
2.1 数据存储
涉及到数据存储,LRU 需要有时序性(方便淘汰最近最少使用),高效查询存储特性。时序性可通过有序的链表 LinkedList 来存储,同时增删只需要更改指针指向即可效率很高,但是查询效率 O(n),高效率查询可以用 hash 结构实现,但是内部元素无序,java 中结合这几个特点有 LinkedHashMap 数据结构可满足这点,但是题目要求需要我们自己手写结构(我也不知道为啥这么苛刻,可能是想看下你的基本链表算法基本功吧~~)。
链表节点用 Node 表示,由于是双向链表,同时需要标注前后驱指针
class Node {
int key;
int val;
Node next,prev;
Node(int k,int v){
this.key = k;
this.val = v;
}
}
}
LRU 结构,这里用Map+Node结构来实现LinkedHashMap
class LRUCache {
private HashMap<Integer,Node> map;
//头尾虚结点
private Node head,tail;
//限制容量
private int limit;
private int size;
public LRUCache(int limit) {
this.map = new HashMap<>();
this.limit = limit;
this.size = 0;
listInit();
}
private void listInit() {
//链表初始化
this.head = new Node(-1,-1);
this.tail = new Node(-1,-1);
this.head.next = this.tail;
this.tail.prev = this.head;
}
}
}
这里可能会想问,为什么要用双向链表作为存储结构,单链表不行吗?这是因为双向链表每个节点都有 prev(前驱) 和 next(后继) 指针分别指向他的前一个和后一个节点,这样在删除节点时候能保证在O(1)情况下完成。
2.2 LRU 细节
编码之前,先想好 get,put 细节功能
-
当使用 get 时候
-
如果节点不存在,返回-1
-
节点存在
- 刚好是头节点,皆大欢喜不需要移动元素,直接返回
- 不是头节点,需要将该元素从链表中删除->置顶->返回结果
-
-
当使用 put 时候
-
如果节点存在
- 刚好是首节点,就更新map和链表,不移动元素
- 不是首节点,就需要将该节点置顶
-
不存在,首先判断容量
- 容量满了,先淘汰尾部元素,头插新元素
- 未满,不淘汰
- size增长
-
public class LRUCache {
public int get(int key){
if(!map.containsKey(key)) return -1;
Node node = map.get(key);
//不需要移动元素
if(node == this.head.next){
return node.val;
}
setRecentlyUse(key);
return node.val;
}
public void put(int key,int val){
if(map.containsKey(key)) {
//不需要移动元素
if(key == this.head.next.key) {
Node node = map.get(key);
node.val = val;
this.head.next.val = val;
return;
}
removeKey(key);
addRecentlyUse(key,val);
return;
}
//容量满了
if(this.limit <= this.size) {
removeRecentlyNotUse();
}
addRecentlyUse(key,val);
}
//把节点从链表中删除,头插到链表
private void setRecentlyUse(int key) {
Node val = map.get(key);
//从链表中删除元素
remove(val);
//头插
addFirst(val);
}
private void addFirst(Node node) {
node.next = this.head.next;
node.prev = head;
this.head.next.prev = node;
this.head.next = node;
size++;
}
private void remove(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
size--;
}
//删除链表节点同时删除map节点
private void removeKey(int key) {
Node node = map.get(key);
remove(node);
map.remove(key);
}
//添加新节点置顶
private void addRecentlyUse(int key, int val) {
Node node = new Node(key,val);
addFirst(node);
map.put(key,node);
}
//淘汰不常用的
private void removeRecentlyNotUse() {
//尾部淘汰
Node last = removeLast();
map.remove(last.key);
}
private Node removeLast() {
Node del = this.tail.prev;
remove(del);
return del;
}
3 LRU不足之处
LRU实现简单,在一般情况下能够表现出很好的命中率,是一个“性价比”很高的算法。但是如果对于一个长时间不使用且还未被淘汰的key,在某个时间突然被访问了一次,后来没有被访问,那么就会在短时间内就变为热点数据,这显然不符合常理,LFU算法靠着数据访问频次解决了这一问题.....