146. LRU 缓存

146. LRU 缓存

!abstract 一句话本质 LRU = 哈希表提供"按 key 找到节点"的 O(1),双向链表提供"移动节点 / 淘汰尾部"的 O(1) 。两者缺一不可:哈希表管"查",链表管"顺序"。核心数据结构是 Map<key, 链表节点引用> + 环形 dummy 双向链表。
!quote 链接

题目

实现一个容量有限的缓存,支持 get(key) 和 put(key, value),两者都要 O(1) 。容量满时,淘汰"最久未使用"的项。get 也算一次使用,会刷新该项的"最近使用"地位。

思路(从暴力到最优)

为什么必须用"哈希表 + 双向链表"两个结构?

要拿下 O(1),拆成两个需求:

  • 查 :get(key) 要 O(1) 找到节点 → 哈希表。
  • 移 :每次访问要把节点"拎出来 → 移到头部",满了还要删尾部 → 需要能 O(1) 删除中间节点。
    • 数组不行:删中间元素要搬后面所有元素,O(n)。
    • 单向链表不行:删节点要找到它的前驱,单向只能从头遍历,O(n)。
    • 双向链表 :每个节点有 prev 和 next,删中间节点改两根指针即可,O(1)。

关键设计:哈希表里存什么?节点里为什么要存 key?

  • 哈希表 value 存链表节点的引用 (不是存 value)。这样 get(key) 拿到引用,立刻知道它在链表里的位置,才能 O(1) 移到头部。
  • 链表的节点要额外存 key 。因为淘汰时手里只有"最久未用的那个节点",要能从 map 里删掉对应记录,必须有 key 才能 map.delete(node.key)。否则 map 里留下"幽灵数据"。

环形 dummy 哨兵:免判空的技巧

用一个 dummy 节点,初始化时 dummy.prev = dummy、dummy.next = dummy 首尾接成环。真实节点都插在 dummy 后面。

  • dummy.next = 最近使用,dummy.prev = 最久未用。
  • 为什么不用判空 :链表为空时 dummy 自环、结构合法;只剩一个真实节点时 dummy.next = node、dummy.prev = node,删掉它后 node 的 prev/next 都指回 dummy,链表变空但环依然成立。所以 remove / pushFront 永远在环上改指针,边界的前驱后继永远是有效节点,永远不为 null。

关键卡点

!warning 我当时卡在这

  • map 里到底存 value 还是存节点引用? 存 value 拿到的是数据不是位置,没法 O(1) 移到头部。必须 Map<key, ListNode>。
  • 节点里为什么还要存 key? 淘汰时手里只有链表节点,没有 key 就删不掉 map 里那条记录。
  • 环形 dummy 的价值:让 remove / pushFront 免判空,边界永远落在 dummy 上,不用处理"链表为空"和"删最后一个节点"两个特例。
  • #getNode 有副作用:它不只查节点,还顺手把节点移到头部。考题里 get 本身就是一次访问,刷新位置是符合语义的,不是 bug。
  • 容量判断直接用 keyToNode.size > capacity,不用自己维护 this.size(省得在多处 ++/-- 漏一处)。

代码

javascript 复制代码
class Node {
    constructor(key = 0, value = 0) {
        this.key = key;
        this.value = value;
        this.prev = null;
        this.next = null;
    }
}

class LRUCache {
    constructor(capacity) {
        this.capacity = capacity;
        this.dummy = new Node();          // 环形哨兵
        this.dummy.prev = this.dummy;     // 首尾相接成环,免判空
        this.dummy.next = this.dummy;
        this.keyToNode = new Map();       // key -> 节点引用
    }

    // 取节点 + 顺手移到头部;不存在返回 null
    #getNode(key) {
        if (!this.keyToNode.has(key)) return null;
        const node = this.keyToNode.get(key);
        this.#remove(node);
        this.#pushFront(node);
        return node;
    }

    // 把 node 从链表中拎出来(前后邻居互相跳过它)
    #remove(node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    // 把 node 插到 dummy 后面(成为最近使用)
    #pushFront(node) {
        node.prev = this.dummy;
        node.next = this.dummy.next;
        node.prev.next = node;
        node.next.prev = node;
    }

    get(key) {
        const node = this.#getNode(key);
        return node ? node.value : -1;
    }

    put(key, value) {
        let node = this.#getNode(key);   // 命中:已移到头部,只改 value
        if (node) {
            node.value = value;
            return;
        }
        node = new Node(key, value);
        this.keyToNode.set(key, node);
        this.#pushFront(node);
        if (this.keyToNode.size > this.capacity) {
            const backNode = this.dummy.prev;   // 最久未用 = 尾部
            this.keyToNode.delete(backNode.key); // 靠节点里的 key 清理 map
            this.#remove(backNode);
        }
    }
}

复杂度

  • 时间:O(1) ------ get 和 put 都是 Map 查询 O(1) + 链表改指针 O(1),满了再淘汰 O(1)。
  • 空间:O(capacity) ------ map 和链表各存 capacity 个节点。

一句话记忆点

!tip 下次看到类似题 "O(1) 查 + O(1) 移顺序/淘汰" → 哈希表(查) + 双向链表(移),map 存节点引用,节点存 key 反向清理,环形 dummy 免判空。 看到"缓存 + 淘汰策略"就想到 LRU 模板。

相似题 / 套用模板

    1. LFU 缓存(在这基础上加"每个频率一个 LRU 链表",更进阶)
  • 任何"既要哈希表又要维护访问顺序"的设计题,核心都是这个双向链表 + 哈希表联动的模板。
相关推荐
Nil2085 小时前
leetcode 118杨辉三角
算法·leetcode·职场和发展
wanderist.8 小时前
线性筛法详解:从筛质数到欧拉函数、Möbius 函数与约数函数
java·数据结构·算法
All for pursuit.8 小时前
【设计-1】208.实现Trie (前缀树)
数据结构·c++·算法·leetcode
weixin_307779138 小时前
C++代码实现MATLAB中的crossval函数功能
开发语言·c++·算法·matlab
青少儿编程课堂8 小时前
背包问题(0/1 背包与完全背包)解题精讲——动态规划入门
c++·python·算法·bfs·信息学竞赛
光影少年9 小时前
为什么 JavaScript 中 0.1 + 0.2 !== 0.3,如何让其相等?
前端·javascript·算法
HEJOO99 小时前
Linux 设备驱动系列——使用全局工作队列
java·linux·算法
Code_Solitude9 小时前
c语言:递归
java·c语言·算法
edtoplort10 小时前
OpenAI紧急叫停GPT-6.1 Astra训练,奥尔特曼为何踩刹车
人工智能·gpt·算法
蒜香可达鸭10 小时前
让视频生成流水线跑不崩:一个事件溯源的多 Agent 可靠执行框架
算法