LeetCode 146. LRU 缓存
本节目标
用哈希表定位节点、双向链表维护最近使用顺序,实现常数时间缓存操作。
这道题承接数据结构综合。它的难点不是单独使用哈希表或链表,而是在每一次操作后让“键能定位节点”和“节点顺序反映最近使用”同时成立。
题意与约束
实现固定容量的缓存,支持 get(key) 与 put(key, value)。访问或写入一个键后,它成为最近使用;容量已满时,插入新键前要淘汰最久未使用的键。题目要求两种操作平均为 O(1)。
单用哈希表能快速按键找到值,却不知道谁最久未使用;单用链表能表示顺序,却要线性查找某个键所在节点。两个需求分别交给不同结构。
两个结构各自负责什么
- 哈希表保存
key -> node,因此get或覆盖已有键时能直接定位链表节点。 - 双向链表从头到尾表示“最近使用到最久使用”。头部紧后方是最新节点,尾部紧前方是最旧节点,移动和删除都是常数时间。
头、尾哨兵不保存真实缓存数据。它们让插入头部、删除尾部前节点不必为第一个或最后一个真实节点写特殊分支。
同步不变量与四种更新
任意时刻都保持三条不变量:哈希表中的每个键恰好对应一个真实链表节点;每个真实节点恰好有一个哈希表键指向它;链表从头到尾严格按最近使用到最久使用排列。
- 插入新键:创建节点,同时写入哈希表,再插到头哨兵后。
- 访问命中:哈希表找到节点,把它从原位置摘下并插到头部,返回值不变。
- 覆盖已有键:不创建第二个节点;先更新节点值,再把同一个节点移到头部。
- 容量淘汰:若节点数超过容量,取尾哨兵前的节点,从链表摘下、从哈希表删除对应键,再释放或丢弃节点。
只移动链表不更新映射会造成以后定位到脱链节点;只删除映射不删除链表会留下错误的淘汰候选。同步更新是本题正确性的核心。
代码实现
C++ 为真实节点动态分配,并在析构函数中遍历释放包括哨兵在内的全部节点;Python 以独立的 Node 类保存键、值和双向链接。两种实现的 get 命中和 put 覆盖都调用同一移动到头部操作。
- C++
- Python
C++17
#include <unordered_map>
using namespace std;
class Node {
public:
int key;
int value;
Node* prev;
Node* next;
Node(int key, int value) : key(key), value(value), prev(nullptr), next(nullptr) {}
};
class LRUCache {
public:
LRUCache(int capacity) : capacity(capacity), head(new Node(0, 0)), tail(new Node(0, 0)) {
head->next = tail;
tail->prev = head;
}
~LRUCache() {
Node* current = head;
while (current != nullptr) {
Node* next = current->next;
delete current;
current = next;
}
}
int get(int key) {
auto it = nodes.find(key);
if (it == nodes.end()) {
return -1;
}
moveToFront(it->second);
return it->second->value;
}
void put(int key, int value) {
auto it = nodes.find(key);
if (it != nodes.end()) {
it->second->value = value;
moveToFront(it->second);
return;
}
Node* node = new Node(key, value);
nodes[key] = node;
addToFront(node);
if (static_cast<int>(nodes.size()) > capacity) {
Node* leastRecent = tail->prev;
remove(leastRecent);
nodes.erase(leastRecent->key);
delete leastRecent;
}
}
private:
int capacity;
unordered_map<int, Node*> nodes;
Node* head;
Node* tail;
void remove(Node* node) {
node->prev->next = node->next;
node->next->prev = node->prev;
}
void addToFront(Node* node) {
node->next = head->next;
node->prev = head;
head->next->prev = node;
head->next = node;
}
void moveToFront(Node* node) {
remove(node);
addToFront(node);
}
};
Python 3
class Node:
def __init__(self, key: int, value: int):
self.key = key
self.value = value
self.prev = None
self.next = None
class LRUCache:
def __init__(self, capacity: int):
self.capacity = capacity
self.nodes: dict[int, Node] = {}
self.head = Node(0, 0)
self.tail = Node(0, 0)
self.head.next = self.tail
self.tail.prev = self.head
def get(self, key: int) -> int:
node = self.nodes.get(key)
if node is None:
return -1
self._move_to_front(node)
return node.value
def put(self, key: int, value: int) -> None:
node = self.nodes.get(key)
if node is not None:
node.value = value
self._move_to_front(node)
return
node = Node(key, value)
self.nodes[key] = node
self._add_to_front(node)
if len(self.nodes) > self.capacity:
least_recent = self.tail.prev
self._remove(least_recent)
del self.nodes[least_recent.key]
def _remove(self, node: Node) -> None:
node.prev.next = node.next
node.next.prev = node.prev
def _add_to_front(self, node: Node) -> None:
node.next = self.head.next
node.prev = self.head
self.head.next.prev = node
self.head.next = node
def _move_to_front(self, node: Node) -> None:
self._remove(node)
self._add_to_front(node)
复杂度分析
- 时间复杂度:
get和put都是期望O(1)。哈希表定位、链表摘除、插入头部和删除尾部前节点均为常数次操作。 - 空间复杂度:
O(capacity)。哈希表与双向链表各保存至多容量个真实节点。
易错点
get命中后忘记更新顺序:随后会淘汰刚访问过的键。- 覆盖已有键时新建节点:旧节点或旧映射会残留,破坏一键一节点。
- 淘汰后只从链表删除:哈希表仍能找到已失效节点。
- 不使用哨兵却遗漏空链表或首尾节点边界。
- 让多个缓存实例共享节点表:不同容量的对象必须维护独立状态。
模式迁移
组合结构先拆分职责,再为每次更新列出所有需同步的状态。哈希表负责定位、双向链表负责顺序的模式也可迁移到需要按键删除的队列、滑动窗口和在线调度问题;本章前面的哈希表只解决了其中的快速定位部分。