分享 | LeetCode 146 - LRU缓存详细解答
804
2024.11.03
2024.11.03
发布于 上海市

问题描述

实现一个 LRU(Least Recently Used,最近最少使用)缓存机制的数据结构,要求:

  1. 初始化时设定缓存容量
  2. get操作:获取数据值,并将该数据移到最近使用
  3. put操作:插入或更新数据,并将该数据移到最近使用
  4. 超出容量时删除最久未使用的数据
  5. 所有操作都要求O(1)时间复杂度

题目解析

  1. 核心考点:
    • 数据结构设计
    • 哈希表和双向链表的结合使用
    • 时间复杂度优化
  2. 要点:
    • 如何在O(1)时间内访问数据
    • 如何在O(1)时间内维护数据的访问顺序
    • 如何在O(1)时间内删除最久未使用的数据
  3. 考察内容:
    • 对常见数据结构的理解和应用
    • 代码的组织能力
    • 边界情况的处理

解决方案

使用哈希表 + 双向链表的组合:

  • 哈希表:保证O(1)时间访问节点
  • 双向链表:维护数据的使用顺序
    • 表头:最近使用的节点
    • 表尾:最久未使用的节点

算法步骤

  1. 初始化:
    • 创建空的哈希表和双向链表
    • 设置容量限制
  2. get操作:
    • 通过哈希表查找节点
    • 如果存在,将节点移到链表头部
  3. put操作:
    • 如果key存在,更新值并移到头部
    • 如果key不存在:
      • 如果达到容量,删除尾部节点
      • 创建新节点并加入头部

关键点说明

  1. 使用双向链表的原因:
    • 需要O(1)时间删除节点
    • 需要O(1)时间将节点移到头部
  2. 使用哈希表的原因:
    • 需要O(1)时间查找节点
  3. 需要同时维护两个数据结构的一致性

代码实现

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),存储的节点数不超过容量限制

结论

  1. 这种设计完美满足LRU缓存的需求
  2. 通过组合使用两种数据结构实现了O(1)的操作复杂度
  3. 代码结构清晰,易于维护和扩展
  4. 使用虚拟头尾节点简化了边界情况的处理

实际应用建议:

  1. 根据具体需求调整Node类的结构
  2. 可以添加更多的统计信息(如命中率)
  3. 考虑添加线程安全机制
  4. 可以实现序列化接口以支持持久化

从更基础的角度重新解释这道题

这道题目最大的难点确实在于理解题意和联想知识点,对于绝大部分人来说,可能理解题目是最困难的,理解了题目,然后还能能联想到相应的知识点,这个是很有难度的

1. 什么是 LRU 缓存?

想象你有一个书架(这就是缓存),但书架空间有限(这就是容量capacity),你需要按照以下规则放书:

  • 每次看完一本书,都要把它放到书架最显眼的位置(最近使用)
  • 如果书架满了,需要移除最角落里的那本书(最久未使用)

生活中的例子:

  1. 浏览器的"最近访问"历史
  2. 手机的"最近使用"应用列表
  3. 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. 为什么难?

  1. 概念理解难点:

    - "最近使用"是什么意思?
    - "缓存"是什么?
    - "逐出"是什么操作?
  2. 操作要求难点:

    - 怎么记录"最近""最久"- 怎么快速找到一个元素?
    - 怎么快速删除一个元素?
  3. 数据结构选择难点:

    - 用数组? 
      - 查找快,但删除中间元素慢
    - 用链表?
      - 删除快,但查找慢
    - 用哈希表?
      - 查找快,但无法记录顺序

4. 解题思路的演进

  1. 最简单的想法(数组):
# 用数组存储,但性能不好
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
  1. 改进想法(哈希表):
# 用哈希表提高查找速度,但仍需要记录顺序
class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.cache = {}  # {key: value}
        self.usage = []  # 记录使用顺序
  1. 最终方案(哈希表+双向链表):
# 组合使用两种数据结构
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.head

