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)。
    • 双向链表 :每个节点有 prevnext,删中间节点改两根指针即可,O(1)。

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

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

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

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

  • dummy.next = 最近使用,dummy.prev = 最久未用。
  • 为什么不用判空 :链表为空时 dummy 自环、结构合法;只剩一个真实节点时 dummy.next = nodedummy.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) ------ getput 都是 Map 查询 O(1) + 链表改指针 O(1),满了再淘汰 O(1)。
  • 空间:O(capacity) ------ map 和链表各存 capacity 个节点。

一句话记忆点

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

相似题 / 套用模板

    1. LFU 缓存(在这基础上加"每个频率一个 LRU 链表",更进阶)
  • 任何"既要哈希表又要维护访问顺序"的设计题,核心都是这个双向链表 + 哈希表联动的模板。
相关推荐
Asize1 小时前
543. 二叉树的直径
算法
lemon_sjdk1 小时前
ObjectProperty
java·开发语言·算法
Zentceh1 小时前
AI-ISP在夜视机芯中的应用:从传统ISP到PixelClean全彩夜视的进化
人工智能·科技·算法·计算机视觉·车载系统·视频·智能硬件
xier_ran2 小时前
【infra之路】AWQ 详解:激活感知权重保护,让 W4A16 量化精度超越 GPTQ
线性代数·算法·机器学习·量化·infra
亦皓ai2 小时前
AI时代后端工程(二):AI把代码写得越来越快,我却越来越不敢让它直接开工了
人工智能·算法·机器学习·搜索引擎·transformer
玖玥拾3 小时前
LeetCode 392 判断子序列
笔记·算法·leetcode
重生之后端学习5 小时前
239. 滑动窗口最大值[困难]✅
java·数据结构·算法·leetcode·职场和发展
fpcc5 小时前
算法和数据结构—动态规划法
数据结构·算法·动态规划
星星.7228 小时前
C++算法竞赛|二分查找与二分答案:边界模板、浮点二分、STL
数据结构·c++·算法