一、题目描述
请你设计并实现一个满足 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 <= 3000
- 0 <= key <= 10000
- 0 <= value <= 10^5
- 最多调用 2 * 10^5 次 get 和 put
二、解题思路总览
核心问题: 如何在 O(1) 时间复杂度内完成 get 和 put 操作?
解决方案:哈希表 + 双向链表
| 数据结构 | 作用 |
|---|---|
| 哈希表 unordered_map | 存储 key -> 节点的映射,实现 O(1) 查找 |
| 双向链表(循环) | 按访问顺序存储节点,最近使用的在头部,最久未使用的在尾部 |
| 操作 | 哈希表操作 | 链表操作 |
|---|---|---|
| get(key) | O(1) 查找节点 | O(1) 移动到头部 |
| put(key, value) | O(1) 插入/更新 | O(1) 插入头部,O(1) 删除尾部 |
时间复杂度:O(1) for both get and put
三、完整代码
cpp
struct Node {
int key;
int value;
Node* prev;
Node* next;
Node(int k = 0, int v = 0) : key(k), value(v) {}
};
class LRUCache {
private:
int _capacity;
Node* dummy;
unordered_map<int, Node*> key_to_node;
// 将节点从双向链表中删除
void remove(Node* x) {
x->prev->next = x->next;
x->next->prev = x->prev;
}
// 将节点插入到链表头部(最近使用)
void push_front(Node* x) {
x->prev = dummy;
x->next = dummy->next;
x->prev->next = x;
x->next->prev = x;
}
// 获取节点,并将节点移到头部
Node* get_node(int key) {
auto it = key_to_node.find(key);
if (it == key_to_node.end()) return NULL;
Node* node = it->second;
remove(node);
push_front(node);
return node;
}
public:
LRUCache(int capacity) : _capacity(capacity), dummy(new Node()) {
dummy->prev = dummy;
dummy->next = dummy;
}
int get(int key) {
Node* node = get_node(key);
return node ? node->value : -1;
}
void put(int key, int value) {
Node* node = get_node(key);
if (node) {
node->value = value;
return;
}
// key 不存在,新建节点并插入头部
key_to_node[key] = node = new Node(key, value);
push_front(node);
// 容量超限时,删除最久未使用的节点(尾部)
if (key_to_node.size() > _capacity) {
Node* back_node = dummy->prev;
key_to_node.erase(back_node->key);
remove(back_node);
delete back_node;
}
}
};
四、算法流程图
4.1 get 操作流程
输入:key
[Step 1] 在哈希表中查找 key
|
v
key_to_node.find(key) 找到?
|否 |是
v v
返回 NULL [Step 2] 获取节点 node
|
v
【从链表中删除 node】
|
v
remove(node)
|
v
【将 node 插入头部】
|
v
push_front(node)
|
v
返回 node->value
4.2 put 操作流程
输入:key, value
[Step 1] 调用 get_node(key) 查找节点
|
v
node 存在?
|是 |否
v v
[Step 2] 更新值 [Step 3] 新建节点
| |
v v
node->value = value key_to_node[key] = node
| | = new Node(key, value)
v v
【返回】 push_front(node)
|
v
[Step 4] 判断容量
|
v
key_to_node.size() > _capacity ?
|否
v
【返回】 |
v
[Step 5] 删除最久未使用
|
v
back_node = dummy->prev
|
v
key_to_node.erase(back_node->key)
|
v
remove(back_node)
|
v
delete back_node
|
v
【返回】
4.3 remove 节点操作流程
输入:待删除节点 x
操作前链表状态:
... -> x->prev -> x -> x->next -> ...
[Step 1] x->prev->next = x->next
将 x 的前驱节点的 next 指向 x 的后继节点
|
v
操作后(中间状态):
... -> x->prev ----> x->next -> ...
[Step 2] x->next->prev = x->prev
将 x 的后继节点的 prev 指向 x 的前驱节点
|
v
操作后(完成):
... -> x->prev -> x->next -> ...
x 节点已从链表中完全脱离
4.4 push_front 插入头部操作流程
输入:待插入节点 x
操作前链表状态:
dummy -> nodeA -> nodeB -> ... -> tail
^
|
x(待插入)
[Step 1] x->prev = dummy
x->next = dummy->next
将 x 的前驱指向 dummy,后继指向原第一个节点
|
v
[Step 2] x->prev->next = x
即 dummy->next = x
|
v
[Step 3] x->next->prev = x
即原来第一个节点的 prev 指向 x
|
v
操作后:
dummy -> x -> nodeA -> nodeB -> ... -> tail
4.5 整体数据流
初始状态(capacity = 2):
dummy -> dummy(空链表)
put(1, 1) 后:
dummy -> [key=1, val=1] -> dummy(循环)
put(2, 2) 后:
dummy -> [key=2, val=2] -> [key=1, val=1] -> dummy(循环)
get(1) 后(1 被移到头部):
dummy -> [key=1, val=1] -> [key=2, val=2] -> dummy(循环)
put(3, 3) 后(容量已满,删除最久未使用的 2):
dummy -> [key=3, val=3] -> [key=1, val=1] -> dummy(循环)
put(4, 4) 后(容量已满,删除最久未使用的 1):
dummy -> [key=4, val=4] -> [key=3, val=3] -> dummy(循环)
get(1) 后(1 不存在):
返回 -1
五、逐行解析
5.1 Node 结构体
cpp
struct Node {
int key;
int value;
Node* prev;
Node* next;
Node(int k = 0, int v = 0) : key(k), value(v) {}
};
为什么要存储 key?
当需要删除尾部节点时,不仅要从链表中删除,还要从哈希表中删除对应条目。而哈希表的 key 就是通过 Node::key 来获取的。
5.2 remove 节点
cpp
void remove(Node* x) {
x->prev->next = x->next;
x->next->prev = x->prev;
}
原理: 标准的双向链表删除节点操作。通过调整前后节点的指针,将 x 从链表中脱离。
5.3 push_front 插入头部
cpp
void push_front(Node* x) {
x->prev = dummy;
x->next = dummy->next;
x->prev->next = x;
x->next->prev = x;
}
原理: 将新节点插入 dummy 和 dummy->next 之间。由于是循环链表,dummy->next 始终指向最近使用的节点。
5.4 get_node 获取并标记为最近使用
cpp
Node* get_node(int key) {
auto it = key_to_node.find(key);
if (it == key_to_node.end()) return NULL;
Node* node = it->second;
remove(node);
push_front(node);
return node;
}
原理: 先在哈希表中查找 key,如果不存在返回 NULL。如果存在,先从链表中删除该节点,再插入到头部,表示最近使用过。
5.5 get 获取值
cpp
int get(int key) {
Node* node = get_node(key);
return node ? node->value : -1;
}
原理: 调用 get_node,如果返回 NULL 则说明 key 不存在,返回 -1;否则返回节点的值。
5.6 put 插入或更新
cpp
void put(int key, int value) {
Node* node = get_node(key);
if (node) {
node->value = value;
return;
}
// key 不存在,新建节点
key_to_node[key] = node = new Node(key, value);
push_front(node);
// 容量超限时删除最久未使用的
if (key_to_node.size() > _capacity) {
Node* back_node = dummy->prev;
key_to_node.erase(back_node->key);
remove(back_node);
delete back_node;
}
}
两种情况:
| 情况 | 处理逻辑 |
|---|---|
| key 已存在 | 更新 value,然后将节点移到头部 |
| key 不存在 | 新建节点插入头部,如果超容量则删除尾部最久未使用的 |
5.7 构造函数初始化循环链表
cpp
LRUCache(int capacity) : _capacity(capacity), dummy(new Node()) {
dummy->prev = dummy;
dummy->next = dummy;
}
为什么用循环链表?
dummy 是一个哑节点,prev 和 next 都指向自己形成循环。这样无论链表是否为空,都有一个统一的头节点,插入和删除操作不需要特殊判断边界情况。
dummy->prev 指向谁?
dummy->prev 指向最久未使用的节点,也就是链表的尾部。
六、复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| get | O(1) | - |
| put | O(1) | - |
| 整体 | - | O(capacity) |
空间复杂度: O(capacity),哈希表和双向链表最多存储 capacity 个节点。
为什么是 O(1)?
- 哈希表查找:O(1)
- 双向链表插入/删除:O(1)(已知节点指针)
- remove + push_front:都是 O(1)
七、面试追问
| 问题 | 回答要点 |
|---|---|
| 为什么不只用哈希表? | 哈希表无法维护「最近使用」的访问顺序,无法实现 LRU 淘汰策略 |
| 为什么不只用双向链表? | 链表按值查找是 O(n),无法满足题目 O(1) 的要求 |
| 为什么要用双向链表而不是单向链表? | 删除节点需要知道前驱节点,单向链表无法 O(1) 完成删除 |
| 循环链表有什么好处? | dummy 哑节点使得插入和删除操作不需要特殊判断空链表的情况 |
| 删除最久未使用的节点一定是 dummy->prev 吗? | 是的,链表从头(dummy->next)到尾(dummy->prev)是「最近到最久」的顺序 |
| Node 结构体中为什么存 key? | 删除尾部节点时需要从哈希表中擦除条目,哈希表的 key 通过 node->key 获取 |
| 容量为 1 的边界情况如何处理? | put 时新建节点然后删除尾部,get 时正常,无特殊处理 |
| 哈希表和链表的对应关系是什么? | key_to_node[key] 存储的是该 key 对应的链表节点指针,链表节点中存有完整的 key 和 value |
八、相关题目
| 题号 | 题目 | 关键点 |
|---|---|---|
| 146 | LRU 缓存 | 本题 |
| 460 | LFU 缓存 | 多重哈希表,比 LRU 更复杂 |
| 432 | 全 O(1) 的数据结构 | 计数器,支持 inc 和 dec |
| 380 | O(1) 时间插入、删除和获取随机元素 | 哈希表 + 数组 |
| 381 | O(1) 时间插入、删除和获取随机元素(可重复) | 数组 + 哈希表(位置列表) |
九、总结
| 要点 | 内容 |
|---|---|
| 核心数据结构 | 哈希表 + 双向循环链表 |
| 哈希表作用 | 存储 key -> 节点的映射,O(1) 查找 |
| 双向链表作用 | 维护访问顺序,O(1) 插入/删除 |
| dummy 节点 | 哑节点,省去空链表的边界判断 |
| get 操作 | 哈希表查找 + 移动到头部 |
| put 操作 | 哈希表查找 + 更新或插入 + 超容量时删除尾部 |
| 时间复杂度 | O(1) for both get and put |
| 空间复杂度 | O(capacity) |
| 记忆口诀 | 哈希表加速,双向循环定顺序,哑节点省边界,最近在前,最久在后 |