题目
了解LRU缓存吗?请详细介绍。
解答
LRU(Least Recently Used,最近最少使用)缓存是一种常见的缓存淘汰策略。当缓存空间不足时,它会优先淘汰那些最长时间未被访问的数据,假设"最近被访问过的数据将来更有可能被再次访问"。下面从原理、数据结构、操作实现、代码示例、复杂度分析、应用场景以及扩展等方面详细解释 LRU 缓存。
1. LRU 缓存的核心思想
-
缓存:存储一部分数据,以加速后续访问。容量有限,当存满时需要淘汰旧数据。
-
淘汰策略:LRU 认为最近使用过的数据是"热"数据,未来被访问的概率更高;而最久未使用的数据是"冷"数据,应当优先淘汰。
-
实现要求 :需要在 O(1) 时间内完成 get (读取数据)和 put(写入数据)操作,同时维护数据的访问顺序。
2. 数据结构设计
为了实现 O(1) 的查找和顺序调整,LRU 缓存通常组合使用两种数据结构:
-
哈希表(HashMap):存储键(key)到对应节点(Node)的映射,提供 O(1) 的键值查找。
-
双向链表(Doubly Linked List):按数据访问时间排序,头部是最近使用的节点,尾部是最久未使用的节点。双向链表允许我们在 O(1) 时间内将任意节点移动到头部,以及删除尾部节点。
节点结构:每个节点包含 key、value、前驱指针 prev 和后继指针 next。
为什么用双向链表而不是单向链表?因为当我们需要将一个节点移动到头部时,需要知道它的前驱节点以完成删除操作,双向链表可以在 O(1) 内完成该操作(单向链表需要遍历找到前驱)。
3. 操作详解
3.1 get(key)
-
如果 key 存在于哈希表中:
-
通过哈希表找到对应的节点。
-
将该节点从链表中当前位置移除,并插入到链表头部(表示最近被使用)。
-
返回节点的 value。
-
-
如果 key 不存在,返回 -1(或 null)。
3.2 put(key, value)
-
如果 key 已存在:
-
更新节点的 value。
-
将该节点移动到链表头部(标记为最近使用)。
-
-
如果 key 不存在:
-
创建新节点,插入到链表头部。
-
在哈希表中添加 key 到该节点的映射。
-
如果当前缓存容量超过上限,则删除链表尾部的节点(最久未使用),同时从哈希表中移除对应的键。
-
注意:移动节点到头部 = 删除原位置 + 插入头部。由于是双向链表,这两个操作都是 O(1)。
4. 代码示例(Python)
下面是一个简单的 LRU 缓存实现,使用 dict 作为哈希表,并自定义双向链表。
本节可参考力扣题目:
python
class DLinkedNode:
def __init__(self, key=0, value=0):
self.key = key
self.value = value
self.prev = None
self.next = None
class LRUCache:
def __init__(self, capacity: int):
self.capacity = capacity
self.cache = {} # key -> node
# 使用虚拟头尾节点,避免边界判断
self.head = DLinkedNode()
self.tail = DLinkedNode()
self.head.next = self.tail
self.tail.prev = self.head
self.size = 0 # 当前节点数量
def _remove_node(self, node):
"""从链表中移除节点"""
prev = node.prev
nxt = node.next
prev.next = nxt
nxt.prev = prev
def _add_to_head(self, node):
"""将节点插入到头部(head之后)"""
node.prev = self.head
node.next = self.head.next
self.head.next.prev = node
self.head.next = node
def _move_to_head(self, node):
"""将节点移动到头部"""
self._remove_node(node)
self._add_to_head(node)
def _pop_tail(self):
"""弹出尾部节点(最久未使用)"""
node = self.tail.prev
self._remove_node(node)
return node
def get(self, key: int) -> int:
if key not in self.cache:
return -1
node = self.cache[key]
self._move_to_head(node) # 更新为最近使用
return node.value
def put(self, key: int, value: int) -> None:
if key in self.cache:
node = self.cache[key]
node.value = value
self._move_to_head(node)
else:
new_node = DLinkedNode(key, value)
self.cache[key] = new_node
self._add_to_head(new_node)
self.size += 1
if self.size > self.capacity:
# 删除尾部节点
tail = self._pop_tail()
del self.cache[tail.key]
self.size -= 1
5. 时间复杂度分析
-
get:哈希表查找 O(1),链表移动 O(1) → 总 O(1)。
-
put:哈希表查找/插入 O(1),链表插入/删除 O(1) → 总 O(1)。
空间复杂度 O(capacity),存储所有键值对和链表节点。
6. 应用场景
-
操作系统内存管理:页面置换算法(但实际中可能采用更复杂的近似算法)。
-
数据库缓存:如 MySQL 的 Buffer Pool、Redis 的内存淘汰(volatile-lru 和 allkeys-lru)。
-
浏览器历史:最近访问的页面保留,旧的被淘汰。
-
CDN 缓存:边缘节点存储热点内容。
-
本地缓存库:如 Guava Cache、Caffeine 等支持 LRU 或类似策略。
7. LRU 的优缺点及变种
优点
-
实现简单,性能高(O(1) 操作)。
-
对热点数据友好,能快速响应频繁访问的键。
缺点
-
无法应对偶发性批量访问:例如一次全表扫描会把大量冷数据带入缓存,挤走真正的热点数据(缓存污染)。
-
只考虑访问时间,不考虑访问频率:一个频繁访问但近期未使用的数据可能会被淘汰(例如周期性任务)。
-
实现中需要维护双向链表,有一定内存开销。
常见变种
-
LRU-K:记录最近 K 次访问的时间戳,淘汰倒数第 K 次访问时间最早的。能更好地抵抗偶发扫描,但实现更复杂。
-
Two Queues (2Q):将数据分为两个队列,一个 FIFO 队列用于初次访问,一个 LRU 队列用于多次访问,减少冷数据冲击。
-
ARC (Adaptive Replacement Cache):动态平衡最近使用和频繁使用的数据,自适应调整。
-
Sliding Window LRU:基于时间窗口的 LRU 近似。
在实际系统中,可能根据业务特点选择或组合这些策略。
8. 总结
LRU 缓存是一种经典且高效的缓存淘汰策略,通过哈希表 + 双向链表可以在 O(1) 时间内完成 get 和 put,同时保持数据按访问时间排序。它广泛应用于需要快速访问且容量有限的场景。理解 LRU 的实现原理不仅有助于面试,也为设计和优化缓存系统打下基础。对于更复杂的访问模式,可以在此基础上改进为 LRU-K 或其他自适应算法。