LRU(Least Recently Used)缓存是一种常见的缓存淘汰策略,属于是算法题中的超高频考题了。
力扣链接 - LRU缓存
文章目录
什么是 LRU 缓存?
LRU 缓存的核心思想是:当缓存空间满时,优先淘汰最近最少使用的数据。就像我们整理书架,经常看的书放在最容易拿到的位置,很久不看的书则被移到角落甚至清理掉。
核心操作
get(key)
: 如果关键字存在于缓存中,则获取关键字的值(正整数),否则返回 -1put(key, value)
: 如果关键字已经存在,则变更其值;如果不存在,则插入该组「关键字-值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据。
应用场景
LRU 缓存在实际应用中非常广泛:
- 数据库缓存层
- Web 应用的页面缓存
- 操作系统的页面置换算法
- Redis 等缓存系统的淘汰策略
数据结构设计
要实现 O(1) 时间复杂度的 LRU 缓存,我们需要结合两种数据结构:
- 哈希表:提供 O(1) 的查找能力
- 双向链表:提供 O(1) 的记录访问顺序的能力
核心思路(如下图):
- 哈希表中存储 key 到链表节点的映射
- 双向链表中按照使用顺序排列节点,最近使用的在头部,最久未使用的在尾部
- 每次访问某个 key(包括更新),就将对应节点移到链表头部
- 当需要淘汰时,直接删除链表尾部节点,同时从 map 中删除该 key!
思路具体到两个真正要实现的核心方法:
get 流程
从 map 中 get(key),如果没有就返回 -1,有则将 node 移动到头部并返回 node.val
put 流程
从 map 中 get(key),如果已经存在,更新 node.val,并且移动到头部;不存在则添加到头部,此时如果容量已超,则从链表中删除最后一个节点,同时从 map 中移除这个节点对应的 key (这也是为什么 node 中需要保存 key)。
上面三个加粗字体的操作可以封装成三个方法方便调用
代码详解
让我们逐步解析 LRU 缓存的 Java 实现:
1. 节点类定义
static class DNode {
int val, key;
DNode pre, next;
// 构造函数和getter/setter方法...
}
双向链表节点,存储 key 和 value,以及前后指针引用。
2. 核心数据结构
DNode head, tail; // 双向链表的虚拟头尾节点
Map<Integer, DNode> keyToNode = new HashMap<>(); // 哈希表映射key到节点
int capacity; // 缓存容量
- 使用 head 和 tail 作为哨兵节点,简化边界情况处理
- 哈希表提供 O(1) 的查找能力
- capacity 控制缓存大小
3. 构造函数
public LRUCache(int capacity) {
head = new DNode();
tail = new DNode();
head.next = tail;
tail.pre = head;
this.capacity = capacity;
}
初始化时,创建双向链表的头尾节点并连接起来,形成一个空链表。
4. get 操作
public int get(int key) {
DNode node = keyToNode.get(key);
if (node == null) {
return -1;
}
moveToHead(node); // 更新使用顺序
return node.getVal();
}
- 查找 key 对应的节点
- 如果不存在,返回 -1
- 如果存在,将节点移到链表头部(表示最近使用),并返回值
5. put 操作
public void put(int key, int value) {
DNode node = keyToNode.get(key);
DNode newNode = new DNode(value);
newNode.setKey(key);
if (node == null) { // key不存在,需要新增
addToHead(newNode);
if (keyToNode.size() == capacity) { // 缓存已满,需要淘汰
DNode tailpre = removeTail();
keyToNode.remove(tailpre.getKey());
}
keyToNode.put(key, newNode);
} else { // key已存在,更新值
node.setVal(value);
moveToHead(node);
}
}
这是最核心的方法,分两种情况:
- key 不存在:创建新节点,添加到链表头部,如果缓存已满,删除尾部节点
- key 已存在:更新节点值,并将节点移到链表头部
6. 辅助方法
private void addToHead(Node node) {
node.next = head.next;
node.prev = head;
head.next.prev = node;
head.next = node;
}
// 移动到头部和移除尾部,可以共用一个移除节点的方法。
private void removeNode(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void moveToHead(Node node) {
// 先移除,再添加...
removeNode(node);
addToHead(node);
}
private Node removeTail() {
Node res = tail.prev;
removeNode(res);
return res;
}
这几个方法封装了对双向链表的基本操作,使主方法只需关注思路
完整代码
class LRUCache {
private static class Node {
int key, value;
Node prev, next;
Node() {}
Node(int key, int value) {
this.key = key;
this.value = value;
}
}
private final int capacity;
private final Map<Integer, Node> keyToNode;
private final Node head, tail;
public LRUCache(int capacity) {
this.capacity = capacity;
this.keyToNode = new HashMap<>(capacity);
// 初始化 head,tail
this.head = new Node();
this.tail = new Node();
head.next = tail;
tail.prev = head;
}
public int get(int key) {
Node node = keyToNode.get(key);
if (node == null) {
return -1;
}
// Move to head (most recently used)
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
Node node = keyToNode.get(key);
if (node != null) {
// 更新已有的
node.value = value;
moveToHead(node);
} else {
// 创建新的
Node newNode = new Node(key, value);
keyToNode.put(key, newNode);
addToHead(newNode);
// 检查是否超过 capacity
if (keyToNode.size() > capacity) {
// Remove tail (least recently used)
Node tail = removeTail();
keyToNode.remove(tail.key);
}
}
}
private void addToHead(Node node) {
node.next = head.next;
node.prev = head;
head.next.prev = node;
head.next = node;
}
// 移动到头部和移除尾部,可以共用一个移除节点的方法。
private void removeNode(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void moveToHead(Node node) {
// 先移除,再添加...
removeNode(node);
addToHead(node);
}
private Node removeTail() {
Node res = tail.prev;
removeNode(res);
return res;
}
}
复杂度分析
-
时间复杂度:O(1),所有操作均为常数时间
- get 操作:哈希表查询 O(1),移动节点 O(1)
- put 操作:哈希表查询/插入 O(1),链表操作 O(1)
-
空间复杂度:O(capacity),存储 capacity 个键值对的开销
常见错误和注意事项
- 忘记双向链表:单向链表无法在 O(1) 时间内删除任意节点
- 忘记在链表操作时同步更新哈希表:会导致数据不一致
- 链表操作顺序错误:这个先熟练掌握链表基本操作即可
- 忘记存储 key:删除尾节点时需要知道对应的 key 才能从哈希表中删除
总结
LRU 缓存是一种优雅而高效的缓存淘汰策略,通过哈希表和双向链表的结合,实现了 O(1) 时间复杂度的各项操作。理解其核心思想和实现细节,不仅对面试有帮助,在实际工作中也能指导我们更好地设计缓存系统。
记住这个算法的关键点:
- 双向链表 + 哈希表的组合
- Node 额外存储 key,且使用 head, tail 虚拟头尾节点。
- 每次访问(包括更新!)都更新链表顺序
- 缓存满时删除尾部节点,同时删除哈希表的 key!
如有问题或建议,欢迎在评论区留言讨论。