146. LRU 缓存

146. LRU 缓存

中等

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

实现 LRUCache 类:

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

函数 getput 必须以 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]

解释
LRUCache lRUCache = new LRUCache(2);
lRUCache.put(1, 1); // 缓存是 {1=1}
lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
lRUCache.get(1);    // 返回 1
lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3}
lRUCache.get(2);    // 返回 -1 (未找到)
lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
lRUCache.get(1);    // 返回 -1 (未找到)
lRUCache.get(3);    // 返回 3
lRUCache.get(4);    // 返回 4

提示:

  • 1 <= capacity <= 3000
  • 0 <= key <= 10000
  • 0 <= value <= 105
  • 最多调用 2 * 105getput

📝 核心笔记:LRU (HashMap + Doubly Linked List)

1. 核心思想 (一句话总结)

"哈希表负责找人(O(1)),链表负责排队(O(1))。"

  • HashMap :存储 Key -> Node 的映射。让我们能瞬间找到这一页书夹在队伍的哪个位置。
  • Double Linked List:维护书籍的"新鲜度"。
    • 头部 ( dummy.next**)**:最新访问的 (MRU)。
    • 尾部 ( dummy.prev**)**:最久未访问的 (LRU)。
    • 哨兵 ( dummy**)**:连接头尾,形成环,避免处理复杂的边界(如空链表)。
2. 您的代码亮点 (单哨兵循环链表)

普通的写法通常定义 headtail 两个哨兵,但您用了一个 dummy 把它们连成了一个圈:

  • 虚拟头dummy.next 指向真正的第一个节点。
  • 虚拟尾dummy.prev 指向真正的最后一个节点。
  • 初始状态dummy 的前驱和后继都指向自己。
3. 算法流程 (书堆操作)
  1. GET (查书)
    • 去 Map 里找,找不到返回 -1。
    • 找到了,先把书从老位置抽出来 (remove)。
    • 再把书放到最上面 (pushFront)。
  1. PUT (放书)
    • 书已存在:抽出来,更新内容,放回最上面。
    • 书不存在
      • 新做一本书。
      • 放到最上面 (pushFront)。
      • 检查容量 :如果书太多了,把最底下的那本 (dummy.prev) 扔掉 (remove),记得同时在 Map 里注销。
🔍 代码回忆清单
复制代码
// 题目:LC 146. LRU Cache (手写双向链表版)
class LRUCache {
    // 1. 定义双向链表节点
    private static class Node {
        int key, value;
        Node prev, next;
        Node(int k, int v) { key = k; value = v; }
    }

    private final int capacity;
    // 2. 核心技巧:一个哨兵形成的循环链表
    // dummy.next 是 MRU (最新),dummy.prev 是 LRU (最老)
    private final Node dummy = new Node(0, 0); 
    private final Map<Integer, Node> keyToNode = new HashMap<>();

    public LRUCache(int capacity) {
        this.capacity = capacity;
        // 初始化:自己指自己,形成闭环
        dummy.prev = dummy;
        dummy.next = dummy;
    }

    public int get(int key) {
        Node node = getNode(key); // 辅助函数处理了"移到头部"的逻辑
        return node != null ? node.value : -1;
    }

    public void put(int key, int value) {
        Node node = getNode(key);
        if (node != null) {
            node.value = value; // 更新值,位置已经在 getNode 里调整过了
            return;
        }
        
        // 新增节点
        node = new Node(key, value);
        keyToNode.put(key, node);
        pushFront(node); // 放进缓存,置于头部
        
        // 容量检查
        if (keyToNode.size() > capacity) {
            Node backNode = dummy.prev; // 倒数第一个节点 (最久未用)
            keyToNode.remove(backNode.key); // 别忘了删哈希表
            remove(backNode); // 删链表
        }
    }

    // 辅助函数:获取节点并"刷新"它的位置
    private Node getNode(int key) {
        if (!keyToNode.containsKey(key)) return null;
        Node node = keyToNode.get(key);
        remove(node);    // 1. 先断开
        pushFront(node); // 2. 再插到头
        return node;
    }

