从造轮子开始彻底理解LRU缓存机制

Leetcode146. LRU缓存机制

造轮子

在面试中,面试官一般会期望读者能够自己实现一个简单的双向链表,而不是使用语言自带的、封装好的数据结构。所以,造轮子还是很有必要的。

LRU 缓存淘汰算法就是一种常用策略。LRU 的全称是 Least Recently Used,翻译过来就是最近最久未使用。

力扣第 146 题「LRU缓存机制」就是让我们来设计这样一个数据结构:

首先要接收一个 capacity 参数作为缓存的最大容量,然后实现两个 API,一个是 put(key, val) 方法存入键值对,另一个是 get(key) 方法获取 key 对应的 val,如果 key 不存在则返回 -1。

函数签名如下:

class LRUCache {
    public LRUCache(int capacity) {}
    public int get(int key) {}
    public void put(int key, int value) {}
}

注意:getput 方法必须都是 O(1) 的时间复杂度,我们先来举个具体例子看看 LRU 算法怎么工作。

LRUCache cache = new LRUCache(2);  // 缓存容量为 2
// 可以把 cache 理解成一个队列
// 假设左边是队头,右边是队尾
// 那么最近使用的排在队头,最久未使用的排在队尾
// 圆括号表示键值对 (key, val)

cache.put(1, 1);  // cache = [(1, 1)]

cache.put(2, 2);  // 新来的添加到队头(左边),此时 cache = [(2, 2), (1, 1)] 

cache.get(1);     // 访问一次 key=1,返回 1。
// 因为(1,1)被访问,被提到队头,此时 cache = [(1, 1), (2, 2)] 

cache.put(3, 3);  // 新添加一个元素
// 但是此时已经满了,需要将队尾(最久未使用)元素删除,然后将新元素添加到队头
// 此时 cache = [(3, 3), (1, 1)] 

cache.get(2);     // 返回 -1 (未找到)

cache.put(1, 4);  // key=1 已存在,把value覆盖为 4,同时将(1,4)提到队头
// 此时cache = [(1, 4), (3, 3)] 

分析上面的操作,要让 putget 方法的时间复杂度为 O(1),我们可以总结出 cache 这个数据结构必要的条件:

1、显然 cache 中的元素必须有时序,以区分最近使用的和久未使用的数据,当容量满了之后要删除最久未使用的那个元素腾出位置。

2、我们要在 cache 中快速找某个 key 是否已存在并得到对应的 val

3、每次访问 cache 中的某个 key,需要将这个元素变为最近使用的,这个操作可以分为三个步骤:①快速找到该元素(即上面的2);② 将该元素删除;③ 将该元素重新添加到队头位置。也就是说 cache 要支持在任意位置快速删除和插入元素。

那么,什么数据结构同时符合上述条件呢?哈希表查找快,但是数据无固定顺序;链表有顺序之分,插入删除快,但是查找慢。所以结合一下,形成一种新的数据结构:哈希链表 LinkedHashMap

LRU 缓存算法的核心数据结构就是哈希链表,双向链表和哈希表的结合体。这个数据结构长这样:

读到这里可能会产生两个问题:

  1. 为什么要是双向链表,单链表行不行?
  2. 哈希表中已经存了 key,为什么链表中还要存 keyval 呢?

这两个问题凭空不好解释,接着往下看,从代码中寻找答案。

架构设计

我们的最终目的是想实现LRUCache这么一个数据结构,在此之前,我们先来画一下架构图:

需要实现三层架构:底层、抽象层、实现层

我们的底层需要三个类:NodeDoubleListHashMap

总体架构如下:

代码实现**:

class Node{
  	public int key, val;
  	public Node next, prev;
  	public Node(int k, int v){
      	this.key = k;
      	this.val = v;
    }
}

然后依靠我们的 Node 类型构建一个双链表,实现几个 LRU 算法必须的 API(无非是一些增删改的方法):

class DoubleList {  
    // 头尾虚节点
    private Node head, tail;  
    // 链表元素数
    private int size;

    public DoubleList() {
        // 初始化双向链表的数据
        head = new Node(0, 0);
        tail = new Node(0, 0);
        head.next = tail;
        tail.prev = head;
        size = 0;
    }

    // 在链表尾部添加节点 x,时间 O(1)
    public void addLast(Node x) {
        x.prev = tail.prev;
        x.next = tail;
        tail.prev.next = x;
        tail.prev = x;
        size++;
    }

    // 删除链表中的 x 节点(x 一定存在)
    // 由于是双链表且给的是目标 Node 节点,时间 O(1)
    public void remove(Node x) {
        x.prev.next = x.next;
        x.next.prev = x.prev;
        size--;
    }

    // 删除链表中第一个节点,并返回该节点,时间 O(1)
    public Node removeFirst() {
        if (head.next == tail)
            return null;
        Node first = head.next;
        remove(first);
        return first;
    }

