LRU缓存 M146

150 阅读6分钟

LRU缓存 M146

1 题目描述

请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。

实现 LRUCache 类:

  • LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存
  • int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
  • void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该 逐出 最久未使用的关键字。

函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。

🌸「示例:」

输入
["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]

输出
[null, null, null, 1, null, -1, null, -1, 3, 4]

2 解答

解法一:自己造轮子

🌸 「分析一下:」

  • 构建双链表的节点类 Node
  • 依靠 Node 类构建一个双链表,实现几个 LRU 算法必须的API
    • 在链表尾部添加节点 x,时间O(1)
    • 删除链表中的 x 结点,时间O(1)
    • 删除链表中第一个结点,并返回该节点,时间O(1)
    • 返回链表长度,时间O(1)

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

  • 有了双向链表的实现,我们只需要在 LRU 算法中把它和哈希表结合起来即可
  • 尽量让 LRU 的主⽅法 getput 避免直接操作 mapcache 的细节。我们可以先实现下⾯⼏个函数
    • 将某个 key 提升为最近使用的 makeRecently(int key)
    • 添加最近使用的元素 addRecently(int key, int val)
    • 删除某一个 key (非必要)
    • 删除最久未使用的元素 removeLeastRecently()

最后

  • get(int key)

  • put(int key, int value)

image.png

🌸 「实现:」

public class M146 {

    public static void main(String[] args) {

        //["LRUCache","put","put","put","put","get","get"]
        //[[2],[2,1],[1,1],[2,3],[4,1],[1],[2]]
        LRUCache lruCache = new LRUCache(2);
        lruCache.put(2, 1);
        lruCache.put(1, 1);
        lruCache.put(2, 3);
        lruCache.put(4, 1);
        lruCache.get(1);
        lruCache.get(2);

        HashMap<Integer, Node> map = lruCache.map;
        System.out.println(map.keySet());
    }
}


// LRU 缓存算法的核⼼数据结构就是哈希链表,双向链表和哈希表的结合体。
class Node{
    public int key, val;

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

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

    public DoubleList() {
        head = new Node(0, 0);
        tail = new Node(0, 0);
        size = 0;
        head.next = tail;
        tail.prev = head;
    }

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

        tail.prev.next = node;
        tail.prev = node;
        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 == null) return null;

        Node first = head.next;
        remove(first);
        return first;
    }

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

}


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

    // ⾸先要接收⼀个 capacity 参数作为缓存的最⼤容量,然后实现两个 API
    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 x = cache.removeFirst();
        // 同时别忘了从 map 中删除它的 key
        map.remove(x.key);
    }



    // 另⼀个是 get(key) ⽅法获取 key 对应的 val,如果 key 不存在则返回 -1。
    public int get(int key) {
        if (!map.containsKey(key)){
            return -1;
        }
        Node x = map.get(key);
        makeRecently(x.key);
        return map.get(key).val;
    }

    // ⼀个是 put(key, val) ⽅法存 ⼊键值对
    // 较复杂
    public void put(int key, int value) {
        // 若key 已存在
        if (map.containsKey(key)){
            //// 删除旧的数据
            //deleteKey(key);
            //// 新插⼊的数据为最近使⽤的数据
            //addRecently(key, value);
            //return;

            // 修改值
            map.get(key).val = value;
            // 将key 提升为最近使用
            makeRecently(key);
            return;
        }

        if (cap == cache.size()){
            // 淘汰最久未使用的key
            removeLeastRecently();
        }
        addRecently(key, value);
    }
}

/**
 * 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);
 */

解法二:使用 LinkedHashMap

🌸 「实现:」

class LRUCache2 {
    int cap;
    LinkedHashMap<Integer, Integer> cache = new LinkedHashMap<>();

    private void makeRecently(int key) {
        Integer value = cache.get(key);
        //System.out.println("makeRecently.key=" + value);
        // 删除key,重新插入到队尾
        cache.remove(key);
        cache.put(key, value);
    }

