LRU 缓存算法
什么是缓存
这里说的缓存是一种广义的概念,在计算机存储层次结构中,低一层的存储器都可以看做是高一层的缓存。比如Cache是内存的缓存,内存是硬盘的缓存,硬盘是网络的缓存等等。
缓存可以有效地解决存储器性能与容量的这对矛盾,但绝非看上去那么简单。如果缓存算法设计不当,非但不能提高访问速度,反而会使系统变得更慢。
从本质上来说,缓存之所以有效是因为程序和数据的局部性(locality)。程序会按固定的顺序执行,数据会存放在连续的内存空间并反复读写。这些特点使得我们可以缓存那些经常用到的数据,从而提高读写速度。
缓存的大小是固定的,它应该只保存最常被访问的那些数据。然而未来不可预知,我们只能从过去的访问序列做预测,于是就有了各种各样的缓存替换策略。本文介绍一种简单的缓存策略,称为最近最少使用(LRU,Least Recently Used)算法。
LRU
LRU(Least Recently Used)是一种常见的页面置换算法,在计算中,所有的文件操作都要放在内存中进行,然而计算机内存大小是固定的,所以我们不可能把所有的文件都加载到内存,因此我们需要制定一种策略对加入到内存中的文件进项选择。
常见的页面置换算法有如下几种:
- LRU 最近最久未使用
- FIFO 先进先出置换算法 类似队列
- OPT 最佳置换算法 (理想中存在的)
- NRU Clock置换算法
- LFU 最少使用置换算法
- PBA 页面缓冲算法
LRU原理
一个简单的例子
LRU的设计原理就是,当数据在最近一段时间经常被访问,那么它在以后也会经常被访问。这就意味着,如果经常访问的数据,我们需要然其能够快速命中,而不常访问的数据,我们在容量超出限制内,要将其淘汰。
当我们的数据按照如下顺序进行访问时,LRU
的工作原理如下:
每次访问的数据都会放在栈顶,当访问的数据不在内存中,且栈内数据存储满了,我们就要选择移除栈底的元素,因为在栈底部的数据访问的频率是比较低的。所以要将其淘汰。
题目描述
设计并实现最近最少使用(LRU)缓存的数据结构。它应该支持以下操作:get 和 put。
get(key) - 如果键存在于缓存中,则获取键的值(总是正数),否则返回 -1。
put(key, value) - 如果键不存在,请设置或插入值。当缓存达到其容量时,它应该在插入新项目之前,
LRUCache cache = new LRUCache( 2 /* capacity (缓存容量) */ );
cache.put(1, 1);
cache.put(2, 2);
cache.get(1); // 返回 1
cache.put(3, 3); // 去除 key 2
cache.get(2); // 返回 -1 (未找到key 2)
cache.get(3); // 返回 3
cache.put(4, 4); // 去除 key 1
cache.get(1); // 返回 -1 (未找到 key 1)
cache.get(3); // 返回 3
cache.get(4); // 返回 4
单链表来解决
当我们进行 put 操作的时候,会出现以下几种情况:
1、如果要 put(key,value) 已经存在于链表之中了(根据key来判断),那么我们需要把链表中旧的数据删除,然后把新的数据插入到链表的头部。、
2、如果要 put(key,value) 的数据没有存在于链表之后,我们我们需要判断下缓存区是否已满,如果满的话,则把链表尾部的节点删除,之后把新的数据插入到链表头部。如果没有满的话,直接把数据插入链表头部即可。
对于 get 操作,则会出现以下情况
1、如果要 get(key) 的数据存在于链表中,则把 value 返回,并且把该节点删除,删除之后把它插入到链表的头部。
2、如果要 get(key) 的数据不存在于链表之后,则直接返回 -1 即可。
时间、空间复杂度分析
对于这种方法,put 和 get 都需要遍历链表查找数据是否存在,所以时间复杂度为 O(n)。空间复杂度为 O(1)。
哈希表
我们可以用一个额外哈希表(例如HashMap)来存放 key-value,这样的话,我们的 get 操作就可以在 O(1) 的时间内寻找到目标节点,并且把 value 返回了。
然而,大家想一下,用了哈希表之后,get 操作真的能够在 O(1) 时间内完成吗?
用了哈希表之后,虽然我们能够在 O(1) 时间内找到目标元素,可是,我们还需要删除该元素,并且把该元素插入到链表头部啊,删除一个元素,我们是需要定位到这个元素的前驱的,然而定位到这个元素的前驱,是需要 O(n) 时间复杂度的。
最后的结果是,用了哈希表时候,最坏时间复杂度还是 O(1),而空间复杂度也变为了 O(n)。
双向链表+哈希表
这可以通过HashMap+双向链表实现。HashMap保证通过key访问数据的时间为O(1),双向链表则按照访问时间的顺序依次穿过每个数据。之所以选择双向链表而不是单链表,是为了可以从中间任意结点修改链表结构,而不必从头结点开始遍历。
代码实现
大致思路:
1 构建双向链表节点ListNode,应包含key,value,prev,next这几个基本属性
2 对于Cache对象来说,我们需要规定缓存的容量,所以在初始化时,设置容量大小,然后实例化双向链表的head,tail,并让head.next->tail tail.prev->head,这样我们的双向链表构建完成
3 对于get操作,我们首先查阅hashmap,如果存在的话,直接将Node从当前位置移除,然后插入到链表的首部,在链表中实现删除直接让node的前驱节点指向后继节点,很方便.如果不存在,那么直接返回Null
4 对于put操作,比较麻烦。
采用这两种数据结构的组合,我们的 get 操作就可以在 O(1) 时间复杂度内完成了。由于 put 操作我们要删除的节点一般是尾部节点,所以我们可以用一个变量 tai 时刻记录尾部节点的位置,这样的话,我们的 put 操作也可以在 O(1) 时间内完成了。
双链表 + 哈希表,采用这两种数据结构的组合,我们的 get 操作就可以在 O(1) 时间复杂度内完成了。由于 put 操作我们要删除的节点一般是尾部节点,所以我们可以用一个变量 tai 时刻记录尾部节点的位置,这样的话,我们的 put 操作也可以在 O(1) 时间内完成了。
// 链表节点的定义
class LRUNode{
String key;
Object value;
LRUNode next;
LRUNode pre;
public LRUNode(String key, Object value) {
this.key = key;
this.value = value;
}
}
// LRU
public class LRUCache {
Map<String, LRUNode> map = new HashMap<>();
RLUNode head;
RLUNode tail;
// 缓存最大容量,我们假设最大容量大于 1,
// 当然,小于等于1的话需要多加一些判断另行处理
int capacity;
public RLUCache(int capacity) {
this.capacity = capacity;
}
public void put(String key, Object value) {
if (head == null) {
head = new LRUNode(key, value);
tail = head;
map.put(key, head);
}
LRUNode node = map.get(key);
if (node != null) {
// 更新值
node.value = value;
// 把他从链表删除并且插入到头结点
removeAndInsert(node);
} else {
LRUNode tmp = new LRUNode(key, value);
// 如果会溢出
if (map.size() >= capacity) {
// 先把它从哈希表中删除
map.remove(tail);
// 删除尾部节点
tail = tail.pre;
tail.next = null;
}
map.put(key, tmp);
// 插入
tmp.next = head;
head.pre = tmp;
head = tmp;
}
}
public Object get(String key) {
LRUNode node = map.get(key);
if (node != null) {
// 把这个节点删除并插入到头结点
removeAndInsert(node);
return node.value;
}
return null;
}
private void removeAndInsert(LRUNode node) {
// 特殊情况先判断,例如该节点是头结点或是尾部节点
if (node == head) {
return;
} else if (node == tail) {
tail = node.pre;
tail.next = null;
} else {
node.pre.next = node.next;
node.next.pre = node.pre;
}
// 插入到头结点
node.next = head;
node.pre = null;
head.pre = node;
head = node;
}
}
其它的代码实现
/**
* @author wjg
*
* LRU(Least Recently Used)缓存算法
* 使用HashMap+双向链表,使get和put的时间复杂度达到O(1)。
* 读缓存时从HashMap中查找key,更新缓存时同时更新HashMap和双向链表,双向链表始终按照访问顺序排列。
*
*/
public class LRUCache {
/**
* @param args
* 测试程序,访问顺序为[[1,1],[2,2],[1],[3,3],[2],[4,4],[1],[3],[4]],其中成对的数调用put,单个数调用get。
* get的结果为[1],[-1],[-1],[3],[4],-1表示缓存未命中,其它数字表示命中。
*/
public static void main(String[] args) {
LRUCache cache = new LRUCache(2);
cache.put(1, 1);
cache.put(2, 2);
System.out.println(cache.get(1));
cache.put(3, 3);
System.out.println(cache.get(2));
cache.put(4, 4);
System.out.println(cache.get(1));
System.out.println(cache.get(3));
System.out.println(cache.get(4));
}
// 缓存容量
private final int capacity;
// 用于加速缓存项随机访问性能的HashMap
private HashMap<Integer, Entry> map;
// 双向链表头结点,该侧的缓存项访问时间较早
private Entry head;
// 双向链表尾结点,该侧的缓存项访问时间较新
private Entry tail;
public LRUCache(int capacity) {
this.capacity = capacity;
map = new HashMap<Integer, Entry>((int)(capacity / 0.75 + 1), 0.75f);
head = new Entry(0, 0);
tail = new Entry(0, 0);
head.next = tail;
tail.prev = head;
}
/**
* 从缓存中获取key对应的值,若未命中则返回-1
* @param key 键
* @return key对应的值,若未命中则返回-1
*/
public int get(int key) {
if (map.containsKey(key)) {
Entry entry = map.get(key);
popToTail(entry);
return entry.value;
}
return -1;
}
/**
* 向缓存中插入或更新值
* @param key 待更新的键
* @param value 待更新的值
*/
public void put(int key, int value) {
if (map.containsKey(key)) {
Entry entry = map.get(key);
entry.value = value;
popToTail(entry);
}
else {
Entry newEntry = new Entry(key, value);
if (map.size() >= capacity) {
Entry first = removeFirst();
map.remove(first.key);
}
addToTail(newEntry);
map.put(key, newEntry);
}
}
/**
* 缓存项的包装类,包含键、值、前驱结点、后继结点
* @author wjg
*
*/
class Entry {
int key;
int value;
Entry prev;
Entry next;
Entry(int key, int value) {
this.key = key;
this.value = value;
}
}
// 将entry结点移动到链表末端
private void popToTail(Entry entry) {
Entry prev = entry.prev;
Entry next = entry.next;
prev.next = next;
next.prev = prev;
Entry last = tail.prev;
last.next = entry;
tail.prev = entry;
entry.prev = last;
entry.next = tail;
}
// 移除链表首端的结点
private Entry removeFirst() {
Entry first = head.next;
Entry second = first.next;
head.next = second;
second.prev = head;
return first;
}
// 添加entry结点到链表末端
private void addToTail(Entry entry) {
Entry last = tail.prev;
last.next = entry;
tail.prev = entry;
entry.prev = last;
entry.next = tail;
}
}
值得一提的是,Java API中其实已经有数据类型提供了我们需要的功能,就是LinkedHashMap这个类。该类内部也是采用HashMap+双向链表实现的。使用这个类实现LRU就简练多了。
/**
*
* 一个更简单实用的LRUCache方案,使用LinkedHashMap即可实现。
* LinkedHashMap提供了按照访问顺序排序的方案,内部也是使用HashMap+双向链表。
* 只需要重写removeEldestEntry方法,当该方法返回true时,LinkedHashMap会删除最旧的结点。
*
* @author wjg
*
*/
public class LRUCacheSimple {
/**
* @param args
*/
public static void main(String[] args) {
LRUCacheSimple cache = new LRUCacheSimple(2);
cache.put(1, 1);
cache.put(2, 2);
System.out.println(cache.get(1));
cache.put(3, 3);
System.out.println(cache.get(2));
cache.put(4, 4);
System.out.println(cache.get(1));
System.out.println(cache.get(3));
System.out.println(cache.get(4));
}
private LinkedHashMap<Integer, Integer> map;
private final int capacity;
public LRUCacheSimple(int capacity) {
this.capacity = capacity;
map = new LinkedHashMap<Integer, Integer>(capacity, 0.75f, true){
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > capacity;
}
};
}
public int get(int key) {
return map.getOrDefault(key, -1);
}
public void put(int key, int value) {
map.put(key, value);
}
}
只需要覆写LinkedHashMap的removeEldestEntry
方法,在缓存已满的情况下返回true
,内部就会自动删除最老的元素。