    // 返回链表长度,时间 O(1)
    public int size() { 
    	return this.size; 
    }
}

到这里就能回答刚才「为什么必须要用双向链表」的问题了,因为我们需要删除操作。删除一个节点不光要得到该节点本身的指针,也需要操作其前驱节点的指针,而双向链表才能支持直接查找前驱,保证操作的时间复杂度 O(1)。

其中:addLast(Node x)、remove(Node node)、removeFirst()三个方法的示意图如下:

注意我们实现的双链表 API 只能从尾部插入,也就是说靠尾部的数据是最近使用的,靠头部的数据是最久为使用的。

有了双向链表的实现,我们只需要在 LRU 算法中把它和哈希表结合起来即可,先搭出代码框架:

class LRUCache {
    // key -> Node(key, val)
    private HashMap<Integer, Node> map;
    // Node(k1, v1) <-> Node(k2, v2)...
    private DoubleList cache;
    // 最大容量
    private int cap;

    public LRUCache(int capacity) {
        this.cap = capacity;
        map = new HashMap<>();
        cache = new DoubleList();
    }
  	
  	/* 将某个 key 提升为最近使用的 */
    private void makeRecently(int key) {
        Node x = map.get(key);
        // 先从链表中删除这个节点
        cache.remove(x);
        // 重新插到队尾
        cache.addLast(x);
    }

    /* 添加最近使用的元素 */
    private void addRecently(int key, int val) {
        Node x = new Node(key, val);
        // 链表尾部就是最近使用的元素
        cache.addLast(x);
        // 别忘了在 map 中添加 key 的映射
        map.put(key, x);
    }

    /* 删除某一个 key */
    private void deleteKey(int key) {
        Node x = map.get(key);
        // 从链表中删除
        cache.remove(x);
        // 从 map 中删除
        map.remove(key);
    }

    /* 删除最久未使用的元素 */
    private void removeLeastRecently() {
        // 链表头部的第一个元素就是最久未使用的
        Node deletedNode = cache.removeFirst();
        // 同时别忘了从 map 中删除它的 key
        int deletedKey = deletedNode.key;
        map.remove(deletedKey);
    }
}

这里就能回答之前的问答题「为什么要在链表中同时存储 key 和 val,而不是只存储 val」,注意 removeLeastRecently 函数中,我们需要用 deletedNode 得到 deletedKey

也就是说,当缓存容量已满,我们不仅仅要删除最后一个 Node 节点,还要把 map 中映射到该节点的 key 同时删除,而这个 key 只能由 Node 得到。如果 Node 结构中只存储 val,那么我们就无法得知 key 是什么,就无法删除 map 中的键,造成错误。

上述方法就是简单的操作封装,调用这些函数可以避免直接操作 cache 链表和 map 哈希表,下面我先来实现 LRU 算法的 get 方法:

public int get(int key) {
    if (!map.containsKey(key)) {
        return -1;
    }
    // 将该数据提升为最近使用的
    makeRecently(key);
    return map.get(key).val;
}

put 方法稍微复杂一些,我们先来画个图搞清楚它的逻辑:

这样我们可以轻松写出 put 方法的代码:

public void put(int key, int val) {
    if (map.containsKey(key)) {
        // 删除旧的数据
        deleteKey(key);
        // 新插入的数据为最近使用的数据
        addRecently(key, val);
        return;
    }
    if (cap == cache.size()) {
        // 删除最久未使用的元素
        removeLeastRecently();
    }
    // 添加为最近使用的元素
    addRecently(key, val);
}

至此,你应该已经完全掌握 LRU 算法的原理和实现了,

解题

我们最后用 Java 的内置类型 LinkedHashMap 来实现 LRU 算法,逻辑和之前完全一致:

class LRUCache {

    int capacity;
    LinkedHashMap<Integer, Integer> cache = new LinkedHashMap<>();

    public LRUCache(int capacity) {
        this.capacity = capacity;
    }
    
    public int get(int key) {
        if(!cache.containsKey(key)){
            return -1;
        }
        makeRecently(key);
        return cache.get(key);
    }
    
    public void put(int key, int value) {
        if(cache.containsKey(key)){
            cache.put(key, value);  // 修改 key 的值
            makeRecently(key);
            return;
        }
        if(cache.size() >= this.capacity){
          	// 链表头部就是最久未使用的 key
            int oldestKey = cache.keySet().iterator().next();
            cache.remove(oldestKey);
        }
      	// 将新的 key 添加链表尾部
        cache.put(key, value);
    }

    private void makeRecently(int key){
        int val = cache.get(key);
      	// 删除 key,重新插入到队尾
        cache.remove(key);
        cache.put(key, val);
    }
}
  • 时间复杂度:对于 put 和 get 都是 O(1)
  • 空间复杂度:O(capacity),因为哈希表和双向链表最多存储capacity+1 个元素。

参考文章

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值