    // 链表操作:从当前位置移除 (断链)
    private void remove(Node x) {
        x.prev.next = x.next;
        x.next.prev = x.prev;
    }

    // 链表操作:插到哨兵后面 (头插法)
    private void pushFront(Node x) {
        x.prev = dummy;
        x.next = dummy.next;
        // 下面两步顺序不能反,先连后面,再连前面
        x.prev.next = x; // 也就是 dummy.next = x
        x.next.prev = x; // 把原来的头节点的 prev 指向 x
    }
}
⚡ 快速复习 CheckList (易错点)
  • \] **为什么 Node 里要存 key?**

    • 非常关键 。当缓存满了需要淘汰链表尾部节点 (backNode) 时,我们手里只有这个 Node 对象。我们必须通过 backNode.key 去把 HashMap 里的记录也删掉。如果 Node 里没存 key,就没法反向操作 Map 了。
  • \] **指针操作顺序?**

    • pushFront 中,最容易写错指针赋值顺序。
    • 口诀 :先处理新节点 (x) 的左右手,再让两边的"老人"牵住 x
  • \] **dummy 的环状初始化?**

    • 构造函数里必须写 dummy.prev = dummy; dummy.next = dummy;。否则第一次 put 时,dummy.next 是 null,访问 dummy.next.prev 就会报空指针。
🖼️ 数字演练

Capacity = 2.

  1. Init : dummy <-> dummy.
  2. put(1, 1):
    • New Node(1). pushFront.
    • Structure: dummy <-> Node(1) <-> dummy.
  1. put(2, 2):
    • New Node(2). pushFront.
    • Structure: dummy <-> Node(2) <-> Node(1) <-> dummy. (2 是头,1 被挤到尾)
  1. get(1):
    • Find(1). remove(1). pushFront(1).
    • Structure: dummy <-> Node(1) <-> Node(2) <-> dummy. (1 变成头了)
  1. put(3, 3):
    • Size > Cap. backNode is dummy.prev (which is 2).
    • keyToNode.remove(2). remove(Node 2).
    • Add 3.
    • Structure: dummy <-> Node(3) <-> Node(1) <-> dummy.

📝 核心笔记:LRU 缓存 (Least Recently Used)

1. 核心思想 (一句话总结)

"越常用的越靠后,队头的都是没人要的。"

LRU 的核心是维护一个有序列表:

  • 刚被访问/修改过 的元素,移到列表 尾部 (MRU - Most Recently Used)
  • 很久没动过 的元素,自然沉底到列表 头部 (LRU - Least Recently Used)
  • 当容量满了,直接砍掉 头部 的元素。
2. 您的代码逻辑 (手动维护顺序)

您没有使用 LinkedHashMap(cap, load, true) 开启自动访问排序,而是手动操作:

  • GET : 只要读取 key,就把它从原位置删掉 (remove),再重新插进去 (put)。这会导致它变成"最新插入"的,自动跑到队尾。
  • PUT:
    • 如果 key 存在:先删后加(更新值 + 移到队尾)。
    • 如果 key 不存在:
      • 检查容量。如果满了,通过 iterator().next() 拿到 队头 (最早插入/最久未更新) 的元素并删除。
      • 插入新 key (自动在队尾)。
🔍 代码回忆清单
复制代码
// 题目:LC 146. LRU Cache
class LRUCache {
    private final int capacity;
    // 这里的 LinkedHashMap 默认是"插入顺序"
    // 我们通过代码逻辑把它变成了"访问顺序"
    private final Map<Integer, Integer> cache = new LinkedHashMap<>(); 

    public LRUCache(int capacity) {
        this.capacity = capacity;
    }

    public int get(int key) {
        // 技巧:remove 返回 null 说明 key 不存在,返回 value 说明存在
        // 这一步既判断了存在性,又把旧位置的节点删了
        Integer value = cache.remove(key);
        
        if (value != null) { 
            // 重新 put,这会让该节点跑到链表末尾 (变成最新的)
            cache.put(key, value);
            return value;
        }
        return -1;
    }

