LRU算法概念
LRU算法在面试中经常遇到重要性可与快排比肩
全称:Least Recently Used即最近最久未使用
主要应用场景:缓存
我们访问数据库的时候,有些数据经常被访问(热点数据),我们把这些数据放在内存中缓存下来,就不用每次都去数据库里面查找,当下一次有同样请求的时候,可以以更快的速度返回
既然是把热点数据存在缓存中,那么缓存的容量是有限的,那么容量不足的时候,我们就要考虑剔除一部分数据,那么问题就来了,我们需要考虑剔除哪一部分数据-----就有了LRU算法,剔除一些最近没有访问的数据,达到缓存数据的实时有效性的效果
关键点:
1:一个最大容量,put方法,get方法
2:要保证快,保证都是O(1)的时间复杂度
3:上一次访问的元素在第一个
/* 缓存容量为2*/
LRUCache cache = new LRuCache(2);//你可以把 cache理解成一个队列//假设左边是队头,右边是队尾
//最近使用的排在队头,很久未使用的排在队尾
cache.put(1,1);
// cache = [(1,1)]
cache.put(2,2);
// cache = [(2,2),(1,1)]
cache.get(1); //返回1
// cache = [(1,1),(2,2)]
/ /解释:因为最近访问了键1,所以提前至队头
cache.put( 3,3);
/ / cache = [(3,3),(1,1)]
//解释:缓存容量已满,需要删除内容空出置
//优先删除久未使用的数据,也就是队尾的数据
//然后把新的数据插入队头
cache.get(2);//返回-1(未找到)
/ / cache = [(3,3),(1,1)]
//解释:cache中不存在键为2的数据
cache.put(1,4);
// cache = [(1,4),(3,3)]
//解释:键1已存在,把原始值1覆盖为4//不要忘了也要将键值对提前到队头
LRU特性:查找快、插入快、删除快、有顺序之分
怎么实现LRU算法?
第一反应:map,但是map不是有序的
实际结构:双向链表+散列表
通过哈希表映射到双向链表
哈希表查找快,但是数据无固定顺序;链表有顺序之分,插入删除快,但是查找慢。所以结合一下,形成一种新的数据结构:哈希链表 LinkedHashMap。
借助这个结构,我们来逐一分析上面的 3 个条件:
1、如果我们每次默认从链表尾部添加元素,那么显然越靠尾部的元素就是最近使用的,越靠头部的元素就是最久未使用的。
2、对于某一个 key,我们可以通过哈希表快速定位到链表中的节点,从而取得对应 val。
3、链表显然是支持在任意位置快速插入和删除的,改改指针就行。只不过传统的链表无法按照索引快速访问某一个位置的元素,而这里借助哈希表,可以通过 key 快速映射到任意一个链表节点,然后进行插入和删除。
为什么是存key-value,不直接存value呢?
我觉得是因为加入容量已满,需要删除链表的最后一个节点,链表好实现,但是怎么从map里面去删除节点呢?
map.remove(tail.key);//在hashmap中删除链表最后一个节点对应的map对象
为什么使用双链表:删除元素保证时间复杂度为O(1),单链表还需要查找它的前一个元素
因为我们需要删除操作。删除一个节点不光要得到该节点本身的指针,也需要操作其前驱节点的指针,而双向链表才能支持直接查找前驱,保证操作的时间复杂度 O(1)。
比如在谷歌搜答案,一个问题对应一个答案,底层链表存有问题和答案的信息,HashMap里面就不用存答案了,存链表中的节点,这样就规避了链表查询慢的缺点,将查询的时间复杂度降到O(1),
那么链表中也没有必要记录Q了,因为HashMap里面已经记录了,可以把数据结构更新如下
我的理解:map里面的顺序还是你插入删除的顺序,只是,map里面各个key的value之间是有联系的,形成了一条链表,链表里面的顺序才是真正的顺序
我的代码(反序遍历链表还有bug)
package LeetCode;
/*
* @Author 此生辽阔
* @Description 实现LRU算法
* @Date 16:05 2021/3/14
* @Param
* @return
**/
/*
遇到的问题:
怎么初始化头结点和尾节点:在调用put方法的时候初始化
怎么在其他方法里面调用map:把map定义为成员变量
map.put()的时候,key和value怎么传?所以,node要存放key和value,map.put(node.key,node)
当map.put()的时候,如何判断map中是否有此节点呢,或者只是value不同呢? map.containsKey(node.key)?
插入数据需要考虑LRU的空间是否已满
*/
import SwordOffer.deleteNode;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Logger;
public class LRU {
private static int capacity;//LRU的容量
public static Node head;//链表头结点
public static Node tail;//链表尾节点
public static Map<Integer,Node> map=new HashMap<>();//定义HashMap
public LRU(int capacity) {
this.capacity = capacity;
}
public static void main(String[] args) {
// Node head=null;
LRU lru=new LRU(5);
lru.put(1,1);
System.out.println( "lru.put(1,1)");
printList();
lru.put(2,2);
System.out.println( "lru.put(2,2)");
printList();
lru.put(3,3);
System.out.println( "lru.put(3,3)");
printList();
lru.put(4,4);
System.out.println( "lru.put(4,4)");
printList();
lru.put(5,5);
System.out.println( "lru.put(5,5)");
printList();
lru.put(6,6);
System.out.println( "lru.put(6,6)");
printList();
lru.put(7,7);
System.out.println( "lru.put(7,7)");
printList();
lru.get(3);
System.out.println( " lru.get(3)");
printList();
lru.put(8,8);
System.out.println( "lru.put(8,8)");
printList();
lru.get(7);
System.out.println( " lru.get(7)");
printList();
lru.put(6,12);
System.out.println( "lru.put(6,12)");
printList();
lru.get(6);
System.out.println( " lru.get(6)");
printList();
lru.get(5);
System.out.println( " lru.get(5)");
printList();
lru.put(9,9);
System.out.println( "lru.put(9,9)");
printList();
System.out.println();
for (Map.Entry<Integer, Node> entry : map.entrySet()) {
System.out.println(entry.getKey() + ":" + entry.getValue().val);
}
}
static void printList()
{
Node node=head;
Node node2=tail;
System.out.print("正序遍历链表: ");
while(node!=null)
{
if(node.next!=null)
{
System.out.print(node.val+"->");
}
else
{
System.out.print(node.val);
}
node=node.next;
}
System.out.println();
// System.out.print("反序遍历链表: ");
// while(node2!=null)
// {
// if(node2.pre!=null) {
// System.out.print(node2.val + "->");
// }
// else
// {
// System.out.print(node2.val );
// }
// node2=node2.pre;
// }
// System.out.println();
}
//思路,LRU算法的核心是当插入一个数据时,把它放到链表的头部,如果插入的节点本来就在链表中,那么先把它从原链表中删除,然后插入到链表头部
//插入数据:
//删除数据:
//获取数据:
//我们要实现LRU算法,要为LRU算法定义get方法和put方法以及delete方法
static void get(int key)
{
Node node=map.get(key);
if(node==null)
{
// Logger logger = Logger.getLogger("未查询到结点");
// logger.info("未查询到结点");
System.out.println("未查询到结点");
return ;//查找不到节点,直接返回
}
if (node==head)
{
//如果当前查找的节点是链表的头结点,那么直接返回
return;
}
//如果当前查找的节点不是链表的头结点,需要先从链表删除节点,再在链表头部插入该节点,那么我们需要定义链表的删除和插入方法
deleteNode( node);
putNode( node);
}
// static void put(Node node)
static void put(int key ,int value)
{
Node node2= map.get(key);
if(node2!=null)
{
//map.get( node.key)=node.val;//更新节点的val值
// Node node1=map.get(node.key);
// node1.val= node.val;
node2.val=value;
// Node node= map.get(key);
// map.put(key, node2);//这句话是没必要的,node2就是指向map中key对应的节点
deleteNode(node2);
putNode(node2);
return;
}
// if(map.get( node.key)!=null)//如果map里面有这个节点的key
// {
// map.get( node.key).val=node.val;//更新节点的val值
// // map.put(node.key,node);
//
// }
Node node=new Node(key,value);
if(map.size()>=capacity)//如果LRU缓存还没有满
{
System.out.println("缓存已满,删除:"+tail.key);
map.remove(tail.key);//在hashmap中删除链表最后一个节点对应的map对象
deleteNode(tail);
}
// System.out.println("我执行了");
/*
我们来理一下上面的逻辑,先判断map中有没有传入的节点的key,如果有,就更新对应的value
如果没有,说明是个新节点,我们先判断LRU缓存有没有满,如果满了,就删除链表末尾的节点
*/
if(head==null) //如果当前链表为空
{
head=tail=node;//把传入的node置为头结点和尾节点
map.put(node.key,node);
return;
}
if(head==tail)//如果当前链表只有一个节点
{
//map中已经包含此key,就更新value
node.next=head;
head.pre=node;
//head.next=tail;
head=node;
// tail= head.next;//这句话我不知道为什么不加。。。
map.put(node.key,node);
return;
}
//当前链表至少有两个节点
map.put(node.key,node);
putNode( node);
}
static void deleteNode(Node node)//链表的删除节点的方法
{
if(node==head)//按理说,应该不用判断头结点,因为只有不是头结点的情况才会调用删除
{
//删除头节点
head.next.pre=null;
head=head.next;
return;//进入if,执行完之后就返回,不向下执行
}
if(node==tail)//删除尾节点
{
tail.pre.next=null;
tail=tail.pre;
return;//进入if,执行完之后就返回,不向下执行
}
//删除中间节点
node.pre.next=node.next;
node.next.pre=node.pre;
node.pre=null;
node.next=null;
}
static void putNode(Node node)//链表插入节点的方法
{
node.next=head;
head.pre=node;
head=node;//更新当前的头结点指向
}
}
//定义节点类
class Node{
public int key;
public int val;
public Node pre;
public Node next;
public Node(int key, int val) {
this.key = key;
this.val = val;
}
}
测试结果
lru.put(1,1)
正序遍历链表: 1
lru.put(2,2)
正序遍历链表: 2->1
lru.put(3,3)
正序遍历链表: 3->2->1
lru.put(4,4)
正序遍历链表: 4->3->2->1
lru.put(5,5)
正序遍历链表: 5->4->3->2->1
缓存已满,删除:1
lru.put(6,6)
正序遍历链表: 6->5->4->3->2
缓存已满,删除:2
lru.put(7,7)
正序遍历链表: 7->6->5->4->3
lru.get(3)
正序遍历链表: 3->7->6->5->4
缓存已满,删除:4
lru.put(8,8)
正序遍历链表: 8->3->7->6->5
lru.get(7)
正序遍历链表: 7->8->3->6->5
lru.put(6,12)
正序遍历链表: 12->7->8->3->5
lru.get(6)
正序遍历链表: 12->7->8->3->5
lru.get(5)
正序遍历链表: 5->12->7->8->3
缓存已满,删除:3
lru.put(9,9)
正序遍历链表: 9->5->12->7->8
5:5
6:12
7:7
8:8
9:9
使用LinlkedHashMap实现LRU算法
Java集合详解5:深入理解LinkedHashMap和LRU缓存
算法–用LinkedHashMap简单实现LRU缓存算法(Java实现)
以下代码参考算法题就像搭乐高:手把手带你拆解 LRU 算法
LinkedHashMap的底层就是HashMap+数组,我们只需要对这个map做查询和添加的操作,而不需要操作链表
查询
:根据key查询,如果没有找到key,就返回-1,如果找到,就返回key对应的值,同时我们要把这个key移除,然后添加到map的末尾
添加
:先判断map中有没有这个key,如果有,就更新value的值,同时把这个key移除,然后添加到map的末尾。如果没有这个Key,说明是新的key,我们首先判断map中的容量有没有达到缓存容量,如果没有达到,直接插入到map的末尾,如果达到缓存容量,那么需要先移除map的首元素(首元素是缓存中最老的没有访问的数),然后把这个新的key插入到map的末尾
class Lruc
{
int cap;
LinkedHashMap<Integer,Integer> cache=new LinkedHashMap<>();
public Lruc(int cap) {
this.cap = cap;
}
public int get (int key)
{
if(!cache.containsKey(key))
{
return -1;
}
// 将 key 变为最近使用
makeRecently(key);
return cache.get(key);
}
public void put(int key, int val) {
if (cache.containsKey(key)) {
cache.put(key, val);// 修改 key 的值
makeRecently(key); // 将 key 变为最近使用
return;
}
if (cache.size() >= this.cap) {
// 链表头部就是最久未使用的 key
int oldestKey = cache.keySet().iterator().next();
cache.remove(oldestKey);
}
cache.put(key, val); // 将新的 key 添加链表尾部
}
private void makeRecently(int key)
{
int val= cache.get(key);
cache.remove(key); // 删除 key,重新插入到队尾
cache.put(key,val);
}
}
参考文献
视频:田小齐详解「LRU Cache」正确的面试解答方式 Leetcode 146 刷题冲呀
算法题就像搭乐高:手把手带你拆解 LRU 算法
基于哈希表和双向链表的 LRU 算法实现
原创 | 你会用缓存吗?详解LRU缓存淘汰算法
带有过期时间的LRU实现(java版)
【系统设计】LRU缓存