--- tags: ["LeetCode", "哈希表", "链表", "设计", "中等"] create time: 2026-05-18 16:00 --- # 35-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` ,则应该 **逐出(evict)** 最久未使用的关键字。 函数 `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] 解释 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 <= 10^5` - 最多调用 `2 * 10^5` 次 `get` 和 `put` > [!warning] ⚠️ 核心约束 > **O(1) 时间复杂度**——这是这道题的灵魂要求。任何涉及线性遍历的操作(如切片删除、遍历 map 找最旧元素)都会直接超时。这意味着每次操作都必须是"直接寻址"级别的速度。 --- ## 思路 > [!question] 💡 需求拆解 > LRU 缓存需要同时满足两个看似矛盾的要求: > > | 操作 | 需要什么能力 | 天然数据结构 | > |------|-------------|-------------| > | `get(key)` | **按 key 快速查找** → O(1) | 哈希表 ✅ | > | `put(key)` + 驱逐最老元素 | **维护访问顺序**,能在两端高效增删 | 双向链表 ✅ | 单一数据结构无法同时满足这两个需求: - **纯哈希表**:查找 O(1),但不知道哪个元素"最久未用"。 - **纯双向链表**:能维护顺序、两端增删都是 O(1),但查找需要遍历 O(n)。 **解法:哈希表 + 双向链表的组合!** 🎯 > [!tip] 🔑 为什么是"双向"链表而不是"单向"? > 在双向链表中删除任意节点只需要该节点的指针(因为既有 next 也有 prev)。但单向链表要删除节点 A,必须知道 A.prev——而 A 自身不持有这个引用。如果你只有 A 的指针,就得从头遍历找到 A.prev,这就变成了 O(n)。**O(1) 删除的前提是有 prev 指针。** ### 数据结构设计 ```mermaid flowchart TD subgraph HashMap["哈希表 map[key] -- Node"] H1["1 -> NodeA"] H2["3 -> NodeB"] end subgraph DoublyLinkedList["双向链表 (队首=最近使用 <-> 队尾=最久未用)"] DH["DummyHead"] --> NA["key=1 -- val=1"] NA <--> NB["key=3 -- val=3"] NB -.-> DT["nil (DummyTail)"] end H1 -.指向.-> NA H2 -.指向.-> NB style HashMap fill:#e8f4fd,stroke:#28a style DoublyLinkedList fill:#fff3cd,stroke:#28a style NA fill:#bbf,stroke:#333 style NB fill:#f9d,stroke:#333 ``` **约定:** - **链表头部(靠近 DummyHead)** = 最近使用的节点(most recent) - **链表尾部(靠近 DummyTail)** = 最久未使用的节点(least recent / victim) - DummyHead / DummyTail 是哨兵节点,让边界操作不需要特殊判空 ### 操作详解 #### 操作一:`get(key)` > [!question] 💡 思考一下 > 查到键后,除了返回值,还需要做什么才能让 LRU 性质维持不变? **流程:** ```mermaid flowchart TD START["get(key)"] --> LOOKUP{"map 中存在 key?"} LOOKUP -->|否| RETURN_NEG1["return -1"] LOOKUP -->|是| FOUND["从 map 拿到对应 Node 指针"] FOUND --> REMOVE["将 Node 从当前位置移除 removeNode(node)"] REMOVE --> ADD_HEAD["把 Node 移到队 head.add(node)"] ADD_HEAD --> RETURN_VAL["return node.val"] style FOUND fill:#bbf,stroke:#333 style REMOVE fill:#ffd700,stroke:#333 style ADD_HEAD fill:#f9d,stroke:#333 style RETURN_VAL fill:#d4edda,stroke:#28a ``` **逐步演示(capacity=2,当前缓存 `{1=1, 3=3}`,调用 `get(1)`):** ```mermaid flowchart LR subgraph BEFORE["get(1) 之前"] B1["head --> key=1--val=1 --> key=3--val=3 <-- tail"] end B1 --> REMOVE["① 摘下 key=1--val=1"] REMOVE --> MID["中间态: head --> key=3--val=3 <-- tail\n map 仍指向原 key=1--val=1 节点"] MID --> REINSERT["② 插入 head 之后"] REINSERT --> AFTER["③ 结果: head --> key=1--val=1 --> key=3--val=3 <-- tail\n 1 变成最近使用的"] style BEFORE fill:#ffd700,stroke:#333 style AFTER fill:#d4edda,stroke:#28a ``` **时间复杂度:O(1)** — map 查找 O(1) + 链表节点摘除/重插 O(1) #### 操作二:`put(key, value)` > [!question] 💡 put 有三种情况,你能枚举出来吗? **分三种情况处理:** ```mermaid flowchart TD START["putkey, value"] --> CHECK{"key 已存在?"} CHECK -->|是| UPDATE["更新值 node.val = value\n然后把 node 移到队首\n-- 因为它变最新了"] CHECK -->|否| CAPACITY{"已满?"} CAPACITY -->|是| EVICT["① 删除 tail 前面的节点(最久未用)\n② 从 map 中删除对应的 key\n③ 创建新节点插到队首\n④ map[key]=newNode"] CAPACITY -->|否| INSERT["① 创建新节点插到队首\n② map[key]=newNode"] UPDATE --> DONE["✅ 完成"] EVICT --> DONE INSERT --> DONE style UPDATE fill:#bbf,stroke:#333 style EVICT fill:#f9d,stroke:#333 style INSERT fill:#9df,stroke:#333 ``` **逐步演示(capacity=2,当前缓存 `{1=1, 2=2}`,调用 `put(3, 3)`):** ```mermaid flowchart LR S1["初始: {1=1, 2=2}\nhead --> key=1--val=1 --> key=2--val=2 <-- tail"] --> FULL{"容量满?"} FULL -->|是, key=3 不存在| EVICT["驱逐 tail 前节点 key=2--val=2\nmap 删除 key=2"] EVICT --> MID["临时: {1=1}\nhead --> key=1--val=1 <-- tail"] MID --> INSERT["插入 key=3--val=3 到队首"] INSERT --> RESULT["结果: {1=1, 3=3}\nhead --> key=3--val=3 --> key=1--val=1 <-- tail\n 3 变最新, 1 变最旧"] style S1 fill:#ffd700,stroke:#333 style RESULT fill:#d4edda,stroke:#28a ``` **时间复杂度:O(1)** — 所有步骤都是 map 查询/O(1) + 链表操作 O(1) ### 完整执行过程追踪(示例) ``` capacity = 2 ``` | 操作 | map[key→Node] | 链表 (head→tail) | 返回值 | |------|---------------|-----------------|--------| | `put(1,1)` | `{1→A}` | `[1,1]` | — | | `put(2,2)` | `{1→A, 2→B}` | `[1,1] ↔ [2,2]` | — | | `get(1)` | `{1→A, 2→B}` | `[1,1] ↔ [2,2]` *(1 移到头)* | **1** | | `put(3,3)` | `{1→A, 3→C}` *[驱逐 B]* | `[3,3] ↔ [1,1]` | — | | `get(2)` | — | — | **-1** *(2 不在 map 中)* | | `put(4,4)` | `{4→D, 3→C}` *[驱逐 A]* | `[4,4] ↔ [3,3]` | — | | `get(1)` | — | — | **-1** *(1 不在 map 中)* | | `get(3)` | `{4→D, 3→C}` | `[4,4] ↔ [3,3]` | **3** | | `get(4)` | `{4→D, 3→C}` | `[4,4] ↔ [3,3]` | **4** | ### 核心操作实现原理 #### 1. `removeNode(node)`:将节点从链表中的任意位置摘除 ```go // node.Prev 和 node.Next 都已正确指向它在链表中的邻居 node.Prev.Next = node.Next node.Next.Prev = node.Prev ``` #### 2. `addToHead(node)`:将节点插入到哨兵头部之后 ```go node.Prev = dummyHead node.Next = dummyHead.Next dummyHead.Next.Prev = node dummyHead.Next = node ``` #### 3. `removeTail()`:摘除尾部有效节点(即 DummyTail 前面那个) ```go removed := dummyTail.Prev // 最久未用的节点 removeNode(removed) // 从链表中摘除 return removed // 返回被驱逐的节点 ``` > [!info] 🧠 为什么加 DummyHead 和 DummyTail? > 如果没有哨兵节点,操作空链表或只有一个节点的链表时需要大量的 nil 判断,代码会变得冗长且容易出错。哨兵节点让这些边界情况自动正确处理——即使是空链表,dummyHead.next = dummyTail 也永远成立。 > [!success] ✅ 复杂度分析 | 操作 | 时间复杂度 | 空间复杂度 | |------|-----------|-----------| | `get(key)` | **O(1)** | — | | `put(key, value)` | **O(1)** | — | | 整体 | O(1) 均摊 | **O(capacity)** 存储 n 个节点 | --- ## 代码提示 ### LRU 缓存通用模板 ``` type Node struct { Key int Val int Prev *Node Next *Node } type LRUCache struct { cap int size int dummyHead *Node // 哨兵头 dummyTail *Node // 哨兵尾 cache map[int]*Node // key → Node 映射 } func Constructor(capacity int) LRUCache { // ① 初始化哨兵节点,互相指向 head := &Node{} tail := &Node{} head.Next = tail tail.Prev = head return LRUCache{ cap: capacity, size: 0, dummyHead: head, dummyTail: tail, cache: make(map[int]*Node), } } func (c *LRUCache) Get(key int) int { // ① map 查不到 → 返回 -1 // ② map 查到了 → 摘除 + 移到头部 → 返回值 } func (c *LRUCache) Put(key int, value int) { // 如果 key 已存在:更新值 + 移到头部 // 如果 key 不存在: // 如果满了:驱逐尾部节点 + map 删除 // 创建新节点 + 插到头部 + map 记录 } ``` ### 关键辅助方法 ``` // 将已有节点从当前位置摘除 removeNode(node): node.Prev.Next = node.Next node.Next.Prev = node.Prev // 把节点插到 dummyHead 后面(最新位置) addToHead(node): node.Prev = dummyHead node.Next = dummyHead.Next dummyHead.Next.Prev = node dummyHead.Next = node // 把节点先摘除再插到头部(组合操作) moveToHead(node): removeNode(node) addToHead(node) // 摘除尾部前一个节点(被驱逐者) removeTail(): toRemove = dummyTail.Prev removeNode(toRemove) return toRemove ``` --- ## 技巧 > [!tip] 🔑 组合模式:"哈希加速 + 链表定序" > 当题目同时要求 **"按 key 查找"** 和 **"维护某种顺序"** 时,考虑用哈希表做 O(1) 定位、用有序容器做排序。这种组合还出现在以下场景中: > - **LFU 缓存**(最少频次)→ 哈希表 + 频次桶 + 双向链表 > - **Top K 频繁元素** → 哈希表计数 + 优先队列(堆) > - **滑动窗口最大值** → 哈希集合 + 双端队列(单调队列) > [!tip] 🔑 哨兵节点(Dummy Head/Tail)的价值 > 在所有需要"在头部/尾部增删"的链式结构中,加入虚拟头尾节点可以消除大量 nil 判断。这不仅是代码简洁的问题——更重要的是**减少分支预测失败的概率**。CPU 对规律性高的代码执行效率更高。 > > 典型应用:链表反转、合并链表、本题、以及所有需要频繁在头部操作的场景。 > [!warning] ⚠️ 常见错误 1:漏掉 update 场景 > `put(key, value)` 中 key 已存在时要更新值并**移到头部**。很多人只写了更新值,忘了移到头部,导致 LRU 失效。记住:`put` 等同于"先读后写再刷新优先级"。 > [!warning] ⚠️ 常见错误 2:驱逐时没删 map > 从链表中移除了最久未用的节点,却忘记了从 `cache map` 中 `delete(key)`。这样后续 `get` 仍然能找到这个已经被淘汰的节点,缓存一致性被破坏。 > [!warning] ⚠️ 常见错误 3:capacity 为负数或零 > 题目说明 `1 <= capacity`,所以不需要处理容量 ≤ 0 的情况。但如果面试题中面试官追问,合理的做法是直接 panic 或在构造函数中返回一个错误。 > [!warning] ⚠️ 常见错误 4:链表操作指针遗漏 > 双向链表插入/删除需要操作 **4 条指针**(每个方向各两条),少写一条就会导致链表断裂或死循环。建议通过"拆开两步走"的方式降低出错率:先拆后连。 > [!note] 🐹 Go 中的实现要点 > 1. **可以用方法接收者**(`(c *LRUCache)`)封装辅助操作,也可以用普通函数独立实现。后者更清晰、更容易测试。 > 2. Go 的 `map` 在 `delete(m, key)` 时对不存在的 key 不会 panic——直接无操作。这是一个安全的特性可以利用。 > 3. Go 的 struct 零值会自动将指针字段设为 nil,所以 `Node{}` 的 `Prev` 和 `Next` 都是 nil。但在构建链表时必须显式设置它们。 > 4. Go 1.21+ 提供了 `container/list` 包,其中 `list.List` 就是双向链表。在实际工程中可以直接使用它来缩短代码——但面试中通常要求自己手写。 > [!info] 📊 如果允许使用标准库? Go 的 `container/list` 包已经实现了双向链表,可以让代码量减少一半以上: ```go type LRUCache struct { cap int list *list.List cache map[int]*list.Element } type entry struct { Key, Val int } func Constructor(capacity int) LRUCache { return LRUCache{cap: capacity, list: list.New(), cache: make(map[int]*list.Element)} } func (c *LRUCache) Get(key int) int { if e, ok := c.cache[key]; ok { c.list.MoveToFront(e) return e.Value.(*entry).Val } return -1 } func (c *LRUCache) Put(key int, value int) { if e, ok := c.cache[key]; ok { e.Value.(*entry).Val = value c.list.MoveToFront(e) return } e := c.list.PushFront(&entry{key, value}) c.cache[key] = e if c.list.Len() > c.cap { removed := c.list.Back() c.list.Remove(removed) delete(c.cache, removed.Value.(*entry).Key) } } ``` > [!success] ✅ 相关题目串联 > - [36-O(1) 数据结构的难题](./36-o1-data-structure.md) — 同一作者设计的 "All O`one Data Structure",同样是哈希表 + 双向链表 > - [37-最小栈](./37-min-stack.md) — 另一个考察"如何做到 O(1)"的经典题 > - [32-随机链表的复制](./32-随机链表的复制.md) — 同样是链表的指针操作 --- ## 代码 ### 手写双向链表 + 哈希表 ⭐(标准答案) ```go package main // Node 双向链表节点 type Node struct { Key, Val int Prev, Next *Node } // LRUCache LRU 缓存,哈希表 + 双向链表 type LRUCache struct { cap int size int dummyHead *Node // 哨兵头节点 dummyTail *Node // 哨兵尾节点 cache map[int]*Node } // Constructor 初始化 LRU 缓存 func Constructor(capacity int) LRUCache { head := &Node{} tail := &Node{} head.Next = tail tail.Prev = head return LRUCache{ cap: capacity, size: 0, dummyHead: head, dummyTail: tail, cache: make(map[int]*Node), } } // Get 获取 key 对应的值,如果不存在返回 -1 // 查到后将该节点移到链表头部(标记为最新使用) func (c *LRUCache) Get(key int) int { if node, ok := c.cache[key]; ok { c.moveToHead(node) return node.Val } return -1 } // Put 插入或更新 key-value // 若 key 已存在则更新值并移到头部;若不存在则插入到新节点到头部 // 超出容量时驱逐尾部前一个节点(最久未使用) func (c *LRUCache) Put(key int, value int) { if node, ok := c.cache[key]; ok { // 情况 1:key 已存在,更新值并移到头部 node.Val = value c.moveToHead(node) return } // 情况 2:key 不存在,创建新节点 newNode := &Node{Key: key, Val: value} c.addToHead(newNode) c.cache[key] = newNode c.size++ // 超出容量,驱逐尾部节点 if c.size > c.cap { removed := c.removeTail() delete(c.cache, removed.Key) c.size-- } } // ---------- 辅助方法 ---------- // addToHead 将节点插入到 dummyHead 之后 func (c *LRUCache) addToHead(node *Node) { node.Prev = c.dummyHead node.Next = c.dummyHead.Next c.dummyHead.Next.Prev = node c.dummyHead.Next = node } // removeNode 将节点从链表中摘除(不管它在什么位置) func (c *LRUCache) removeNode(node *Node) { node.Prev.Next = node.Next node.Next.Prev = node.Prev } // moveToHead 先摘除再插到头部(用于 get 命中和 put 更新场景) func (c *LRUCache) moveToHead(node *Node) { c.removeNode(node) c.addToHead(node) } // removeTail 摘除并返回尾部前一个有效节点 func (c *LRUCache) removeTail() *Node { removed := c.dummyTail.Prev // 倒数第二个节点 = 最久未用的 c.removeNode(removed) return removed } ``` > [!success] ✅ 执行验证与总结 **这套实现的每一个操作都是严格的 O(1):** | 操作 | 路径 | 耗时组成 | |------|------|---------| | `Get(key)` | map 查找 → `moveToHead` | O(1) + O(1) = **O(1)** | | `Put(key, val)`(命中) | map 查找 → `moveToHead` | O(1) + O(1) = **O(1)** | | `Put(key, val)`(未命中,未满) | map 写入 → `addToHead` | O(1) + O(1) = **O(1)** | | `Put(key, val)`(未命中,已满) | map 写入 → `addToHead` → `removeTail` → map 删除 | O(1) × 4 = **O(1)** | > [!TIP] 💬 面试加分话术 > "我选择哈希表 + 双向链表的组合来实现。哈希表保证了 key 的 O(1) 查找,双向链表保证了任意位置节点的 O(1) 增删。为了简化边界处理,我在链表两端加了哨兵节点。整个设计中,最近使用的节点永远保持在头部,最久未用的永远在尾部——这天然匹配 LRU 的语义。唯一需要注意的是 put 操作中 key 已存在的情况,这时候需要先更新值再 move 到头部,保持 LRU 属性。" > [!note] 🤔 为什么不用单链表? > 如前所述,单链表若要删除某个已知节点,需要找到它的 predecessor 才能断开前驱指针。虽然可以通过"覆盖后继节点的值"的技巧绕过这个问题(见下方思考),但这牺牲了可读性和正确性保证。**双向链表是 O(1) 删除已知节点的最自然方案。** > [!question] 💡 延伸思考:单链表的 O(1) 删除 trick > 如果只能用单链表,有一种技巧可以在只知道待删除节点指针的情况下完成 O(1) 删除:**把后继节点的值复制到当前节点,然后删除后继**。但这要求待删除节点不能是最后一个节点。在 LRU 缓存的场景下,如果要驱逐的是尾节点,这个方法无效。所以这不是一个通用的解决方案。这也解释了为什么 LRU 的标准实现总是用双向链表。 > [!info] 📊 与其他语言的对比 | 语言 | 实现方式 | 备注 | |------|---------|------| | Go | 手写 struct + 指针 | 需手动管理 prev/next 指针 | | Java | `LinkedHashMap` / 手写 Node | JDK 内置 LinkedHashMap 就是 LRU 的实现 | | Python | `collections.OrderedDict` / 手写 | OrderedDict.move_to_end() 内置支持 | | C++ | `std::list` + `std::unordered_map` | STL 提供了双向链表和无序 map | Go 没有类似 Java LinkedHashMap 或 Python OrderedDict 这样的内置结构,因此手写是实现 LRU 缓存的常规做法,也是面试的默认期望。