解析 LeetCode 146. LRU 缓存:哈希表与双向链表的协同设计
一、题目分析
(一)问题定义
设计一个缓存结构,支持 get 和 put 操作,所有操作必须在 O(1) 时间内完成。
capacity 表示缓存最多能存几个键值对。
每个 key 对应唯一 value。
get(key) 返回对应 value,key 不存在时返回 -1。
put(key, value) 若 key 已存在,则更新 value 并标记为最近使用。若不存在,则插入新键值对。插入后若总 key 数超过 capacity,必须立即删除最久未使用的那个 key。
例如 capacity = 2 时:
- put(1,1) → 缓存中只有 (1,1)
- put(2,2) → 缓存变成 (1,1) (2,2),(1,1) 在前表示更早插入或更久没被访问
- get(1) → 返回 1,并把 (1,1) 移到最新位置,缓存仍为 (1,1) (2,2),但时序已重排
- put(3,3) → 要插入新 key,但已满,删最久未用的 (2,2),再加 (3,3),缓存变成 (1,1) (3,3)
(二)核心挑战
O(1) 是硬门槛:不能有任何 for 循环,不能遍历链表找尾,不能扫描哈希表查最老。
既要按 key 快速定位 value,又要按访问时间动态调整顺序------哈希表擅长前者,但无法记录时序。数组或单链表能记顺序,但删最老或插最新都可能 O(n)。
真实场景中,一次 get 就是用户点开商品页,一次 put 就是加入购物车,缓存必须瞬时响应,否则页面卡顿。
二、算法思路:哈希表 + 双向链表
(一)为何选双向链表而非单向
- 单向链表删 tail 需从 head 开始走到倒数第二个节点,O(n) 直接失败。
- 双向链表中 tail.prev 就是最久未用节点,一步拿到,O(1) 删除。
- 把任意节点挪到 head 后,只需断开原连接、重连 head 附近四条指针,全程无遍历。
(二)头尾哨兵节点的设计意义
- head 和 tail 是虚拟节点,不存业务数据,只起锚点作用。
- 所有真实节点永远夹在 head 和 tail 中间:head.next 永远指向最新节点,tail.prev 永远指向最久节点。
- 初始化时 head ↔ tail 连好,后续所有操作都不用判空:removeNode 不检查 node.prev 是否 null,removeTail 直接取 tail.prev 不怕空指针。
三、代码实现与详细解析
java
import java.util.HashMap;
import java.util.Map;
public class LRUCache
{
// 链表节点类:存 key、value 和前后指针
private class ListNode
{
int key;
int value;
ListNode prev;
ListNode next;
public ListNode(int key, int value)
{
this.key = key;
this.value = value;
}
}
private final int capacity; // 缓存最大容量,定长不改
private final Map<Integer, ListNode> cache; // key 到节点的映射,保证 get/put 查找 O(1)
private final ListNode head; // 哨兵头节点,不存数据,只作锚点
private final ListNode tail; // 哨兵尾节点,不存数据,只作锚点
public LRUCache(int capacity)
{
this.capacity = capacity;
this.cache = new HashMap<>(); // 初始化哈希表,初始桶数组为空
this.head = new ListNode(-1, -1); // 哨兵头,key/value 仅占位,无业务含义
this.tail = new ListNode(-1, -1); // 哨兵尾,同上
head.next = tail; // 连 head → tail
tail.prev = head; // 连 tail ← head,形成空双向链表 head ↔ tail
}
public int get(int key)
{
if (!cache.containsKey(key))
{
return -1; // key 不在缓存中,直接返回 -1,不触发任何链表操作
}
ListNode node = cache.get(key); // 从哈希表 O(1) 拿到节点引用
moveToHead(node); // 访问即最新,必须移到 head 后,更新时序
return node.value; // 返回对应 value
}
public void put(int key, int value)
{
if (cache.containsKey(key))
{
ListNode node = cache.get(key); // key 已存在,拿到旧节点
node.value = value; // 直接更新 value,不新建节点
moveToHead(node); // 移到 head 后,标记为最新访问
}
else
{
ListNode newNode = new ListNode(key, value); // key 不存在,新建节点
cache.put(key, newNode); // 哈希表登记 key→node 映射
addToHead(newNode); // 插入链表 head 后,成为最新
if (cache.size() > capacity)
{
ListNode tailNode = removeTail(); // 超容,删最久节点 tail.prev
cache.remove(tailNode.key); // 哈希表同步删 key,避免 dangling reference
}
}
}
// 将指定节点移到 head 后,代表它变成最新访问
private void moveToHead(ListNode node)
{
removeNode(node); // 先从当前链表位置摘下该节点
addToHead(node); // 再插到 head 后,完成时序刷新
}
// 从链表中彻底移除指定节点,仅改指针,不涉及哈希表
private void removeNode(ListNode node)
{
node.prev.next = node.next; // 上一节点的 next 指向下一节点
node.next.prev = node.prev; // 下一节点的 prev 指向上一节点
}
// 在 head 后插入节点,使 node 成为最新
private void addToHead(ListNode node)
{
node.prev = head; // node 的 prev 指向 head
node.next = head.next; // node 的 next 指向原 head.next
head.next.prev = node; // 原 head.next 的 prev 指回 node
head.next = node; // head 的 next 指向 node,完成插入
}
// 删除 tail.prev 节点并返回它,用于淘汰最久未用项
private ListNode removeTail()
{
ListNode tailNode = tail.prev; // 直接取 tail 的前一个,就是最久节点
removeNode(tailNode); // 调用通用删除逻辑
return tailNode; // 返回被删节点,供上层清理哈希表
}
}
(一)代码流程拆解
- 初始化:new LRUCache(2) 时,建 capacity=2、空 HashMap、head 和 tail 两个哨兵,并让 head.next = tail、tail.prev = head,此时链表长度为 0,但结构已就绪。
- get 流程:查 cache 无 key → 返回 -1。有 key → 取 node → moveToHead → 返回 value。moveToHead 内部先 removeNode 再 addToHead,两步都是 O(1) 指针操作。
- put 流程:key 存在 → 更新 value + moveToHead。key 不存在 → new node + cache.put + addToHead + 检查 size。超容时 removeTail 返回节点,cache.remove 删除映射,双写一致。
- 辅助方法闭环:removeNode 断开左右连接。addToHead 四步连指针。removeTail 封装 tail.prev 获取和删除。moveToHead 组合前两者,复用不重复。
(二)关键逻辑解析
head.next 是最新: 每次 addToHead 都把 node 插在 head 后,所以 head.next 永远是刚插入或刚访问的节点。例如 put(1,1) 后 head.next 就是 (1,1)。再 put(2,2),(2,2) 插到 head 后,head.next 变成 (2,2),(1,1) 退为第二新。
tail.prev 是最久: 所有节点都在 head 和 tail 之间,新增和移动只动 head 端,tail 端始终不动,所以 tail.prev 一定是当前链表里最晚被 addToHead 或 moveToHead 的那个,也就是最久未用。
removeNode 不判空: 因为所有调用 removeNode 的地方,node 都来自 cache.get 或 tail.prev,而 cache 中的 node 必然已正确挂入链表(prev/next 非 null),且 tail.prev 在非空链表中一定不为 null ------ 哨兵保证了这一点。
addToHead 四步不能乱序: 必须先设 node.prev = head 和 node.next = head.next,再改 head.next.prev 和 head.next。如果先改 head.next,head.next.prev 就找不到原节点了。
超容判断用 cache.size: 不用链表 length,因为 cache 是唯一权威入口。size() 是 HashMap 的 O(1) 方法,且与实际 key 数严格一致,比遍历链表数节点靠谱。
(三)易错点
-
误用单向链表删 tail: 单向链表没有 prev 指针,删 tail 时得从 head 一路 next 到倒数第二个节点,再 node.next = null,O(n) 直接超时。双向链表 tail.prev 一步到位,O(1)。
-
moveToHead 里漏掉 removeNode: 如果只做 addToHead 不先摘下 node,node 会同时挂在原位置和 head 后,造成指针混乱,链表断裂,后续操作全错。比如 (1,1)→(2,2) 链表中,直接 addToHead(1,1),不先 remove,就会出现 (1,1) 同时连着 (2,2) 和 head,head.next 也指向 (1,1),但 (1,1).next 还是 (2,2),链表成环或断裂。
-
超容时先删哈希再删链表: cache.remove(tailNode.key) 放在 removeTail() 前面,会导致 removeTail() 里取 tail.prev 时,该节点虽在链表中但已不在 cache,后续 get 或 put 若命中该 key 会误判为 miss,但更危险的是:如果此时恰好有并发访问,可能拿到已被 remove 的 node 引用,造成 dangling reference,读到脏数据或空指针。
-
addToHead 四步顺序颠倒: 比如先 head.next = node,再 node.next = head.next,那 node.next 就等于自己,链表自环。正确顺序是先固定 node 的 prev 和 next,再更新外围指针。
-
用链表节点数判断容量: 链表长度需遍历统计,O(n)。而 cache.size() 是 HashMap 内置字段,O(1) 且绝对准确。用链表长度不仅慢,还可能因指针错误导致计数不准。
-
哨兵节点不初始化双向连接: 初始化时只写 head.next = tail,漏掉 tail.prev = head,那么第一次 addToHead 就会让 head.next.prev 指向 null,触发空指针异常。必须双向连死,形成 head ↔ tail 初始闭环。
四、复杂度分析
(一)时间复杂度
所有操作均为 O(1):HashMap 的 get/put/remove 平均 O(1)。双向链表的 removeNode、addToHead、removeTail 都是固定次数指针赋值,O(1)。
(二)空间复杂度
O(capacity):哈希表最多存 capacity 个键值对,链表最多存 capacity 个节点,两者空间消耗均与 capacity 线性正比,无额外放大。