从API调用到手写LRU:力扣146「LRU缓存」的哈希表+双向链表终极进化之路
当面试官说"不要用LinkedHashMap"时,你就知道,真正的挑战来了。这道题,是对你数据结构组合能力的终极考验。
前言
在完成了链表的各种翻转(206/24/25)、合并(21/23)、排序(148)和复制(138)之后,今天我们要攻克的是一道在面试中"如雷贯耳"的经典设计题------力扣146. LRU缓存(LRU Cache)。
这道题在LeetCode上标记为中等(Medium) ,但它的江湖地位远超其难度标签。在字节跳动、腾讯、Google、Meta 的面试中,它几乎是"必刷清单"上的TOP 3。为什么?因为它完美地融合了哈希表 和双向链表 两种数据结构,考察的是你能否在 O(1) 的时间复杂度内,同时完成"查找"和"更新顺序"这两个核心操作。
LRU(Least Recently Used,最近最少使用)缓存机制,不仅在面试中高频出现,更是Redis、MySQL等中间件内存淘汰策略的核心思想。你可能会想:"Java有LinkedHashMap,Python有OrderedDict,几行代码不就搞定了吗?"------但面试官真正想看的是,当你不依赖这些现成的"轮子"时,如何用最基础的数组/链表+哈希表,精准地实现这一机制。
今天,我们就从最直观的"时间戳排序"思路出发,再到利用LinkedHashMap的"作弊"写法,最后抵达那个让无数人头皮发麻、但一旦理解就酣畅淋漓的手写双向链表+哈希表 的终极实现。当你亲手把 prev 和 next 指针一根根接上时,LRU将不再是你的噩梦,而是你的肌肉记忆。
题目回顾
请你设计并实现一个满足 LRU (最近最少使用) 缓存约束的数据结构。
实现
LRUCache类:
LRUCache(int capacity)以正整数作为容量capacity初始化 LRU 缓存。int get(int key)如果关键字key存在于缓存中,则返回关键字的值,否则返回-1。void put(int key, int value)如果关键字key已经存在,则变更其数据值;如果不存在,则向缓存中插入该组key-value。如果插入操作导致关键字数量超过capacity,则应该逐出最久未使用的关键字。函数
get和put必须以O(1)的平均时间复杂度运行。示例:
css输入: ["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]
核心难点:同时维护"访问顺序"与"快速查找"
为什么这道题让无数人栽跟头?因为它有两个相互冲突的要求:
- 快速查找 :要能在
O(1)时间内通过key找到value。------这显然是哈希表的专长。 - 快速更新顺序 :每次
get或put时,这个key必须变成"最近使用"的。当缓存满了,我们要快速找到"最久未使用"的那个并删除。------这需要一种能快速调整顺序的数据结构。
数组 能快速查找(二分或哈希映射),但调整顺序要移动大量元素,O(n) 不行。 单链表 能快速调整(插入/删除节点),但要找某个节点必须从头遍历,O(n) 也不行。
因此,唯一的出路就是哈希表 + 双向链表 的结合体。哈希表负责 key 到节点的映射(实现 O(1) 查找),双向链表负责维护节点的访问顺序(实现 O(1) 的删除和插入)。
第一层:偷懒法(利用语言内置特性) ------ 面试前的"热身"
很多高级语言提供了现成的有序字典。在面试中,如果你能直接说出这个方案,说明你懂思路;但如果面试官让你"禁止使用",你就得拿出真本事了。
Python(OrderedDict) / Java(LinkedHashMap)
java
class LRUCache extends LinkedHashMap<Integer, Integer> {
private int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75f, true); // accessOrder=true 表示按访问顺序排序
this.capacity = capacity;
}
public int get(int key) {
return super.getOrDefault(key, -1);
}
public void put(int key, int value) {
super.put(key, value);
}
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > capacity;
}
}
点评: 这种写法在工程上没有任何问题,但它完全屏蔽了底层原理。如果面试官想考的是"设计数据结构",直接甩出 LinkedHashMap 相当于"作弊"。我们只把它作为引子,真正的核心还在后面。
第二层:手写双向链表 + 哈希表 ------ 终极奥义
这是本题最正统、最硬核的解法。我们需要自己构建一个双向链表,并搭配哈希表使用。
数据结构设计
首先,我们定义一个双向链表的节点类 DLinkedNode。它不仅要存 value,还要存 key(因为删除尾节点时,我们需要通过 key 去哈希表中删除记录)。
java
class DLinkedNode {
int key;
int value;
DLinkedNode prev;
DLinkedNode next;
public DLinkedNode() {}
public DLinkedNode(int key, int value) {
this.key = key;
this.value = value;
}
}
然后,在 LRUCache 类中维护:
Map<Integer, DLinkedNode> cache:快速定位节点。DLinkedNode head, tail:虚拟头尾节点(哨兵),简化边界操作。int capacity和int size:当前容量和大小。
核心操作:定义"原子"动作
为了避免 put 和 get 逻辑混乱,我们封装几个辅助函数:
addToHead(Node node):将节点添加到虚拟头节点之后(标记为最新使用)。removeNode(Node node):从链表中移除一个节点(不删除哈希映射)。moveToHead(Node node):将某个节点移动到头部。removeNode(node)+addToHead(node)。popTail():移除真正的尾节点(虚拟尾的前一个),并返回它,供删除使用。
为什么使用虚拟头尾节点? 如果不使用虚拟节点,我们在插入第一个节点、删除最后一个节点时,都要特殊判断 head == null 或 tail == null。引入 head 和 tail 作为固定的哨兵,让所有操作都在"非空"节点间进行,极大地减少了 if (null) 的烦恼。
代码实现(Java 完整版)
java
class LRUCache {
private Map<Integer, DLinkedNode> cache;
private DLinkedNode head, tail;
private int capacity;
private int size;
public LRUCache(int capacity) {
this.capacity = capacity;
this.size = 0;
cache = new HashMap<>();
// 初始化虚拟头尾节点,并让它们互相指向
head = new DLinkedNode();
tail = new DLinkedNode();
head.next = tail;
tail.prev = head;
}
public int get(int key) {
DLinkedNode node = cache.get(key);
if (node == null) return -1;
// 只要访问过,就移到头部
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
DLinkedNode node = cache.get(key);
if (node != null) {
// 存在:更新值,并移到头部
node.value = value;
moveToHead(node);
} else {
// 不存在:创建新节点
DLinkedNode newNode = new DLinkedNode(key, value);
cache.put(key, newNode);
addToHead(newNode);
size++;
// 检查是否超出容量
if (size > capacity) {
// 删除尾部节点
DLinkedNode tailNode = popTail();
cache.remove(tailNode.key);
size--;
}
}
}
// ---------- 辅助函数 ----------
private void addToHead(DLinkedNode node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
private void removeNode(DLinkedNode node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void moveToHead(DLinkedNode node) {
removeNode(node);
addToHead(node);
}
private DLinkedNode popTail() {
DLinkedNode res = tail.prev;
removeNode(res);
return res;
}
}
python
class DLinkedNode:
def __init__(self, key=0, value=0):
self.key = key
self.value = value
self.prev = None
self.next = None
class LRUCache:
def __init__(self, capacity: int):
self.capacity = capacity
self.size = 0
self.cache = {}
self.head = DLinkedNode()
self.tail = DLinkedNode()
self.head.next = self.tail
self.tail.prev = self.head
def get(self, key: int) -> int:
node = self.cache.get(key)
if not node:
return -1
self._move_to_head(node)
return node.value
def put(self, key: int, value: int) -> None:
node = self.cache.get(key)
if not node:
new_node = DLinkedNode(key, value)
self.cache[key] = new_node
self._add_to_head(new_node)
self.size += 1
if self.size > self.capacity:
removed = self._pop_tail()
del self.cache[removed.key]
self.size -= 1
else:
node.value = value
self._move_to_head(node)
def _add_to_head(self, node):
node.prev = self.head
node.next = self.head.next
self.head.next.prev = node
self.head.next = node
def _remove_node(self, node):
node.prev.next = node.next
node.next.prev = node.prev
def _move_to_head(self, node):
self._remove_node(node)
self._add_to_head(node)
def _pop_tail(self):
node = self.tail.prev
self._remove_node(node)
return node
流程演示
以 capacity = 2 为例,执行 put(1,1),put(2,2),get(1),put(3,3):
put(1,1):链表head <-> 1 <-> tail。put(2,2):链表head <-> 2 <-> 1 <-> tail(2最新)。get(1):将1移到头部,链表head <-> 1 <-> 2 <-> tail。put(3,3):发现容量满了,popTail()移除尾节点(即2),删除映射。链表head <-> 3 <-> 1 <-> tail。完美淘汰了最久未使用的2。
第三层:深度剖析 ------ 为什么 Node 里要存 key?
很多同学写代码时会在节点里存 key,但没想过为什么。答案就藏在 popTail() 中:
当我们删除尾节点时,我们拿到了这个 DLinkedNode 对象。此时我们需要从哈希表 cache 中删除这个映射。哈希表是 Map<Integer, DLinkedNode>,要从哈希表中删除,必须提供 key。
如果节点中不存 key,我们就无法知道这个尾节点对应的 key 是什么,也就无法删除哈希表条目,最终导致内存泄漏或逻辑错误。这就是节点必须存 key 的根本原因。
第四层(拓展):为什么不用单链表?为什么不用数组?
- 单链表 :删除节点时,我们需要找到它的前驱节点。在单链表中,这需要从头遍历(O(n))。双向链表则通过
prev指针直接获取前驱,实现 O(1) 删除。 - 数组 + 时间戳:每次访问更新数组中的时间戳,删除时遍历找最小时间戳。查找和删除都是 O(n),不满足要求。
- 单独用 HashMap:无法维护顺序;单独用 LinkedList:查找 O(n)。
所以,"哈希表 + 双向链表"是这个命题下的唯一标准解。
深度总结:从设计模式到细节实现
| 组件 | 数据结构 | 职责 |
|---|---|---|
| 查找引擎 | HashMap(哈希表) | 通过 key 在 O(1) 时间内定位到链表节点 |
| 顺序维护引擎 | 双向链表(Doubly Linked List) | 维护节点的访问顺序,头部为最新,尾部为最旧 |
| 辅助节点 | 虚拟头尾节点(Dummy Head/Tail) | 简化边界操作,避免空指针判断 |
| 删除机制 | popTail() + cache.remove(key) |
当容量超限时,删除尾节点并清理哈希表 |
从这道题中我们学到了什么?
-
数据结构的组合(Composition)胜于继承 。我们不是扩展某个类,而是将
Map和自定义链表组合起来。这种设计模式(组合模式)在工程中极其常见。 -
双向链表 + 哈希表 = 万能钥匙。凡是涉及到"快速查找 + 快速调整顺序"的问题(如 LFU 缓存、某些调度算法),都可以考虑这个组合。
-
虚拟节点是链表编程的"护身符" 。在力扣的链表题中,我们见过很多
dummy节点。在 LRU 的实现中,虚拟头尾节点更是把插入和删除的边界逻辑减少到了极致。 -
O(1) 复杂度的实现必然伴随着细节的严谨。少改一个指针,链表就可能断裂形成环;少删一个 map 映射,内存就会泄露。手写 LRU 是对"代码严谨性"的极好训练。
最后的一些心里话
力扣146是一道"看答案觉得简单,自己写全是坑"的题。很多同学在面试中因为指针顺序写反(比如 head.next.prev = node 和 node.next = head.next 的顺序搞反)而功亏一篑。
我建议你在写这道题时,把 addToHead 和 removeNode 的指针操作当成"四行咒语"背下来,并在纸上画出箭头指向的变化。 当你画过三遍之后,这四行代码就会变成你的条件反射。
现在的你,已经掌握了哈希表、单链表、双向链表、反转链表、合并链表、排序链表、复制链表,再加上今天的 LRU 设计。链表这个数据结构,你已经从"使用者"变成了"设计者"。
记住今天的口诀:
LRU 设计巧,哈希链表结合好。 虚拟头尾当哨兵,增删改查 O(1) 保。 节点记得存 key 值,淘汰尾部才找得到。
如果你觉得这篇题解帮你彻底搞懂了 LRU 缓存,欢迎点赞、收藏、转发!接下来我们将开启全新的篇章------二叉树,你准备好了吗? 🚀