中等
请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。
实现 LRUCache 类:
LRUCache(int capacity)以 正整数 作为容量capacity初始化 LRU 缓存int get(int key)如果关键字key存在于缓存中,则返回关键字的值,否则返回-1。void put(int key, int value)如果关键字key已经存在,则变更其数据值value;如果不存在,则向缓存中插入该组key-value。如果插入操作导致关键字数量超过capacity,则应该 逐出 最久未使用的关键字。
函数 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 <= 30000 <= key <= 100000 <= value <= 105- 最多调用
2 * 105次get和put
📝 核心笔记:LRU (HashMap + Doubly Linked List)
1. 核心思想 (一句话总结)
"哈希表负责找人(O(1)),链表负责排队(O(1))。"
- HashMap :存储
Key -> Node的映射。让我们能瞬间找到这一页书夹在队伍的哪个位置。 - Double Linked List:维护书籍的"新鲜度"。
-
- 头部 ( dummy.next**)**:最新访问的 (MRU)。
- 尾部 ( dummy.prev**)**:最久未访问的 (LRU)。
- 哨兵 ( dummy**)**:连接头尾,形成环,避免处理复杂的边界(如空链表)。
2. 您的代码亮点 (单哨兵循环链表)
普通的写法通常定义 head 和 tail 两个哨兵,但您用了一个 dummy 把它们连成了一个圈:
- 虚拟头 :
dummy.next指向真正的第一个节点。 - 虚拟尾 :
dummy.prev指向真正的最后一个节点。 - 初始状态 :
dummy的前驱和后继都指向自己。
3. 算法流程 (书堆操作)
- GET (查书):
-
- 去 Map 里找,找不到返回 -1。
- 找到了,先把书从老位置抽出来 (
remove)。 - 再把书放到最上面 (
pushFront)。
- PUT (放书):
-
- 书已存在:抽出来,更新内容,放回最上面。
- 书不存在:
-
-
- 新做一本书。
- 放到最上面 (
pushFront)。 - 检查容量 :如果书太多了,把最底下的那本 (
dummy.prev) 扔掉 (remove),记得同时在 Map 里注销。
-
🔍 代码回忆清单
// 题目:LC 146. LRU Cache (手写双向链表版)
class LRUCache {
// 1. 定义双向链表节点
private static class Node {
int key, value;
Node prev, next;
Node(int k, int v) { key = k; value = v; }
}
private final int capacity;
// 2. 核心技巧:一个哨兵形成的循环链表
// dummy.next 是 MRU (最新),dummy.prev 是 LRU (最老)
private final Node dummy = new Node(0, 0);
private final Map<Integer, Node> keyToNode = new HashMap<>();
public LRUCache(int capacity) {
this.capacity = capacity;
// 初始化:自己指自己,形成闭环
dummy.prev = dummy;
dummy.next = dummy;
}
public int get(int key) {
Node node = getNode(key); // 辅助函数处理了"移到头部"的逻辑
return node != null ? node.value : -1;
}
public void put(int key, int value) {
Node node = getNode(key);
if (node != null) {
node.value = value; // 更新值,位置已经在 getNode 里调整过了
return;
}
// 新增节点
node = new Node(key, value);
keyToNode.put(key, node);
pushFront(node); // 放进缓存,置于头部
// 容量检查
if (keyToNode.size() > capacity) {
Node backNode = dummy.prev; // 倒数第一个节点 (最久未用)
keyToNode.remove(backNode.key); // 别忘了删哈希表
remove(backNode); // 删链表
}
}
// 辅助函数:获取节点并"刷新"它的位置
private Node getNode(int key) {
if (!keyToNode.containsKey(key)) return null;
Node node = keyToNode.get(key);
remove(node); // 1. 先断开
pushFront(node); // 2. 再插到头
return node;
}
// 链表操作:从当前位置移除 (断链)
private void remove(Node x) {
x.prev.next = x.next;
x.next.prev = x.prev;
}
// 链表操作:插到哨兵后面 (头插法)
private void pushFront(Node x) {
x.prev = dummy;
x.next = dummy.next;
// 下面两步顺序不能反,先连后面,再连前面
x.prev.next = x; // 也就是 dummy.next = x
x.next.prev = x; // 把原来的头节点的 prev 指向 x
}
}
⚡ 快速复习 CheckList (易错点)
-
\] **为什么 Node 里要存 key?**
-
- 非常关键 。当缓存满了需要淘汰链表尾部节点 (
backNode) 时,我们手里只有这个Node对象。我们必须通过backNode.key去把 HashMap 里的记录也删掉。如果 Node 里没存 key,就没法反向操作 Map 了。
- 非常关键 。当缓存满了需要淘汰链表尾部节点 (
-
\] **指针操作顺序?**
-
- 在
pushFront中,最容易写错指针赋值顺序。 - 口诀 :先处理新节点 (
x) 的左右手,再让两边的"老人"牵住x。
- 在
-
\] **dummy 的环状初始化?**
-
- 构造函数里必须写
dummy.prev = dummy; dummy.next = dummy;。否则第一次put时,dummy.next是 null,访问dummy.next.prev就会报空指针。
- 构造函数里必须写
🖼️ 数字演练
Capacity = 2.
- Init :
dummy<->dummy. - put(1, 1):
-
- New Node(1).
pushFront. - Structure:
dummy<->Node(1)<->dummy.
- New Node(1).
- put(2, 2):
-
- New Node(2).
pushFront. - Structure:
dummy<->Node(2)<->Node(1)<->dummy. (2 是头,1 被挤到尾)
- New Node(2).
- get(1):
-
- Find(1).
remove(1).pushFront(1). - Structure:
dummy<->Node(1)<->Node(2)<->dummy. (1 变成头了)
- Find(1).
- put(3, 3):
-
- Size > Cap.
backNodeisdummy.prev(which is 2). keyToNode.remove(2).remove(Node 2).- Add 3.
- Structure:
dummy<->Node(3)<->Node(1)<->dummy.
- Size > Cap.
📝 核心笔记:LRU 缓存 (Least Recently Used)
1. 核心思想 (一句话总结)
"越常用的越靠后,队头的都是没人要的。"
LRU 的核心是维护一个有序列表:
- 刚被访问/修改过 的元素,移到列表 尾部 (MRU - Most Recently Used)。
- 很久没动过 的元素,自然沉底到列表 头部 (LRU - Least Recently Used)。
- 当容量满了,直接砍掉 头部 的元素。
2. 您的代码逻辑 (手动维护顺序)
您没有使用 LinkedHashMap(cap, load, true) 开启自动访问排序,而是手动操作:
- GET : 只要读取 key,就把它从原位置删掉 (
remove),再重新插进去 (put)。这会导致它变成"最新插入"的,自动跑到队尾。 - PUT:
-
- 如果 key 存在:先删后加(更新值 + 移到队尾)。
- 如果 key 不存在:
-
-
- 检查容量。如果满了,通过
iterator().next()拿到 队头 (最早插入/最久未更新) 的元素并删除。 - 插入新 key (自动在队尾)。
- 检查容量。如果满了,通过
-
🔍 代码回忆清单
// 题目:LC 146. LRU Cache
class LRUCache {
private final int capacity;
// 这里的 LinkedHashMap 默认是"插入顺序"
// 我们通过代码逻辑把它变成了"访问顺序"
private final Map<Integer, Integer> cache = new LinkedHashMap<>();
public LRUCache(int capacity) {
this.capacity = capacity;
}
public int get(int key) {
// 技巧:remove 返回 null 说明 key 不存在,返回 value 说明存在
// 这一步既判断了存在性,又把旧位置的节点删了
Integer value = cache.remove(key);
if (value != null) {
// 重新 put,这会让该节点跑到链表末尾 (变成最新的)
cache.put(key, value);
return value;
}
return -1;
}
public void put(int key, int value) {
// 1. 如果存在,先删掉 (为了稍后重新插入以更新位置)
if (cache.remove(key) != null) {
cache.put(key, value); // 更新值并移到末尾
return;
}
// 2. 如果不存在,判断是否要腾位置
if (cache.size() == capacity) {
// iterator().next() 获取的是链表头部 (最老的元素)
Integer eldestKey = cache.keySet().iterator().next();
cache.remove(eldestKey);
}
// 3. 插入新元素 (默认在链表末尾)
cache.put(key, value);
}
}
⚠️ 面试高频预警 (Hard Core Mode)
但 LRU 是各大厂面试中 最爱考手写原理 的题目之一。面试官极大概率会说:
"请不要使用 LinkedHashMap,请使用 HashMap + 双向链表 (Double Linked List) 从零实现。"
如果遇到这种情况,您需要构建这样的结构:
- Node 类 :包含
key,val,prev,next。 - HashMap :存储
<Integer, Node>,用于 O(1) 查找 Node。 - DoubleLinkedList:
-
- 虚拟头尾 (dummyHead, dummyTail):避免处理 null 指针。
- moveToTail(Node node):将节点移到尾部。
- removeNode(Node node):删除任意节点。
- addToTail(Node node):在尾部添加节点。
- removeFirst():删除头部的节点(淘汰 LRU)。
⚡ 快速复习 CheckList (LinkedHashMap 版)
-
\] **为什么** **iterator().next()****是最老的?**
-
- 因为
LinkedHashMap默认维护插入顺序。你每次get或put更新时都执行了remove+put,相当于把该元素变成了"最新插入"。那么一直没被动过的元素自然就留在了迭代器的最前面。
- 因为
-
\] **有没有更"Java"的写法?**
-
-
有。继承
LinkedHashMap并重写removeEldestEntry方法。class LRUCache extends LinkedHashMap<Integer, Integer> {
private int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75F, true); // true 表示开启访问排序
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > capacity; // 自动淘汰
}
// get 和 put 甚至不需要重写
}
-
-
- 但在面试中,您的写法比这种继承写法更好,因为它展示了您懂 LRU 的内部逻辑,而不是单纯背 API。
🖼️ 数字演练
Capacity = 2.
- put(1, 1) : Cache:
[1] - put(2, 2) : Cache:
[1, 2](2 是最新的) - get(1):
-
- Remove 1 -> Cache:
[2] - Put 1 -> Cache:
[2, 1](1 变成最新的了)
- Remove 1 -> Cache:
- put(3, 3):
-
- Full! Remove head (2).
- Put 3.
- Cache:
[1, 3].