LRU 缓存算法
一、题目描述
运用你所掌握的数据结构,设计和实现一个 LRU(最近最少使用)缓存机制。它应该支持以下操作:
- 获取数据 get(key):如果密钥 (key) 存在于缓存中,则获取密钥的值(总是正数),否则返回 -1。
- 写入数据 put(key, value) :如果密钥不存在,则写入其数据值。当缓存容量达到上限时,它应该在写入新数据之前删除最近最少使用的数据值,从而为新的数据值留出空间。
进阶:
你是否可以在 O(1) 时间复杂度内完成这两种操作?
LRUCache cache = new LRUCache( 2 /* 缓存容量 */ );
cache.put(1, 1);
cache.put(2, 2);
cache.get(1); // 返回 1
cache.put(3, 3); // 该操作会使得密钥 2 作废
cache.get(2); // 返回 -1 (未找到)
cache.put(4, 4); // 该操作会使得密钥 1 作废
cache.get(1); // 返回 -1 (未找到)
cache.get(3); // 返回 3
cache.get(4); // 返回 4
二、题解
LRU 是 Least Recently Used 的缩写,这种算法认为最近使用的数据是热门数据,下一次很大概率将会再次被使用。而最近很少被使用的数据,很大概率下一次不再用到。当缓存容量满的时候,优先淘汰最近很少使用的数据。
实现 LRU 缓存的常用方法是使用有界队列。实现 LRU 的关键是将所有最近使用的数据放在队列的开头。在每次插入之前,我们检查队列是否已满。如果队列已满,我们将删除其最后一个元素,并将新节点插入队列的开头。如果队列未满,我们只需将数据添加到队列的开头。
为了方便理解,我们借助前面的示例来演示一下上述的处理流程:
当我们想要删除节点或更新节点时,我们需要快速找到该节点在队列中的位置。因此,可以使用 HashMap 支持快速查找操作。在这种情况下,get 操作的时间复杂度为 O(1)。
由于我们还需要有效地删除队列中间的节点,因此需要一个双向链表。在两种情况下,我们需要删除队列中间的节点:
- 客户端指定需要删除一个节点。
- 节点已更新,需要将其删除并插入队列的开头。
通过使用双向链表,一旦我们通过 HashMap 定位了要删除的节点的位置,就可以在 O(1) 时间从队列中删除该节点。
当我们需要更新键的缓存时,我们首先使用 HashMap 定位相应的节点,更新值,然后从队列中删除该节点,并将该节点放置在 Doubly Linked List 的开头。
什么是缓存
这里说的缓存是一种广义的概念,在计算机存储层次结构中,低一层的存储器都可以看做是高一层的缓存。比如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)。
http://www.manongjc.com/article/37262.html 泛型
https://blog.csdn.net/weixin_46625895/article/details/105387746 非泛型
package com.logi.algorithm;
public class LRUWithSinglyLinkedList {
LRUNode head;
int capacity;
int size;
public LRUWithSinglyLinkedList(int capacity) {
this.capacity = capacity;
this.size = 0;
this.head = new LRUNode();
}
public static void main(String[] args) {
LRUWithSinglyLinkedList lru = new LRUWithSinglyLinkedList(2);
lru.put(1, 1);
System.out.println(lru + ", after put(1,1)");
lru.put(2, 2);
System.out.println(lru + ", after put(2,2)");
lru.get(1);
System.out.println(lru + ", after get(1)");
lru.put(3, 3);
System.out.println(lru + ", after put(3,3)");
lru.get(2);
System.out.println(lru + ", after get(2)");
lru.put(4, 4);
System.out.println(lru + ", after put(4,4)");
lru.get(1);
System.out.println(lru + ", after get(1)");
lru.get(3);
System.out.println(lru + ", after get(3)");
lru.get(4);
System.out.println(lru + ", after get(4)");
}
@Override
public String toString() {
LRUNode current = this.head.next;
StringBuilder list = new StringBuilder();
while (current != null) {
list.append(current.value);
if (current.next != null) {
list.append("->");
}
current = current.next;
}
return list.toString();
}
/**
* 根据 key 查找 value,如果已存在将其移至表头并返回,否则返回 -1
*
* @param key
* @return
*/
public int get(int key) {
LRUNode prev = this.getPrev(key);
if (prev != null) {
LRUNode current = prev.next;
this.delete(prev);
this.insert(current);
return current.value;
} else {
return -1;
}
}
/**
* 加载页面,如果缓存已满,删掉表尾
*
* @param key
* @param value
*/
public void put(int key, int value) {
LRUNode current = new LRUNode(key, value);
LRUNode prev = this.getPrev(key);
if (prev == null) {
if (this.size == this.capacity) {
this.delete(this.getTailPrev());
}
this.insert(current);
}
}
/**
* 获取 tail 前驱
*
* @return
*/
public LRUNode getTailPrev() {
LRUNode current = this.head;
if (current.next == null) {
return null;
}
while (current.next.next != null) {
current = current.next;
}
return current;
}
/**
* 根据 key 获得前驱
*
* @param key
* @return
*/
public LRUNode getPrev(int key) {
LRUNode prev = this.head;
while (prev != null) {
if (prev.next != null && prev.next.key == key) {
break;
}
prev = prev.next;
}
return prev;
}
/**
* 根据前驱删除
*
* @param prev
*/
public void delete(LRUNode prev) {
prev.next = prev.next.next;
this.size--;
}
/**
* 插入到表头
*
* @param current
*/
public void insert(LRUNode current) {
current.next = this.head.next;
this.head.next = current;
this.size++;
}
}
class LRUNode {
LRUNode next;
int key;
int value;
public LRUNode() {
this.key = this.value = 0;
this.next = null;
}
public LRUNode(int key, int value) {
this.key = key;
this.value = value;
this.next = null;
}
}
双向链表+哈希表
这可以通过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 Node{
int key;
int value;
Node pre;
Node next;
public Node(int key,int value){
this.key = key;
this.value = value;
}
}
public class LRUCache{
Map<Integer,Node> cacheMap = new HashMap<>();
//定义哈希表
Node head = null;
//定义头节点
Node tail = null;
//定义尾节点
int nodeLength =0;
//容量
//put操作
public int get(int key){
if(cacheMap.containsKey(key)){
//如果存在的话
Node node =cacheMap.get(key);
removeNode(node);
//进行移除
setHead(node);
//把节点放到首部
return node.value;
//返回节点
}
return -1;
//不存在返回-1;
}
//移除链表中的节点
public void removeNode(Node node){
if(node.pre != null){
//如果前节点不为空
node.pre.next = node.next;
}else{
//前节点为空
head = node.next;
}
if(node.next != null){
//后节点不为空
node.next.pre = node.pre;
}else{
后节点为空
tail = node.pre;
}
}
//把节点放到链表的头部
public void setHead(Node node){
node.next = head;
node.pre = null;
if(head != null){
//如果头结点不为空
head.pre = node;
}else{
head = node;
//头结点为空
}
if(tail == null){
//如果尾部节点为空
tail = head;
}
}
//put操作
public void put(int key,int value){
if(cacheMap.containsKey(key)){
//如果包含key的话
Node oldNode = cacheMap.get(key);
//取出旧的值
oldNode.value = value;
//替换旧的值
remove(oldNode);
//移除链表中的旧的值
setHead(oldNode);
//把旧的值放到首部
}else{
//不包含的情况
Node newNode = new Node(key,value);
//考虑容量是否足够
if(cacheMap.size() >= nodeLength){
//容量不够的话
cacheMap.remove(tail.key);
//哈希表去掉尾部值
remove(tail);
//删除链表尾部
setHead(newNode);
//把新的链表放到头部位置
}else{
setHead(newNode);
容量够的话
}
cacheMap.put(key,newNode);
//放入到hashmap中
}
}
}
import java.util.HashMap;
import java.util.Map;
/**
* @Auther: liuhaidong
* Data: 2020/3/24 0024、22:41
* Description:
* @version: 1.0
*/
class Node {
int key;
int value;
Node pre;
Node next;
public Node(int key, int value) {
this.key = key;
this.value = value;
}
}
public class LRUCache {
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.get(1));
cache.put(3, 3);
System.out.println("cache.get(2): " + cache.get(2));
System.out.println("cache.get(3): " + cache.get(3));
}
int capacity;
Map<Integer, Node> map = new HashMap<>();
Node head = null;
Node tail = null;
public LRUCache(int capacity) {
this.capacity = capacity;
}
/**
* 根据指定key查找其对应的值
*
* @param key
* @return
*/
public int get(int key) {
if (map.containsKey(key)) {
Node node = map.get(key);
remove(node);
setHead(node);
return node.value;
}
return -1;
}
/**
* 移除指定n节点
*
* @param n
*/
public void remove(Node n) {
if (n.pre != null) { // 非头节点
n.pre.next = n.next;
} else { // 头节点
head = n.next;
}
if (n.next != null) { // 非尾节点
n.next.pre = n.pre;
} else { // 尾节点
tail = n.pre;
}
}
/**
* 设置双向链表的头节点
*
* @param n
*/
public void setHead(Node n) {
n.next = head;
n.pre = null;
if (head != null)
head.pre = n;
head = n;
if (tail == null)
tail = head;
}
/**
* 把指定key和value添加到缓存中
*
* @param key
* @param value
*/
public void put(int key, int value) {
// key对应的节点已存在,先更新节点值,然后将其删除并插入到链表头
if (map.containsKey(key)) {
Node old = map.get(key);
old.value = value;
remove(old);
setHead(old);
} else {
// 基于key和value创建新的节点
Node created = new Node(key, value);
// 若链表已满,则先移除链表尾元素,然后再把新元素添加到链表头
if (map.size() >= capacity) {
map.remove(tail.key);
remove(tail);
setHead(created);
} else {
setHead(created);
}
map.put(key, created);
}
}
}
https://cloud.tencent.com/developer/article/1583695
其它代码
值得一提的是,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
,内部就会自动删除最老的元素。