Files

538 lines
19 KiB
Markdown
Raw Permalink Normal View History

2026-05-18 12:24:21 +08:00
---
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 缓存的常规做法,也是面试的默认期望。