一、题目描述
146. 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) 的平均时间复杂度运行。
示例:
text
输入:
["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 <= 10^4 -
0 <= value <= 10^5 -
最多调用
2 * 10^5次get和put
二、解题思路概览
LRU 缓存的核心需求:
-
快速查找:根据 key 找到对应的 value(需要 HashMap)
-
快速更新访问顺序:每次 get/put 都要将该 key 标记为最近使用(需要能快速移动节点)
-
快速删除最久未使用:当容量满时,删除链表尾部的节点(需要能快速删除尾部)
满足 O(1) 操作的数据结构组合:HashMap + 双向链表
| 解法 | 时间复杂度 | 空间复杂度 | 特点 | 面试推荐 |
|---|---|---|---|---|
| 双向链表 + HashMap | O(1) | O(capacity) | 标准解法,面试必考 | ⭐⭐⭐⭐⭐ |
| LinkedHashMap | O(1) | O(capacity) | 利用 Java 内置类,代码极简 | ⭐⭐⭐⭐ |
| 单链表 + HashMap | O(1) 查,O(n) 删 | O(capacity) | 不符合 O(1) 要求 | ❌ |
三、解法一:双向链表 + HashMap(标准解法)⭐⭐⭐⭐⭐
3.1 核心思路
-
双向链表:维护 key 的访问顺序。头部是最近使用的,尾部是最久未使用的
-
HashMap:存储 key 到链表节点的映射,实现 O(1) 查找
3.2 数据结构设计
java
// 双向链表节点
class Node {
int key;
int value;
Node prev;
Node next;
Node(int key, int value) {
this.key = key;
this.value = value;
}
}
// LRU 缓存主类
class LRUCache {
private Map<Integer, Node> map;
private Node head; // 伪头节点,head.next 是真正的头
private Node tail; // 伪尾节点,tail.prev 是真正的尾
private int capacity;
public LRUCache(int capacity) {
this.capacity = capacity;
map = new HashMap<>();
head = new Node(-1, -1);
tail = new Node(-1, -1);
head.next = tail;
tail.prev = head;
}
public int get(int key) {
if (!map.containsKey(key)) {
return -1;
}
Node node = map.get(key);
// 移到头部
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
if (map.containsKey(key)) {
// 更新值并移到头部
Node node = map.get(key);
node.value = value;
moveToHead(node);
} else {
// 如果容量已满,删除尾部节点
if (map.size() == capacity) {
Node tailNode = removeTail();
map.remove(tailNode.key);
}
// 创建新节点,添加到头部
Node newNode = new Node(key, value);
map.put(key, newNode);
addToHead(newNode);
}
}
// 将节点移动到头部
private void moveToHead(Node node) {
removeNode(node);
addToHead(node);
}
// 从链表中删除节点
private void removeNode(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
// 在头部添加节点
private void addToHead(Node node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
// 删除尾部节点并返回
private Node removeTail() {
Node tailNode = tail.prev;
removeNode(tailNode);
return tailNode;
}
}
3.3 图解示例
以 capacity = 2 为例:
text
初始: head <-> tail
put(1,1):
head <-> 1 <-> tail
put(2,2):
head <-> 2 <-> 1 <-> tail
get(1):
head <-> 1 <-> 2 <-> tail
put(3,3): 容量满,删除尾部2
head <-> 3 <-> 1 <-> tail
get(2): 返回 -1
put(4,4): 容量满,删除尾部1
head <-> 4 <-> 3 <-> tail
3.4 复杂度分析
-
时间复杂度:O(1),所有操作都是常数时间
-
空间复杂度:O(capacity),存储最多 capacity 个节点
四、解法二:LinkedHashMap(Java 内置实现)⭐⭐⭐⭐
4.1 核心思路
LinkedHashMap 本身就是一个 HashMap + 双向链表,可以维护访问顺序。通过构造参数 accessOrder = true,每次 get/put 都会将节点移到链表末尾。
4.2 代码实现
java
class LRUCache extends LinkedHashMap<Integer, Integer> {
private int capacity;
public LRUCache(int capacity) {
// accessOrder = true 表示按访问顺序排序
super(capacity, 0.75f, true);
this.capacity = capacity;
}
public int get(int key) {
Integer value = super.get(key);
return value == null ? -1 : value;
}
public void put(int key, int value) {
super.put(key, value);
}
// 当 size > capacity 时,删除最久未使用的节点(即链表头部的节点)
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > capacity;
}
}
4.3 一行流写法(面试炫技,不推荐)
java
class LRUCache extends LinkedHashMap<Integer, Integer> {
private int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75F, 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;
}
}
4.4 优缺点
| 优点 | 缺点 |
|---|---|
| 代码极简(不到20行) | 面试官可能要求自己实现双向链表 |
| 利用成熟的内置类 | 无法展示对数据结构的理解 |
| 不易出错 | 有些公司面试禁止使用 |
五、常见错误与注意事项
5.1 常见错误
-
忘记更新双向链表的双向指针 :只更新了
prev或只更新了next -
removeNode 时未处理边界:使用伪头尾节点可以避免空指针
-
put 已存在的 key 时,只更新 value 而未移到头部
-
put 新节点时,先判断容量再添加,而不是先添加再判断
5.2 关键点总结
| 操作 | 要做的事 |
|---|---|
| get | ① 从 map 取节点 ② 移到头部 ③ 返回值 |
| put(key 存在) | ① 更新 value ② 移到头部 |
| put(key 不存在) | ① 若容量满,删除尾部节点 ② 创建新节点 ③ 添加到头部 ④ 放入 map |
六、解法对比与总结
| 方法 | 代码量 | 可读性 | 面试推荐 | 适用场景 |
|---|---|---|---|---|
| 双向链表 + HashMap | 较多 | 清晰 | ⭐⭐⭐⭐⭐ | 面试标准答案 |
| LinkedHashMap | 极少 | 极简 | ⭐⭐⭐⭐ | 快速实现,或说明思路后使用 |
七、面试建议
7.1 标准回答流程
-
分析需求:需要 O(1) 的 get 和 put,同时维护访问顺序
-
选择数据结构:HashMap(查找)+ 双向链表(维护顺序)
-
解释为什么双向链表:
-
删除任意节点 O(1)
-
移动到头部 O(1)
-
删除尾部 O(1)
-
单链表无法 O(1) 删除任意节点(需要找前驱)
-
-
画图说明:伪头尾节点如何简化边界处理
-
写出代码
7.2 追问与扩展
Q:为什么不用单链表?
A:单链表删除任意节点需要 O(n) 时间找前驱,无法满足 O(1) 要求。
Q:为什么不用数组 + 时间戳?
A:get 操作需要 O(log n) 找最大值,且维护访问时间需要额外存储。
Q:LinkedHashMap 是怎么实现的?
A:它继承了 HashMap,内部维护了一个双向链表,通过 before 和 after 指针连接节点。
Q:如何实现 LFU(最不经常使用)缓存?
A:需要两个 HashMap + 双向链表/优先队列,实现更复杂。
八、相关链接
-
官方题解 :LRU 缓存官方题解