[LeetCode 146 LRU Cache] 最近最少使用缓存(一) | 刷题打卡

708 阅读4分钟

题目描述

先来看看题目描述:

运用你所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制 。实现 LRUCache 类:

  • LRUCache(int capacity) 以正整数作为容量 capacity 初始化 LRU 缓存。
  • int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
  • void put(int key, int value) 如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字-值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。

进阶:你是否可以在 O(1) 时间复杂度内完成这两种操作?

思路分析

本文会提到两种解法,一种是直接继承 LinkedHashMap,另一种是哈希表+双向链表

解法一:

如果有熟悉 LinkedHashMap 源码的朋友,相信很快就能想到该题实际上用 LinkedHashMap 就能实现,因为它提供两种顺序排列,一种是按照插入的顺序,一种是按照读取的顺序(就是本题)。其内部是靠建立一个双向链表来维护这个顺序的,每次的插入、删除后都会调用一个函数来进行双向链表的维护,准确的来说,是有三个函数来做这件事,这三个函数都统称为 回调函数 ,这三个函数分别是:

  • void afterNodeAccess(Node<K,V> p) { } 其作用就是在访问元素之后,将该元素放到双向链表的尾巴处(所以这个函数只有在按照读取的顺序的时候才会执行)。

  • void afterNodeRemoval(Node<K,V> p) { } 其作用就是在删除元素之后,将元素从双向链表中删除。

  • void afterNodeInsertion(boolean evict) { } 这个才是我们题目中会用到的,在插入新元素之后,需要回调函数判断是否需要移除一直不用的某些元素!

    void afterNodeInsertion(boolean evict) { // possibly remove eldest
        LinkedHashMap.Entry<K,V> first;
        // 根据条件判断是否移除最近最少被访问的节点
        if (evict && (first = head) != null && removeEldestEntry(first)) {
            K key = first.key;
            removeNode(hash(key), key, null, false, true);
        }
    }
    

所以说我们可以继承 LinkedHashMap,对于 put 函数题目没有特殊要求,所以可以不写;而对于 get 函数,如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 ,所以我们可以调用 LinkedHashMap 中的 getOrDefault(),完美符合这个要求,即当 key 不存在时会返回默认值 -1;还需要写一个构造函数,调用super(capacity, 0.75F, true); 代码实现如下:

class LRUCache extends LinkedHashMap<Integer, Integer>{
    private int capacity;
    
    public LRUCache(int capacity) {
        super(capacity, 0.75F, true);
        this.capacity = capacity;
    }

    public int get(int key) {
        return super.getOrDefault(key, -1);
    }

    //可以不写
    public void put(int key, int value) {
        super.put(key, value);
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
        return size() > capacity; 
    }
}
解法二:

哈希表 + 双向链表

LRU 缓存机制可以通过哈希表辅以双向链表实现,我们用一个哈希表和一个双向链表维护所有在缓存中的键值对。

双向链表按照被使用的顺序存储了这些键值对,靠近头部的键值对是最近使用的,而靠近尾部的键值对是最久未使用的。

哈希表即为普通的哈希映射(HashMap),通过缓存数据的键映射到其在双向链表中的位置。

这样以来,我们首先使用哈希表进行定位,找出缓存项在双向链表中的位置,随后将其移动到双向链表的头部,即可在 O(1) 的时间内完成 get 或者 put 操作。

注意:在双向链表的实现中,使用一个伪头部和伪尾部标记界限,这样在添加节点和删除节点的时候就不需要检查相邻的节点是否存在。

代码如下:

class LRUCache {

    class DLinkedNode{
        int key;
        int value;

        DLinkedNode prev;
        DLinkedNode next;
        public DLinkedNode(){}
        public DLinkedNode(int key,int value){
            this.key = key;
            this.value = value;
        }
    }

    private Map<Integer,DLinkedNode> cache = new HashMap<Integer,DLinkedNode>();
    private int size;
    private int capacity;
    private DLinkedNode head,tail;

    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.size = 0;
        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;
        }
        //如果 key 存在,先通过哈希表定位,再移到头部
        moveToHead(node);
        return node.value;

    }
    
    public void put(int key, int value) {
        DLinkedNode node = cache.get(key);
        if(node == null){
            //如果 key 不存在,创建一个新的节点
            DLinkedNode newNode = new DLinkedNode(key,value);
            //添加至哈希表
            cache.put(key,newNode);
            //添加至双向链表的头部
            addToHead(newNode);
            ++size;
            //如果超出容量,删除双向链表的尾部节点,同时删除哈希表中对应的项
            if(size > capacity){
                DLinkedNode tail = removeTail();
                cache.remove(tail.key);
                --size;
            }

        } else {
            //如果 key 存在,先通过哈希表定位,再修改 value(覆盖值),并转移到头部
            node.value = value;
            moveToHead(node);
        }
    }

    private void addToHead(DLinkedNode node){
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }

    private void moveToHead(DLinkedNode node){
        removeNode(node);
        addToHead(node);
    }

    private DLinkedNode removeTail(){
        DLinkedNode res = tail.prev;
        removeNode(res);
        return res;
    }

    private void removeNode(DLinkedNode node){
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }


}

/**
 * Your LRUCache object will be instantiated and called as such:
 * LRUCache obj = new LRUCache(capacity);
 * int param_1 = obj.get(key);
 * obj.put(key,value);
 */

总结

面试官考察面试者 LRU Cache 时,肯定是希望面试者手写 LRU 的实现,而不是依赖于 LinkedHashMap,同时伪头部和伪尾部的节点标记界限也很重要,简化了处理。