【数据结构与算法】LRU 缓存淘汰算法

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操作。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一场雪ycx

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值