    public void put(int key, int value) {
        // 1. 如果存在,先删掉 (为了稍后重新插入以更新位置)
        if (cache.remove(key) != null) { 
            cache.put(key, value); // 更新值并移到末尾
            return;
        }
        
        // 2. 如果不存在,判断是否要腾位置
        if (cache.size() == capacity) { 
            // iterator().next() 获取的是链表头部 (最老的元素)
            Integer eldestKey = cache.keySet().iterator().next();
            cache.remove(eldestKey); 
        }
        
        // 3. 插入新元素 (默认在链表末尾)
        cache.put(key, value);
    }
}
⚠️ 面试高频预警 (Hard Core Mode)

但 LRU 是各大厂面试中 最爱考手写原理 的题目之一。面试官极大概率会说:

"请不要使用 LinkedHashMap,请使用 HashMap + 双向链表 (Double Linked List) 从零实现。"

如果遇到这种情况,您需要构建这样的结构:

  1. Node 类 :包含 key, val, prev, next
  2. HashMap :存储 <Integer, Node>,用于 O(1) 查找 Node。
  3. DoubleLinkedList
    • 虚拟头尾 (dummyHead, dummyTail):避免处理 null 指针。
    • moveToTail(Node node):将节点移到尾部。
    • removeNode(Node node):删除任意节点。
    • addToTail(Node node):在尾部添加节点。
    • removeFirst():删除头部的节点(淘汰 LRU)。
⚡ 快速复习 CheckList (LinkedHashMap 版)
  • \] **为什么** **iterator().next()****是最老的?**

    • 因为 LinkedHashMap 默认维护插入顺序。你每次 getput 更新时都执行了 remove + put,相当于把该元素变成了"最新插入"。那么一直没被动过的元素自然就留在了迭代器的最前面。
  • \] **有没有更"Java"的写法?**

    • 有。继承 LinkedHashMap 并重写 removeEldestEntry 方法。

      class LRUCache extends LinkedHashMap<Integer, Integer> {
      private int capacity;
      public LRUCache(int capacity) {
      super(capacity, 0.75F, true); // true 表示开启访问排序
      this.capacity = capacity;
      }
      @Override
      protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
      return size() > capacity; // 自动淘汰
      }
      // get 和 put 甚至不需要重写
      }

    • 但在面试中,您的写法比这种继承写法更好,因为它展示了您懂 LRU 的内部逻辑,而不是单纯背 API。
🖼️ 数字演练

Capacity = 2.

  1. put(1, 1) : Cache: [1]
  2. put(2, 2) : Cache: [1, 2] (2 是最新的)
  3. get(1):
    • Remove 1 -> Cache: [2]
    • Put 1 -> Cache: [2, 1] (1 变成最新的了)
  1. put(3, 3):
    • Full! Remove head (2).
    • Put 3.
    • Cache: [1, 3].
相关推荐
程曦曦2 小时前
原地删除有序数组重复项:双指针法的艺术与实现
数据结构·算法·leetcode
萧曵 丶2 小时前
懒加载单例模式中DCL方式和原理解析
java·开发语言·单例模式·dcl
你怎么知道我是队长2 小时前
C语言---排序算法6---递归归并排序法
c语言·算法·排序算法
回忆是昨天里的海2 小时前
k8s部署的微服务动态扩容
java·运维·kubernetes
萧曵 丶2 小时前
单例模式 7 种实现方式对比表
java·单例模式
智驱力人工智能2 小时前
景区节假日车流实时预警平台 从拥堵治理到体验升级的工程实践 车流量检测 城市路口车流量信号优化方案 学校周边车流量安全分析方案
人工智能·opencv·算法·安全·yolo·边缘计算
lang201509282 小时前
Tomcat Maven插件全解析:开发部署一体化
java·tomcat·maven
MicroTech20252 小时前
微算法科技(NASDAQ :MLGO)抗量子攻击区块链共识机制:通过量子纠缠态优化节点验证流程,降低计算复杂度
科技·算法·区块链
pp起床2 小时前
贪心算法 | part01
算法·贪心算法