问题描述
实现一个 LRU(Least Recently Used,最近最少使用)缓存机制的数据结构,要求:
- 初始化时设定缓存容量
- get操作:获取数据值,并将该数据移到最近使用
- put操作:插入或更新数据,并将该数据移到最近使用
- 超出容量时删除最久未使用的数据
- 所有操作都要求O(1)时间复杂度
题目解析
- 核心考点:
- 数据结构设计
- 哈希表和双向链表的结合使用
- 时间复杂度优化
- 要点:
- 如何在O(1)时间内访问数据
- 如何在O(1)时间内维护数据的访问顺序
- 如何在O(1)时间内删除最久未使用的数据
- 考察内容:
- 对常见数据结构的理解和应用
- 代码的组织能力
- 边界情况的处理
解决方案
使用哈希表 + 双向链表的组合:
- 哈希表:保证O(1)时间访问节点
- 双向链表:维护数据的使用顺序
- 表头:最近使用的节点
- 表尾:最久未使用的节点
算法步骤
- 初始化:
- 创建空的哈希表和双向链表
- 设置容量限制
- get操作:
- 通过哈希表查找节点
- 如果存在,将节点移到链表头部
- put操作:
- 如果key存在,更新值并移到头部
- 如果key不存在:
- 如果达到容量,删除尾部节点
- 创建新节点并加入头部
关键点说明
- 使用双向链表的原因:
- 需要O(1)时间删除节点
- 需要O(1)时间将节点移到头部
- 使用哈希表的原因:
- 需要O(1)时间查找节点
- 需要同时维护两个数据结构的一致性
代码实现
class Node:
"""双向链表节点类
用于在双向链表中存储键值对,并维护节点间的前后关系
包含前驱和后继指针,方便在O(1)时间内删除和移动节点
"""
def __init__(self, key=0, value=0):
self.key = key # 存储键,在删除最久未使用节点时需要用到
self.value = value # 存储值
self.prev = None # 前驱节点指针
self.next = None # 后继节点指针
class LRUCache:
"""LRU(最近最少使用)缓存实现类
使用哈希表和双向链表的组合实现LRU缓存
- 哈希表:实现O(1)时间的数据查找
- 双向链表:实现O(1)时间的节点移动和删除
"""
def __init__(self, capacity: int):
"""初始化LRU缓存
Args:
capacity: 缓存的最大容量
创建:
1. 头尾虚拟节点
2. 哈希表
3. 设置容量限制和当前大小
"""
self.capacity = capacity # 缓存容量
self.size = 0 # 当前缓存中的元素个数
self.cache = {} # 哈希表,存储key到节点的映射
# 创建虚拟的头尾节点,简化边界情况的处理
self.head = Node() # 虚拟头节点,最近使用的节点之前
self.tail = Node() # 虚拟尾节点,最久未使用的节点之后
# 初始化时头尾节点相连
self.head.next = self.tail
self.tail.prev = self.head
def _remove_node(self, node: Node) -> None:
"""从双向链表中删除一个节点
Args:
node: 要删除的节点
原理:修改节点前后的指针指向,跳过当前节点
[prev] <-> [node] <-> [next] 变成 [prev] <-> [next]
"""
# 修改前后节点的指针,跳过当前节点
node.prev.next = node.next # 前一个节点的后继指向当前节点的后继
node.next.prev = node.prev # 后一个节点的前驱指向当前节点的前驱
def _add_to_head(self, node: Node) -> None:
"""将节点添加到双向链表的头部
Args:
node: 要添加到头部的节点
原理:在虚拟头节点后插入新节点
[head] <-> [next] 变成 [head] <-> [node] <-> [next]
"""
# 设置node的前后指针
node.prev = self.head # node的前驱指向虚拟头节点
node.next = self.head.next # node的后继指向原来的第一个节点
# 更新原来的节点指针
self.head.next.prev = node # 原来的第一个节点的前驱指向node
self.head.next = node # 虚拟头节点的后继指向node
def get(self, key: int) -> int:
"""获取缓存中key对应的值,并将节点移到链表头部
Args:
key: 要查找的键
Returns:
如果key存在,返回对应的值,并将节点移到头部(最近使用)
如果key不存在,返回-1
"""
# 如果key不在缓存中,返回-1
if key not in self.cache:
return -1
# 如果key存在:
node = self.cache[key] # 1. 从哈希表中获取节点
self._remove_node(node) # 2. 从当前位置删除节点
self._add_to_head(node) # 3. 将节点添加到头部
return node.value # 4. 返回节点的值
def put(self, key: int, value: int) -> None:
"""向缓存中插入或更新值
Args:
key: 键
value: 值
如果key已存在:
1. 更新值
2. 将节点移到头部
如果key不存在:
1. 创建新节点
2. 如果缓存满了,删除最久未使用的节点
3. 将新节点添加到头部
"""
# 如果key已经存在,更新值并移到头部
if key in self.cache:
node = self.cache[key] # 1. 获取已存在的节点
node.value = value # 2. 更新节点的值
self._remove_node(node) # 3. 从当前位置删除节点
self._add_to_head(node) # 4. 将节点添加到头部
else:
# 如果key不存在,创建新节点
node = Node(key, value) # 1. 创建新节点
self.cache[key] = node # 2. 将节点添加到哈希表
self._add_to_head(node) # 3. 将节点添加到链表头部
self.size += 1 # 4. 增加缓存大小计数
# 如果超出容量,删除最久未使用的节点(链表尾部的节点)
if self.size > self.capacity:
lru = self.tail.prev # 1. 获取最久未使用的节点(尾部节点)
self._remove_node(lru) # 2. 从链表中删除该节点
del self.cache[lru.key] # 3. 从哈希表中删除该节点
self.size -= 1 # 4. 减少缓存大小计数
# 使用示例
def example_usage():
"""LRU缓存使用示例"""
# 创建容量为2的缓存
cache = LRUCache(2)
# 添加元素
cache.put(1, 1) # 缓存:[(1,1)]
cache.put(2, 2) # 缓存:[(2,2), (1,1)]
# 访问元素
print(cache.get(1)) # 返回1,缓存:[(1,1), (2,2)]
# 添加新元素,导致淘汰最久未使用的元素
cache.put(3, 3) # 缓存:[(3,3), (1,1)],2被淘汰
# 尝试访问被淘汰的元素
print(cache.get(2)) # 返回-1,因为2已被淘汰
# 再次添加新元素
cache.put(4, 4) # 缓存:[(4,4), (3,3)],1被淘汰
# 验证最终状态
print(cache.get(1)) # 返回-1,因为1已被淘汰
print(cache.get(3)) # 返回3
print(cache.get(4)) # 返回4主要注意点:
双向链表的操作是最容易出错的部分,特别是指针的移动
需要同时维护哈希表和双向链表的一致性
容量满时的删除操作需要同时处理两个数据结构
虚拟头尾节点的使用大大简化了边界情况的处理
图文解释
假设容量为2,执行以下操作:
1. put(1,1)
哈希表:{1: Node(1,1)}
链表:head <-> [1,1] <-> tail
2. put(2,2)
哈希表:{1: Node(1,1), 2: Node(2,2)}
链表:head <-> [2,2] <-> [1,1] <-> tail
3. get(1)
哈希表:{1: Node(1,1), 2: Node(2,2)}
链表:head <-> [1,1] <-> [2,2] <-> tail
4. put(3,3)
删除最久未使用的2
哈希表:{1: Node(1,1), 3: Node(3,3)}
链表:head <-> [3,3] <-> [1,1] <-> tail算法分析
时间复杂度:
- get操作:O(1)
- put操作:O(1)
- 所有操作都只涉及哈希表查找和链表节点移动
空间复杂度:
- O(capacity),存储的节点数不超过容量限制
结论
- 这种设计完美满足LRU缓存的需求
- 通过组合使用两种数据结构实现了O(1)的操作复杂度
- 代码结构清晰,易于维护和扩展
- 使用虚拟头尾节点简化了边界情况的处理
实际应用建议:
- 根据具体需求调整Node类的结构
- 可以添加更多的统计信息(如命中率)
- 考虑添加线程安全机制
- 可以实现序列化接口以支持持久化
从更基础的角度重新解释这道题
这道题目最大的难点确实在于理解题意和联想知识点,对于绝大部分人来说,可能理解题目是最困难的,理解了题目,然后还能能联想到相应的知识点,这个是很有难度的
1. 什么是 LRU 缓存?
想象你有一个书架(这就是缓存),但书架空间有限(这就是容量capacity),你需要按照以下规则放书:
- 每次看完一本书,都要把它放到书架最显眼的位置(最近使用)
- 如果书架满了,需要移除最角落里的那本书(最久未使用)
生活中的例子:
- 浏览器的"最近访问"历史
- 手机的"最近使用"应用列表
- Windows的"最近打开的文件"
2. 题目要求做什么?
设计一个类似的数据结构,支持三个操作:
# 假设我们的书架最多能放2本书
cache = LRUCache(2) # 创建一个容量为2的书架
# 放入新书
cache.put(1, 1) # 放入第1本书
cache.put(2, 2) # 放入第2本书
# 现在书架是:[最近] 2, 1 [最旧]
# 查找并使用某本书
cache.get(1) # 查找并使用第1本书
# 因为使用了1,所以现在书架是:[最近] 1, 2 [最旧]
# 书架满了要放新书
cache.put(3, 3) # 要放入第3本书
# 书架满了,需要移除最旧的2
# 现在书架是:[最近] 3, 1 [最旧]3. 为什么难?
-
概念理解难点:
- "最近使用"是什么意思? - "缓存"是什么? - "逐出"是什么操作? -
操作要求难点:
- 怎么记录"最近"和"最久"? - 怎么快速找到一个元素? - 怎么快速删除一个元素? -
数据结构选择难点:
- 用数组? - 查找快,但删除中间元素慢 - 用链表? - 删除快,但查找慢 - 用哈希表? - 查找快,但无法记录顺序
4. 解题思路的演进
- 最简单的想法(数组):
# 用数组存储,但性能不好
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.items = [] # [(key, value), ...]
def get(self, key):
# 遍历数组找key,找到后移到最前面
for i, (k, v) in enumerate(self.items):
if k == key:
self.items.pop(i) # 删除当前位置
self.items.insert(0, (k, v)) # 插入到最前面
return v
return -1- 改进想法(哈希表):
# 用哈希表提高查找速度,但仍需要记录顺序
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.cache = {} # {key: value}
self.usage = [] # 记录使用顺序- 最终方案(哈希表+双向链表):
# 组合使用两种数据结构
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.cache = {} # 快速查找
self.head = Node() # 双向链表头(最近)
self.tail = Node() # 双向链表尾(最久)
self.head.next = self.tail
self.tail.prev = self.head5. 关键知识点
-
数据结构的特点:
- 数组:按序存储,随机访问快
- 链表:插入删除快,查找慢
- 哈希表:查找快,无序
-
复杂度要求:
- O(1) 意味着操作时间不随数据量增加而增加
- 类似从数组第一个位置取数据的速度
-
设计模式:
- 组合优于继承
- 分而治之(一个结构负责查找,一个负责顺序)
6. 面试建议
- 先说生活中的例子,确保理解题意
- 从简单方案开始,解释为什么需要改进
- 画图展示数据结构,比如:
哈希表: {1: Node1, 2: Node2} 链表: head <-> Node1 <-> Node2 <-> tail
要想能顺利的解决这道题目,需要掌握什么知识,或者说具备什么能力
要解决好这道 LRU 缓存的题目,需要具备以下知识和能力:
1. 基础数据结构知识
-
哈希表/字典:
# 要理解哈希表的特性 - O(1)时间的查找/插入/删除 - key-value的存储方式 - Python中dict的基本操作 -
链表:
# 特别是双向链表的结构 class Node: def __init__(self): self.prev = None # 前驱指针 self.next = None # 后继指针 # 要理解常见操作 - 节点的插入 - 节点的删除 - 节点的移动
2. 面向对象编程能力
-
类的设计:
# 会设计类的结构 class LRUCache: def __init__(self): # 初始化 pass def get(self): # 方法实现 pass -
封装思想:
# 私有辅助方法的设计 def _remove_node(self): pass def _add_to_head(self): pass
3. 系统设计能力
-
需求分析:
- 理解 LRU(最近最少使用)的概念
- 分析操作需求(get/put)
- 理解性能要求(O(1)时间复杂度)
-
数据结构选择:
# 理解为什么需要组合使用两种数据结构 self.cache = {} # 哈希表用于O(1)查找 self.head = Node() # 双向链表用于O(1)维护顺序
4. 算法思维能力
-
复杂度分析:
# 理解时间复杂度 查找节点:O(1) # 使用哈希表 删除节点:O(1) # 使用双向链表 添加节点:O(1) # 使用双向链表 -
优化意识:
- 使用虚拟头尾节点简化操作
- 维护size避免遍历计数
- 在Node中存储key方便删除
5. 代码实现能力
-
指针操作:
# 准确处理指针移动 def _remove_node(self, node): node.prev.next = node.next node.next.prev = node.prev -
边界处理:
# 处理各种边界情况 - 缓存为空 - 缓存已满 - key不存在 - key已存在
6. 问题抽象能力
-
实际问题转换:
- 浏览器的最近访问
- 手机的最近使用应用
- 数据库的缓存机制
-
功能抽象:
- 访问 -> get操作
- 更新 -> put操作
- 淘汰 -> 删除最久未用
7. 调试能力
-
测试用例设计:
# 覆盖各种情况 - 正常插入和获取 - 更新已存在的值 - 删除最久未用 - 容量限制处理 -
Bug定位:
- 指针错误
- 哈希表和链表不一致
- 边界情况处理
8. 代码组织能力
-
模块化:
# 功能分解 - Node类负责节点定义 - 辅助方法负责节点操作 - 主要方法负责缓存逻辑 -
代码可读性:
# 清晰的命名和注释 self.head # 虚拟头节点 self.tail # 虚拟尾节点 self.cache # 哈希表存储
9. 综合运用能力
-
知识整合:
- 综合使用多种数据结构
- 结合面向对象和算法思维
- 平衡性能和可读性
-
实践能力:
- 将思路转化为代码
- 调试和优化实现
- 处理异常情况
培养建议:
- 多练习基础数据结构的操作
- 理解常见设计模式和思想
- 注重代码的组织和重构
- 积累系统设计的经验
- 培养问题分析和抽象能力
评论 (0)


