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 模板。
相似题 / 套用模板
-
- LFU 缓存(在这基础上加"每个频率一个 LRU 链表",更进阶)
- 任何"既要哈希表又要维护访问顺序"的设计题,核心都是这个双向链表 + 哈希表联动的模板。