1.LRU的含义
LeetCode146:运用你所掌握的数据结构,设计和实现一个LRU (最近最少使用) 缓存机制
实现 LRUCache 类:
LRUCache(int capacity) 以正整数作为容量 capacity 初始化 LRU 缓存
int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
void put(int key, int value) 如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字-值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。
进阶:你是否可以在 O(1) 时间复杂度内完成这两种操作?
输入
["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]
输出
[null, null, null, 1, null, -1, null, -1, 3, 4]
解释
LRUCache lRUCache = new LRUCache(2);
lRUCache.put(1, 1); // 缓存是 {1=1}
lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
lRUCache.get(1); // 返回 1
lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3}
lRUCache.get(2); // 返回 -1 (未找到)
lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
lRUCache.get(1); // 返回 -1 (未找到)
lRUCache.get(3); // 返回 3
lRUCache.get(4); // 返回 4
关于什么是LRU,简单来说就是 当内存空间满了,不得不淘汰某些数据时(通常是容量已满),选择最久未被使用的数据进行淘汰。
这里做了简化 ,题目让我们实现一个容量固定的 LRUCache 。如果插入数据时,发现容器已满时,则先按照 LRU 规则淘汰一个数据,再将新数据插入,其中「插入」和「查询」都算作一次“使用”。
看一个百度百科的例子:https://baike.baidu.com/item/LRU/1269842?fr=aladdin
最近最少使用算法(LRU)是大部分操作系统为最大化页面命中率而广泛采用的一种页面置换算法。
该算法的思路是,发生缺页中断时,选择未使用时间最长的页面置换出去。 假设内存只能容纳3个页大小,按照 7 0 1 2 0 3 0 4 的次序访问页。假设内存按照栈的方式来描述访问时间,在上面的,是最近访问的,在下面的是,最远时间访问的,LRU就是这样工作的:
2 hash+双向链表实现LRU
目前公认最合理的方式是使用hash+双向链表。想不到吧,接下来我们就看看该怎么做。
- 目前公认最合理的方式是使用hash+双向链表。想不到吧,接下来我们就看看该怎么做。
Hash的作用是 用来做到O(1)访问元素,哈希表就是普通的哈希映射(HashMap),通过缓存数据的键映射到其在双向链表中的位置。Hash里的数据是key-value结构。value就是我们自己封装的node,key则是键值,也就是在Hash的地址。 - 双向链表用来实现根据访问情况对元素进行排序。双向链表按照被使用的顺序存储了这些键值对,靠近头部的键值对是最近使用的,而靠近尾部的键值对是最久未使用的。
这样以来,我们要确认元素的位置直接访问哈希表就行了,找出缓存项在双向链表中的位置,随后将其移动到双向链表的头部,即可在 O(1)的时间内完成 get 或者 put 操作。具体的方法如下:
- 对于 get 操作,首先判断 key 是否存在:
- 如果 key 不存在,则返回 -1;
- 如果 key 存在,则 key 对应的节点是最近被使用的节点。通过哈希表定位到该节点在双向链表中的位置,并将其移动到双向链表的头部,最后返回该节点的值。
- 对于 put 操作,首先判断 key 是否存在:
- 如果 key 不存在,使用 key 和 value 创建一个新的节点,在双向链表的头部添加该节点,并将 key 和该节点添加进哈希表中。然后判断双向链表的节点数是否超出容量,如果超出容量,则删除双向链表的尾部节点,并删除哈希表中对应的项;
- 如果 key 存在,则与 get 操作类似,先通过哈希表定位,再将对应的节点的值更新为 value,并将该节点移到双向链表的头部。
上述各项操作中,访问哈希表的时间复杂度为 O(1),在双向链表的头部添加节点、在双向链表的尾部删除节点的复杂度也为 O(1)。而将一个节点移到双向链表的头部,可以分成「删除该节点」和「在双向链表的头部添加节点」两步操作,都可以在 O(1) 时间内完成。
同时为了方便操作,在双向链表的实现中,使用一个伪头部(dummy head)和伪尾部(dummy tail)标记界限,这样在添加节点和删除节点的时候就不需要检查相邻的节点是否存在。
看个图示:
在双向链表的实现中,使用一个伪头部(dummy head)和伪尾部(dummy tail)标记界限,这样在添加节点和删除节点的时候就不需要检查相邻的节点是否存在。
我们先看容量为3的例子,首先缓存了1,此时结构如图a所示。之后再缓存2和3,结构如b所示。
之后 4再进入, 此时容量已经不够了,只能将最远未使用的元素1删掉, 然后将4插入到链表头部。此时就变成了上图c的样子。
接下来假如又访问了一次2,会怎么样呢?此时会将2移动到链表的首部,也就是下图d的样子。
之后假如又要缓存5呢?此时就将tail指向的3删除,然后将5插入到链表头部。也就是上图e的样子。
上面的方案要实现是非常容易的,我们注意到链表主要执行几个操作:
- 假如容量没满, 则将新元素直接插入到链表头就行了。
- 如果容量够了,新的元素到来,则将tail指向的表尾元素删除就行了。
- 假如要访问已经存在的元素,则此时将该先从链表中删除,再插入到表头就行了。
再看Hash的操作:
- Hash没有容量的限制,凡是被访问的元素都会在Hash中有个标记,key就是我们的查询条件,而value就是链表的结点的引用,可以不用访问链表直接定位到某个结点,然后就可以执行我们在上一节提到的方法来删除对应的结点。
- 这里双向链表的删除好理解,那HashMap中是如何删除的呢?其实就是将node变成为null。这样get(key)的时候返回的是null,就实现了删除的功能。
上述各项操作中,访问哈希表的时间复杂度为 O(1),在双向链表的头部添加节点、在双向链表的尾部删除节点的复杂度也为 O(1)。而将一个节点移到双向链表的头部,可以分成「删除该节点」和「在双向链表的头部添加节点」两步操作,都可以在 O(1)时间内完成。
public class LRUChallenge {
private Map<Integer, DLinkedNode> cache = new HashMap<>();
private int size;
private int capacity;
private DLinkedNode head, tail;
public LRUChallenge(int capacity) {
this.size = 0;
this.capacity = capacity;
// 使用伪头部和伪尾部节点
head = new DLinkedNode();
tail = new DLinkedNode();
head.next = tail;
tail.prev = head;
}
public int get(int key) {
DLinkedNode node = cache.get(key);
if (node == null) {
return -1;
}
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
DLinkedNode node = cache.get(key);
if (node != null) {
node.value = value;
moveToHead(node);
} else {
node = new DLinkedNode(key, value);
addToHead(node);
cache.put(key, node);
size++;
if (size > capacity) {
DLinkedNode removeTail = removeTail();
cache.remove(removeTail.key);
size--;
}
}
}
private void addToHead(DLinkedNode node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
private void removeNode(DLinkedNode node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
public void moveToHead(DLinkedNode node) {
removeNode(node);
addToHead(node);
}
private DLinkedNode removeTail() {
DLinkedNode res = tail.prev;
removeNode(res);
return res;
}
static class DLinkedNode {
int key;
int value;
DLinkedNode prev;
DLinkedNode next;
public DLinkedNode() {
}
public DLinkedNode(int _key, int _value) {
key = _key;
value = _value;
}
}
public static void main(String[] args) {
LRUChallenge lRUCache = new LRUChallenge(2);
lRUCache.put(1, 1); // 缓存是 {1=1}
lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
System.out.println(lRUCache.get(1)); // 返回 1
lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3}
System.out.println(lRUCache.get(2)); // 返回 -1 (未找到)
lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
System.out.println(lRUCache.get(1)); // 返回 -1 (未找到)
System.out.println(lRUCache.get(3)); // 返回 3
System.out.println(lRUCache.get(4)); // 返回 4
}
}
3.拓展(LFU的实现)
LFU(Least Frequently Used) 最近最不常用算法,它是根据频率维度来选择将要淘汰的元素,即删除访问频率最低的元素。如果两个元素的访问频率相同,则淘汰最久没被访问的元素。
也就是说LFU淘汰的时候会选择两个维度,先比较频率,选择访问频率最小的元素;如果频率相同,则按时间维度淘汰掉最久远的那个元素。
LRU的实现是一个哈希表加上一个双链表
而LFU则要复杂多了,需要用两个哈希表再加上N个双链表才能实现
我们先看看LFU的两个哈希表里面都存了什么
第一个哈希表是key-value的哈希表(以下简称kv哈希表)
这里的key就是输入的key,没什么特别的。关键是value,它的value不是一个简单的value,而是一个节点对象。
节点对象Node包含了key,value,以及频率,这个Node又会出现在第二个哈希表的value中。
至于为什么Node中又重复包含了key,因为某些情况下我们不是通过k-v哈希表拿到Node的,而是通过其他方式获得了Node,之后需要用Node中的key去k-v哈希表中做一些操作,所以Node中包含了一些冗余信息。
第二张哈希表,频率哈希表,这个就要复杂多了
这张哈希表中的key是频率,也就是元素被访问的频率(被访问了1次,被访问了两次等等),它的value是一个双向链表
刚才说的Node对象,现在又出现了,这里的Node其实是双向链表中的一个节点。
第一张图中我们介绍了Node中包含了一个冗余的key,其实它还包含了一个冗余的频率值,因为某些情况下,我们需要通过Node中的频率值,去频率哈希表中做查找,所以也需要一个冗余的频率值。
下面我们将两个哈希表整合起来看看完整的结构:
k-v哈希表中key1指向一个Node,这个Node的频率为1,位于频率哈希表中key=1下面的双链表中(处于第一个节点)。
具体操作
下面我们来看看具体操作,get操作相对简单一些,我们就先说get操作吧。
get操作的具体逻辑大致是这样:
- 如果key不存在则返回-1
- 如果key存在,则返回对应的value,同时:
- 将元素的访问频率+1
- 将元素从访问频率i的链表中移除,放到频率i+1的链表中
- 如果频率i的链表为空,则从频率哈希表中移除这个链表
- 将元素的访问频率+1
第一个很简单就不用说了,我们看下第二点的执行过程
假设某个元素的访问频率是3,现在又被访问了一次,那么就需要将这个元素移动到频率4的链表中。如果这个元素被移除后,频率3的那个链表变成空了(只剩下头结点和尾节点)就需要删除这个链表,同时删除对应的频率(也就是删除key=3)
put操作就要复杂多了,大致包括下面几种情况
-
如果key已经存在,修改对应的value,并将访问频率+1
-
将元素从访问频率i的链表中移除,放到频率i+1的链表中
-
如果频率i的链表为空,则从频率哈希表中移除这个链表
-
-
如果key不存在
-
缓存超过最大容量,则先删除访问频率最低的元素,再插入新元素
- 新元素的访问频率为1,如果频率哈希表中不存在对应的链表需要创建
-
缓存没有超过最大容量,则插入新元素
- 新元素的访问频率为1,如果频率哈希表中不存在对应的链表需要创建
-
我们在代码实现中还需要维护一个minFreq的变量,用来记录LFU缓存中频率最小的元素,在缓存满的时候,可以快速定位到最小频繁的链表,以达到 O(1) 时间复杂度删除一个元素。 具体做法是:
- 更新/查找的时候,将元素频率+1,之后如果minFreq不在频率哈希表中了,说明频率哈希表中已经没有元素了,那么minFreq需要+1,否则minFreq不变。
- 插入的时候,这个简单,因为新元素的频率都是1,所以只需要将minFreq改为1即可。
我们重点看下缓存超过最大容量时是怎么处理的
代码部分
做了一些简单的封装。 这里自定义了一个双向链表,增加了一些自定义的函数
import java.util.HashMap;
import java.util.Map;
/**
* 自定义的LFU缓存类
*/
public class LFUCache {
/**
* 双链表中的链表节点对象
*/
protected static class Node{
//对应输入的key
private final int key;
//对应输入的value
private int value;
//被访问的频率
private int freq;
//指向前一个节点的指针
protected Node pre;
//指向后一个节点的指针
protected Node next;
public Node(int key, int value, int freq) {
this.key = key;
this.value = value;
this.freq = freq;
}
public Node(int key, int value, int freq, Node pre, Node next) {
this.key = key;
this.value = value;
this.freq = freq;
this.pre = null;
this.next = null;
}
public void updateValue(int value) {
this.value = value;
}
public void incrFreq() {
++this.freq;
}
public int getKey() {
return this.key;
}
public int getValue() {
return this.value;
}
public int getFreq() {
return this.freq;
}
public static final Node createEmptyNode() {
return new Node(-1,-1,-1,null,null);
}
}
/**
* 自定义的双向链表类
*/
protected static class LinkedList {
//双向链表的头结点
private final Node head;
//双向链表的尾节点
private final Node tail;
public LinkedList() {
this.head = Node.createEmptyNode();
this.tail = Node.createEmptyNode();
this.head.next = this.tail;
this.tail.pre = this.head;
}
/**
* 将指定的节点插入到链表的第一个位置
* @param node 将要插入的节点
*/
public void insertFirst(Node node) {
if(node==null) {
throw new IllegalArgumentException();
}
node.next = this.head.next;
this.head.next.pre = node;
node.pre = this.head;
this.head.next = node;
}
/**
* 从链表中删除指定的节点
* @param node 将要删除的节点
*/
public void deleteNode(Node node) {
if(node==null) {
throw new IllegalArgumentException();
}
node.pre.next = node.next;
node.next.pre = node.pre;
node.pre = null;
node.next = null;
}
/**
* 从链表中获取最后一个节点
* @return 双向链表中的最后一个节点,如果是空链表则返回None
*/
public Node getLastNode() {
if(this.head.next==this.tail) {
return Node.createEmptyNode();
}
return this.tail.pre;
}
/**
* 判断链表是否为空,除了head和tail没有其他节点即为空链表
* @return 链表不空返回True,否则返回False
*/
public boolean isEmpty() {
return this.head.next==this.tail;
}
}
//key->Node 这种结构的哈希表
private final Map<Integer,Node> keyMap = new HashMap<Integer,Node>();
//freq->LinkedList 这种结构的哈希表
private final Map<Integer,LinkedList> freqMap = new HashMap<Integer,LinkedList>();
//缓存的最大容量
private final int capacity;
//记录缓存中最低频率
private int minFreq = 0;
public LFUCache(int capacity) {
// if(capacity<=0) {
// throw new IllegalArgumentException();
// }
this.capacity = capacity;
}
/**
* 获取一个元素,如果key不存在则返回-1,否则返回对应的value,同时更新被访问元素的频率
* @param key 要查找的关键字
* @return 如果没找到则返回-1,否则返回对应的value
*/
public int get(int key) {
if(!this.keyMap.containsKey(key)) {
return -1;
}
Node node = this.keyMap.get(key);
this.increment(node);
return node.getValue();
}
/**
* 插入指定的key和value,如果key存在则更新value,同时更新频率,
* 如果key不存并且缓存满了,则删除频率最低的元素,并插入新元素。否则,直接插入新元素
* @param key 要插入的关键字
* @param value 要插入的值
*/
public void put(int key, int value) {
if(this.keyMap.containsKey(key)) {
Node node = this.keyMap.get(key);
node.updateValue(value);
this.increment(node);
}
else {
if(this.capacity==0) {
return;
}
if(this.keyMap.size()==this.capacity) {
this.remoteMinFreqNode();
}
Node node = new Node(key,value,1);
this.increment(node,true);
this.keyMap.put(key, node);
}
}
/**
* 更新节点的访问频率
* @param node 要更新的节点
*/
private void increment(Node node) {
increment(node,false);
}
/**
* 更新节点的访问频率
* @param node 要更新的节点
* @param isNewNode 是否是新节点,新插入的节点和非新插入节点更新逻辑不同
*/
private void increment(Node node,boolean isNewNode) {
if(isNewNode) {
this.minFreq = 1;
this.insertToLinkedList(node);
}
else {
this.deleteNode(node);
node.incrFreq();
this.insertToLinkedList(node);
if(!this.freqMap.containsKey(this.minFreq)) {
++this.minFreq;
}
}
}
/**
* 根据节点的频率,插入到对应的LinkedList中,如果LinkedList不存在则创建
* @param node 将要插入到LinkedList的节点
*/
private void insertToLinkedList(Node node) {
if(!this.freqMap.containsKey(node.getFreq())) {
this.freqMap.put(node.getFreq(), new LinkedList());
}
LinkedList linkedList = this.freqMap.get(node.getFreq());
linkedList.insertFirst(node);
}
/**
* 删除指定的节点,如果节点删除后,对应的双链表为空,则从__freqMap中删除这个链表
* @param node 将要删除的节点
*/
private void deleteNode(Node node) {
LinkedList linkedList = this.freqMap.get(node.getFreq());
linkedList.deleteNode(node);
if(linkedList.isEmpty()) {
this.freqMap.remove(node.getFreq());
}
}
/**
* 删除频率最低的元素,从freqMap和keyMap中都要删除这个节点,
* 如果节点删除后对应的链表为空,则要从__freqMap中删除这个链表
*/
private void remoteMinFreqNode() {
LinkedList linkedList = this.freqMap.get(this.minFreq);
Node node = linkedList.getLastNode();
linkedList.deleteNode(node);
this.keyMap.remove(node.getKey());
if(linkedList.isEmpty()) {
this.freqMap.remove(node.getFreq());
}
}
}