    public LRUCache2(int capacity) {
        this.cap = 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)){
            // 修改key值
            cache.put(key, value);
            // 将key变为最近使用
            makeRecently(key);
            return;
        }

        if (cache.size() > this.cap){
            Integer oldestKey = cache.keySet().iterator().next();
            cache.remove(oldestKey);
        }
        cache.put(key, value);
    }
}

3 LinkedHashMap

LinkedHashMap 继承自 HashMap,在 HashMap 基础上,通过维护一条双向链表,解决了 HashMap 不能随时保持遍历顺序和插入顺序一致的问题。

除此之外,LinkedHashMap 对访问顺序也提供了相关支持。在一些场景下,该特性很有用,比如缓存。

在实现上,LinkedHashMap 很多方法直接继承自 HashMap,仅为维护双向链表覆写了部分方法。所以,要看懂 LinkedHashMap 的源码,需要先看懂 HashMap 的源码。

增加了一条双向链表,使得可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。

image.png

  • 每当有新键值对节点插入,新节点最终会接在 tail 引用指向的节点后面。而 tail 引用则会移动到新的节点上,这样一个双向链表就建立起来了。
  • 上面的结构并不是很难理解,虽然引入了红黑树,导致结构看起来略为复杂了一些。但大家完全可以忽略红黑树,而只关注链表结构本身。

3.1 Entry 的继承体系

分析一下键值对节点的继承体系。

image.png

3.2 链表的建立过程

链表的建立过程是在插入键值对节点时开始的,初始情况下,让 LinkedHashMapheadtail 引用同时指向新节点,链表就算建立起来了。

随后不断有新节点插入,通过将新节点接在 tail 引用指向节点的后面,即可实现链表的更新。

Map 类型的集合类是通过 put(K,V) 方法插入键值对,LinkedHashMap 本身并没有覆写父类的 put 方法,而是直接使用了父类的实现。

// HashMap 中实现
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

// HashMap 中实现
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0) {...}
    // 通过节点 hash 定位节点所在的桶位置,并检测桶中是否包含节点引用
    if ((p = tab[i = (n - 1) & hash]) == null) {...}
    else {
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode) {...}
        else {
            // 遍历链表,并统计链表长度
            for (int binCount = 0; ; ++binCount) {
                // 未在单链表中找到要插入的节点,将新节点接在单链表的后面
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) {...}
                    break;
                }
                // 插入的节点已经存在于单链表中
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null) {...}
            afterNodeAccess(e);    // 回调方法,后续说明
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold) {...}
    afterNodeInsertion(evict);    // 回调方法,后续说明
    return null;
}

// HashMap 中实现
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
    return new Node<>(hash, key, value, next);
}

// LinkedHashMap 中覆写
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
    LinkedHashMap.Entry<K,V> p =
        new LinkedHashMap.Entry<K,V>(hash, key, value, e);
    // 将 Entry 接在双向链表的尾部
    linkNodeLast(p);
    return p;
}

// LinkedHashMap 中实现
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    LinkedHashMap.Entry<K,V> last = tail;
    tail = p;
    // last 为 null,表明链表还未建立
    if (last == null)
        head = p;
    else {
        // 将新节点 p 接在链表尾部
        p.before = last;
        last.after = p;
    }
}

image.png

LinkedHashMap 覆盖了 newNode 方法。在这个方法中,LinkedHashMap 创建了 Entry,并通过 linkNodeLast 方法将 Entry 接在双向链表的尾部,实现了双向链表的建立。

双向链表建立之后,我们就可以按照插入顺序去遍历 LinkedHashMap。

3.3 链表节点的删除过程

3.4 访问顺序的维护过程

3.5 实现缓存

内容待补充!!!

参考

leetcode.cn/problems/lr…

www.imooc.com/article/229…

juejin.cn/post/684490…