LRU 缓存淘汰算法
介绍
最近,我个人在业务上接触到LRU缓存的场景非常多,比如本人是负责维护公司的音视频播放器中间件的,音视频的业务场景就涉及到LRU缓存,播放器需要对视频流进行缓存,一方面便于下次加载视频更快,另一方面可以一定程度上的节省用户流量,缓存到达一定容量,就要把最久没用过的缓存删除掉。再比如,迭代开发过程中用到的Glide框架,里面的图片缓存等,也是用到了LRU缓存机制。
LRU,Least Recently Used,就是一种缓存淘汰策略。
我们都知道,计算机的存储容量是有限的,所以缓存也是要限制的,既然有限制,那么如果缓存满了,就得删除一些东西。
问题来了,删除哪些东西呢?
我们肯定希望删掉哪些无用的缓存,把有用的数据继续留在缓存里,方便之后继续使用嘛。
也就是说我们认为最近使用过的数据应该是有用的,很久都没用过的数据应该是无用的,内存满了就优先删那些很久没用过的数据。
举个简单的例子,假设我有一个车库,车库最多只能停三辆车。
假设我有三辆车,分别是【兰博基尼】、【法拉利】、【保时捷】,它们的顺序代表我最近对他们的喜爱程度,现在我最喜欢的是【兰博基尼】。然后哪辆车最近开过就说明我最近最喜欢哪辆车。
第一天,我开了法拉利,所以我就最喜欢法拉利这辆车了,它们的排序变为
【法拉利】、【兰博基尼】、【保时捷】
第二天,我开了保时捷,所以它们的排序又变为
【保时捷】、【法拉利】、【兰博基尼】
第三天,我买了新车子【布加迪】,所以布加迪又变成了我的最爱。但是,我的车库最多只能放三辆车,所以为了车库能刚好放下我的三辆车,我只能把我最不喜欢的那辆车扔了,很明显,就是【兰博基尼】嘛。
所以,这时候,我的车的排序就变成了
【布加迪】、【保时捷】、【法拉利】
分析
首先我们确定的是,我们实现的LRU缓存,它应该支持以下操作,获取数据的操作get和写入数据的操作put。
读取数据
就是根据取缓存嘛,取缓存一般都需要通过一个key来取,不然哪知道想要取那个缓存呢。
对于get方法,我们可以分为以下几个步骤:
1、根据key,拿到对应的缓存数据
2、如果获取的缓存数据为空,直接返回-1即可
3、如果数据不为null,就将数据移动到最前面
4、返回节点的value值
注意,这几个步骤我我们都要在O(1)的时间复杂度完成哦,其中【将数据移动到最前面】这个操作,其实内部可以分为两个子操作,需要先删除这个数据,然后再把数据添加到最前面,怎么做到复杂度也是O(1)呢?这就需要用到双向链表的特性了。后面我们再来详细分析。
写入数据
put方法就相对比较复杂一点点了,我们需要传一个key和value,分别对应缓存的key,和缓存的值。
我们慢慢分析下。
假如当前缓存的形式为:【0,Node_A】【1,Node_B】,最大容量是3,
调用put(2,Node_C)后,
1、因为此时缓存中没有2这个key对应的缓存,所以就得构建一个缓存,即【2,Node_C】
2、将该缓存移动到最前面
3、此时容量没有超过限制,所以此时缓存形式为:【2,Node_C】【0,Node_A】【1,Node_B】
调用put(3,Node_D)后,
1、因为缓存中没有3这个key对应的缓存,所以就得构建一个缓存,即【3,Node_D】
2、将该缓存移动到最前面
3、此时容量超过限制了,所以直接将最末尾的缓存移除,即移除【1,Node_B】
4、此时,缓存的形式变为:【3,Node_D】【2,Node_C】【0,Node_A】
再调用put(2,Node_E)后,
1、因为此时缓存中有2这个key对应的缓存,所以直接把这个缓存的value值覆盖即可,即【2,Node_E】
2、将该缓存移动到最前面
3、缓存的形式将变为:【2,Node_E】【3,Node_D】【0,Node_A】
以O(1)的时间复杂度来实现这两种操作
那么,如何以O(1)的时间复杂度来实现这两种操作呢?
在java中,其实有封装好的数据结构可以使用,比如:LinkedHashMap。直接使用LinkedHashMap来实现LRU缓存,其实非常简单。但是这并不是我们的初衷,为了更深入的理解LRU的原理,我们还是要自己来造个轮子。
首先要明确的一点,我们的缓存是一个链式结构,因为我们要判断靠近头部的数据是最近使用的数据,靠近尾部的是最久未使用的数据嘛。
所以对于put操作,我们的目的是往头部插入数据,如果缓存满了的情况,我们还要从尾部删除数据。头部插入和尾部删除,我们希望能以O(1)的复杂度完成。
有没有这样的数据结构呢?首先,我们可以排除数组,因为它的插入效率是O(n);
我们回想我们学过的数据结构,链表,它的插入效率能达到O(1)。
我们先来看看单向链表,它的头部插入效率确实是O(1),但是尾部删除的时候,因为要从头遍历找到尾部节点的前驱节点,所以效率是O(n),单向链表也不符合;
我们再来看看双向链表,因为它的每个目标节点都保存了它直接前驱和直接后继结点的指针,所以通过目标节点,它头部插入的效率是O(1),尾部删除的时候,我们可以直接通过尾节点定位到它的前驱节点,所以,尾部删除的效率是O(1)。
那么,怎么快速定位到目标节点呢?
我们知道,还有一种数据结构,它没顺序性,但是它的查询效率非常高,那就是HashMap,通过哈希表,我们就能快速定位到目标节点的位置,前提是先建立好它跟双向链表的映射关系。
所以,我们可以再给双向链表节点加上一个key字段,跟HashMap的key保持映射关系。这样映射关系就建好了。
一般来说,这样的数据结构叫做哈希链表。
实现和复杂度分析
哈希链表的定义
我们先看下双向链表的节点的定义:
public class DoublyNode {
int value;
DoublyNode next;
DoublyNode prev;
DoublyNode(int element) {
this.value = element;
}
}
它有一个节点值,以及分别指向其前驱节点和后继节点的两个指针。双向链表有不懂的可以看我之前写的文章【双向链表复杂度分析】或者网上找其他资料了解,在这里就不详细介绍了。
在LRU缓存的设计中,我们要利用到双向链表的特性,保证缓存是有先后顺序的,而且在插入数据和删除数据时都保持O(1)的复杂度。同时还要利用到HashMap的特性,根据key查找到缓存对应的链表节点对象,复杂度也要保证O(1)。
所以我们再给双向链表节点加上一个key字段,跟HashMap的key保持映射关系。我们就给这个数据结构取名为哈希链表吧,如下:
class DLinkedNode {
int key;
int value;
DLinkedNode prev;
DLinkedNode next;
public DLinkedNode(int _key, int _value) {
key = _key;
value = _value;
}
}
下面开始设计LRUCache类:
成员变量定义
首先,缓存是有容量的,我们定义两个变量保存当前缓存的容量大小currentSize,以及最大不能超过的容量大小capacity。
//当前缓存的容量
private int currentSize;
//缓存的最大容量
private int capacity;
其次,定义一个HashMap,以int作为key,DLinkedNode作为value,用来维护缓存的内容。
//cacheMap,键为int,值为双向链表的节点
private Map<Integer, DLinkedNode> cacheMap = new HashMap<>();
然后,再定义伪头部和伪尾部的双向链表节点,方便我们对双向链表进行操作:
//伪头部和伪尾部节点
private DLinkedNode head, tail;
构造函数
在构造函数中,我对LRUCache的一些基本变量赋值
public LRUCache(int capacity) {
this.currentSize = 0;
this.capacity = capacity;
head = new DLinkedNode();
tail = new DLinkedNode();
head.next = tail;
tail.prev = head;
}
经过构造函数后,双向链表已经构造出两个节点,链表的形式可以类比为:head -><- tail
GET方法实现
get方法,就是取缓存的意思嘛,这里要注意下,取出缓存后,要把取的缓存前置。
假如当前缓存的形式如:【0,Node_A】【1,Node_B】【2,Node_C】调用get(1)后,
缓存的形式将变为:【1,Node_B】【0,Node_A】【2,Node_C】,并且会返回节点Node_B的值
public int get(int key) {
//根据key,从map中获取链表对应的节点
DLinkedNode node = cacheMap.get(key);
//如果获取的节点为null,直接返回-1即可
if (node == null) {
return -1;
}
//如果节点不为null,就将这个节点移动到双向链表的头部
moveToHead(node);
return node.value;
}
对于get方法:分以下几个步骤:
1、根据key,从map中获取链表对应的节点,O(1)。
2、如果获取的节点为null,直接返回-1即可,O(1)。
3、如果节点不为null,就将这个节点移动到双向链表的头部,O(1)。
4、返回改节点的value值,O(1)。
其中【这个节点移动到双向链表的头部】这个操作,其实内部分为两个子操作,需要先删除链表中的该节点,然后再把该节点添加到头部,实际上时间复杂度也是O(1),这个属于双向链表的操作,我们等会再分析。
所以,get方法这几个步骤的复杂度都是O(1),所以get方法的综合复杂度就是O(1)。
PUT方法实现
public void put(int key, int value) {
//尝试根据key从缓存中获取对应节点
DLinkedNode node = cacheMap.get(key);
if (node == null) {
//如果缓存中没有这个节点,就创建一个新的链表节点
DLinkedNode newNode = new DLinkedNode(key, value);
//节点和key添加进map
cacheMap.put(key, newNode);
//将该节点移动到链表头部
addToHead(newNode);
//容量增加
++currentSize;
if (currentSize > capacity) {
//如果超出容量,删除双向链表的尾部节点
DLinkedNode tail = removeTail();
//删除map中对应的项
cacheMap.remove(tail.key);
--currentSize;
}
} else {
//如果缓存中有这个节点,如就把这个节点对应的值修改为最新值,然后将该节点移动到链表头部
node.value = value;
moveToHead(node);
}
}
put方法就相对比较复杂一点点了,我们慢慢分析下。
假如当前缓存的形式为:【0,Node_A】【1,Node_B】,最大容量是3,
调用put(2,Node_C)后,
1、因为此时缓存中没有2这个key对应的节点,所以就新建一个节点node,并把key和node保存在map中,即【2,Node_C】
2、将该节点移动到链表头部
3、此时容量没有超过限制,所以此时缓存形式为:【2,Node_C】【0,Node_A】【1,Node_B】
调用put(3,Node_D)后,
1、因为此时缓存中没有3这个key对应的节点,所以就新建一个节点node,并把key和node保存在map中,即【3,Node_D】
2、将该节点移动到链表头部
3、此时容量超过限制了,所以直接将链表尾部元素和map中对应的键值对移除。即【1,Node_B】
4、此时,缓存的形式变为:【3,Node_D】【2,Node_C】【0,Node_A】
再调用put(2,Node_E)后,
1、因为此时缓存中有2这个key对应的节点,所以直接把这个节点的value值覆盖即可,即【2,Node_E】
2、然后还需要把这个节点移动到链表头部
3、缓存的形式将变为:【2,Node_E】【3,Node_D】【0,Node_A】
put方法的所有操作都是O(1)复杂度。
哈希链表复杂度的分析
为什么双向链表的addToHead()方法、moveToHead()方法、removeTail()方法、removeNode()方法时间复杂度都是O(1)呢?这就要归功于双向链表的双指针了。
下面我直接贴出这几个方法的代码吧,复杂度的分析也都写在注释上了。
/**
* 双向链表【添加某节点到链表头部】
* 例如 当前链表形式如:head -><- A -><- B -><- C -><- tail ,
* 调用addToHead(D)后,链表将变为:head -><- D -><- A -><- B -><- C -><- tail
**/
private void addToHead(DLinkedNode node) {
//处理当前节点
//当前节点的前指针指向head,当前节点的后指针指向head的后指针
node.prev = head;
node.next = head.next;
//处理当前节点的后继节点。后继节点的前指针指向当前节点
head.next.prev = node;
//处理当前节点的前驱节点。前驱节点的后指针指向当前节点
head.next = node;
}
/**
* 双向链表【移除某节点】
* 例如 当前节点是B,它的位置如下:head -><- A -><- B -><- C -><- tail ,
* 调用removeNode(B)后,链表将变为:head -><- A -><- C -><- tail
**/
private void removeNode(DLinkedNode node) {
//处理前驱节点。当前节点的前驱节点的后指针指向当前节点的后指针
node.prev.next = node.next;
//处理后继节点。当前节点的后继节点的前指针指向当前节点的前指针
node.next.prev = node.prev;
}
/**
* 双向链表【移动某节点到链表头部】
**/
private void moveToHead(DLinkedNode node) {
//先移除该节点
removeNode(node);
//再把该节点添加到头部
addToHead(node);
}
/**
* 双向链表【移除尾部节点】,并返回移除的节点
* 例如 当前链表形式如:head -><- A -><- B -><- C -><- tail ,
* 调用removeTail后,链表将变为:head -><- D -><- A -><- B -><- tail
**/
private DLinkedNode removeTail() {
DLinkedNode res = tail.prev;
removeNode(res);
return res;
}
总结
由此,我们的所有分析已经结束了,下面把整个LRUCache类的代码直接贴出来:
/**
* 哈希链表节点类
**/
class DLinkedNode {
int key;
int value;
DLinkedNode prev;
DLinkedNode next;
public DLinkedNode() {
}
public DLinkedNode(int _key, int _value) {
key = _key;
value = _value;
}
}
/**
* LRUCache实现
**/
public class LRUCache {
//cacheMap,键为int,值为双向链表的节点
private Map<Integer, DLinkedNode> cacheMap = new HashMap<>();
//当前缓存的容量
private int currentSize;
//缓存的最大容量
private int capacity;
//伪头部和伪尾部节点
private DLinkedNode head, tail;
public LRUCache(int capacity) {
this.currentSize = 0;
this.capacity = capacity;
head = new DLinkedNode();
tail = new DLinkedNode();
head.next = tail;
tail.prev = head;
}
public int get(int key) {
//根据key,从map中获取链表对应的节点
DLinkedNode node = cacheMap.get(key);
//如果获取的节点为null,直接返回-1即可
if (node == null) {
return -1;
}
//如果节点不为null,就将这个节点移动到双向链表的头部
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
//尝试根据key从缓存中获取对应节点
DLinkedNode node = cacheMap.get(key);
if (node == null) {
//如果缓存中没有这个节点,就创建一个新的链表节点
DLinkedNode newNode = new DLinkedNode(key, value);
//节点和key添加进map
cacheMap.put(key, newNode);
//将该节点移动到链表头部
addToHead(newNode);
//容量增加
++currentSize;
if (currentSize > capacity) {
//如果超出容量,删除双向链表的尾部节点
DLinkedNode tail = removeTail();
//删除map中对应的项
cacheMap.remove(tail.key);
--currentSize;
}
} else {
//如果缓存中有这个节点,如就把这个节点对应的值修改为最新值,然后将该节点移动到链表头部
node.value = value;
moveToHead(node);
}
}
private void addToHead(DLinkedNode node) {
//处理当前节点
//当前节点的前指针指向head,当前节点的后指针指向head的后指针
node.prev = head;
node.next = head.next;
//处理当前节点的后继节点。后继节点的前指针指向当前节点
head.next.prev = node;
//处理当前节点的前驱节点。前驱节点的后指针指向当前节点
head.next = node;
}
private void moveToHead(DLinkedNode node) {
//先移除该节点
removeNode(node);
//再把该节点添加到头部
addToHead(node);
}
private void removeNode(DLinkedNode node) {
//处理前驱节点。当前节点的前驱节点的后指针指向当前节点的后指针
node.prev.next = node.next;
//处理后继节点。当前节点的后继节点的前指针指向当前节点的前指针
node.next.prev = node.prev;
}
private DLinkedNode removeTail() {
DLinkedNode res = tail.prev;
removeNode(res);
return res;
}
}
可以感受到,基于哈希链表这种数据结构,能高效的完成LRU缓存的get操作和put操作。