5. 关键知识点

  1. 数据结构的特点:

    • 数组:按序存储,随机访问快
    • 链表:插入删除快,查找慢
    • 哈希表:查找快,无序
  2. 复杂度要求:

    • O(1) 意味着操作时间不随数据量增加而增加
    • 类似从数组第一个位置取数据的速度
  3. 设计模式:

    • 组合优于继承
    • 分而治之(一个结构负责查找,一个负责顺序)

6. 面试建议

  1. 先说生活中的例子,确保理解题意
  2. 从简单方案开始,解释为什么需要改进
  3. 画图展示数据结构,比如:
    哈希表:  {1: Node1, 2: Node2}
    链表:    head <-> Node1 <-> Node2 <-> tail

要想能顺利的解决这道题目,需要掌握什么知识,或者说具备什么能力

要解决好这道 LRU 缓存的题目,需要具备以下知识和能力:

1. 基础数据结构知识

  1. 哈希表/字典:

    # 要理解哈希表的特性
    - O(1)时间的查找/插入/删除
    - key-value的存储方式
    - Python中dict的基本操作
  2. 链表:

    # 特别是双向链表的结构
    class Node:
        def __init__(self):
            self.prev = None  # 前驱指针
            self.next = None  # 后继指针
    
    # 要理解常见操作
    - 节点的插入
    - 节点的删除
    - 节点的移动

2. 面向对象编程能力

  1. 类的设计:

    # 会设计类的结构
    class LRUCache:
        def __init__(self):
            # 初始化
            pass
        
        def get(self):
            # 方法实现
            pass
  2. 封装思想:

    # 私有辅助方法的设计
    def _remove_node(self):
        pass
    
    def _add_to_head(self):
        pass

3. 系统设计能力

  1. 需求分析:

    • 理解 LRU(最近最少使用)的概念
    • 分析操作需求(get/put)
    • 理解性能要求(O(1)时间复杂度)
  2. 数据结构选择:

    # 理解为什么需要组合使用两种数据结构
    self.cache = {}         # 哈希表用于O(1)查找
    self.head = Node()      # 双向链表用于O(1)维护顺序

4. 算法思维能力

  1. 复杂度分析:

    # 理解时间复杂度
    查找节点:O(1)    # 使用哈希表
    删除节点:O(1)    # 使用双向链表
    添加节点:O(1)    # 使用双向链表
  2. 优化意识:

    • 使用虚拟头尾节点简化操作
    • 维护size避免遍历计数
    • 在Node中存储key方便删除

5. 代码实现能力

  1. 指针操作:

    # 准确处理指针移动
    def _remove_node(self, node):
        node.prev.next = node.next
        node.next.prev = node.prev
  2. 边界处理:

    # 处理各种边界情况
    - 缓存为空
    - 缓存已满
    - key不存在
    - key已存在

6. 问题抽象能力

  1. 实际问题转换:

    • 浏览器的最近访问
    • 手机的最近使用应用
    • 数据库的缓存机制
  2. 功能抽象:

    • 访问 -> get操作
    • 更新 -> put操作
    • 淘汰 -> 删除最久未用

7. 调试能力

  1. 测试用例设计:

    # 覆盖各种情况
    - 正常插入和获取
    - 更新已存在的值
    - 删除最久未用
    - 容量限制处理
  2. Bug定位:

    • 指针错误
    • 哈希表和链表不一致
    • 边界情况处理

8. 代码组织能力

  1. 模块化:

    # 功能分解
    - Node类负责节点定义
    - 辅助方法负责节点操作
    - 主要方法负责缓存逻辑
  2. 代码可读性:

    # 清晰的命名和注释
    self.head  # 虚拟头节点
    self.tail  # 虚拟尾节点
    self.cache # 哈希表存储

9. 综合运用能力

  1. 知识整合:

    • 综合使用多种数据结构
    • 结合面向对象和算法思维
    • 平衡性能和可读性
  2. 实践能力:

    • 将思路转化为代码
    • 调试和优化实现
    • 处理异常情况

培养建议:

  1. 多练习基础数据结构的操作
  2. 理解常见设计模式和思想
  3. 注重代码的组织和重构
  4. 积累系统设计的经验
  5. 培养问题分析和抽象能力
评论 (0)