【力扣-146LRU缓存】Python笔记

0 阅读5分钟

面试必杀技:手撕LRU缓存,打通哈希表与双向链表的“任督二脉”

摘要:还在为 LRU 缓存算法头秃吗?本文带你彻底吃透“哈希表+双向链表”的黄金组合。通过引入虚拟头尾节点简化边界逻辑,配合清晰的代码注释,助你轻松搞定 LeetCode 146 题,从此面试不慌!


📚 核心知识点:为什么是“哈希表 + 双向链表”?

在开始写代码之前,咱们得先聊聊为什么。LRU(Least Recently Used,最近最少使用)算法是面试中的“老熟人”了,它的核心需求有两个,而且非常“苛刻”:

  1. 查找要快get(key) 操作必须是 O(1)O(1)
  2. 顺序调整要快:每次访问或插入数据,都要把它标记为“最近使用”,这也必须是 O(1)O(1)

这就引出了我们的“黄金搭档”:

  • 哈希表(字典) :用来实现 O(1) 的查找。它负责存储 key -> 节点 的映射,让我们能瞬间定位到数据在哪里。

  • 双向链表:用来维护数据的“新鲜度”顺序。

    • 为什么是双向?  因为当我们想删除一个节点时,必须知道它的“前驱”和“后继”。单向链表做不到 O(1)删除。
    • 为什么是链表?  因为链表在插入和删除节点时,不需要像数组那样移动大量元素,效率极高。

通俗理解
把哈希表想象成“地图索引”,把双向链表想象成“排队通道”。

  • 新来的或者刚被叫到号的人,直接通过索引找到,然后被安排到队尾(最近使用)。
  • 队伍满了,就把队头(最久没用)的人踢出去。

📝 题目解析:LeetCode 146. LRU 缓存

题目要求
请你设计并实现一个满足 LRU 缓存约束的数据结构。

  • get(key):如果关键字存在,返回值并将其移到最后;否则返回 -1。
  • put(key, value):插入或更新键值对。如果超出容量,淘汰最久未使用的键。

绝招
引入虚拟头节点(Dummy Head)虚拟尾节点(Dummy Tail) 。这两个“哨兵”能帮我们省去大量 if/else 判断,让代码丝般顺滑!


💡 解题思路:哨兵节点的魔法

如果不使用虚拟节点,我们在删除头节点或尾节点时,需要判断链表是否只有一个节点、是否为空等复杂情况。

有了虚拟头尾节点后:

  • 链表永远不会为空(至少有头尾两个哨兵)。
  • 真实的头节点永远是 head.next(最久未使用)。
  • 真实的尾节点永远是 tail.prev(最近使用)。
  • 插入删除操作统一,不再需要判空。

💻 代码实战(附带详细注释)

下面是 Python 的完整实现,你可以直接复制去运行。

# 1. 定义双向链表节点
class Node:
    def __init__(self, key=0, value=0):
        self.key = key       # 存储key,方便删除节点时从哈希表移除
        self.value = value   # 存储value
        self.prev = None     # 前驱指针
        self.next = None     # 后继指针

class LRUCache:
    def __init__(self, capacity: int):
        # 哈希表:key -> 节点,实现 O(1) 查找
        self.cache = dict()
        
        # 使用伪头部和伪尾部节点,简化边界情况的处理
        # 它们不存储实际数据,只作为链表的锚点
        self.head = Node()
        self.tail = Node()
        
        # 初始化时,将伪头部和伪尾部互指,构成一个空链表
        self.head.next = self.tail
        self.tail.prev = self.head
        
        self.capacity = capacity # 缓存最大容量
        self.size = 0            # 当前缓存大小

    # 【辅助函数】从链表中删除指定节点
    # 核心逻辑:前驱连后继,后继连前驱
    def _remove_node(self, node: Node) -> None:
        node.prev.next = node.next
        node.next.prev = node.prev

    # 【辅助函数】将节点添加到链表尾部(虚拟尾节点的前面)
    # 核心逻辑:插入到 tail.prev 和 tail 之间
    def _add_to_tail(self, node: Node) -> None:
        node.prev = self.tail.prev
        node.next = self.tail
        self.tail.prev.next = node
        self.tail.prev = node

    # 【辅助函数】将节点移动到尾部
    # 逻辑:先删除,再添加到尾部,代表“最近使用”
    def _move_to_tail(self, node: Node) -> None:
        self._remove_node(node)
        self._add_to_tail(node)

    # 【辅助函数】删除链表头节点(虚拟头节点的后面)
    # 逻辑:删除的是 self.head.next,即最久未使用的节点
    def _remove_head(self) -> Node:
        head_node = self.head.next
        self._remove_node(head_node)
        return head_node

    def get(self, key: int) -> int:
        # 如果 key 不在哈希表中,直接返回 -1
        if key not in self.cache:
            return -1
        
        # 如果存在,先通过哈希表定位节点
        node = self.cache[key]
        # 将该节点移到尾部,标记为“最近使用”
        self._move_to_tail(node)
        # 返回节点的值
        return node.value

    def put(self, key: int, value: int) -> None:
        if key in self.cache:
            # 情况1:Key 已存在
            # 更新值,并移到尾部
            node = self.cache[key]
            node.value = value
            self._move_to_tail(node)
        else:
            # 情况2:Key 不存在
            # 创建新节点
            new_node = Node(key, value)
            # 加入哈希表
            self.cache[key] = new_node
            # 加入链表尾部
            self._add_to_tail(new_node)
            self.size += 1
            
            # 检查是否超容量
            if self.size > self.capacity:
                # 超出容量:删除链表头节点(最久未使用)
                removed_node = self._remove_head()
                # 别忘了!还要把哈希表里的记录删掉,防止内存泄漏
                del self.cache[removed_node.key]
                self.size -= 1

🚀 总结与复盘

这道题虽然标为“中等”,但却是考察数据结构的经典题目。

  1. 为什么要存 Key?
    Node 类中存储 key 是为了在删除链表节点时,能快速去 self.cache 中把对应的映射删掉。否则我们只拿到节点对象,却不知道它的 Key 是多少。
  2. 哨兵节点的好处
    headtail 两个空节点,让 addremove 操作不需要判断“是不是第一个节点”或“是不是最后一个节点”,极大地减少了 Bug。
  3. 时间复杂度
    由于哈希表查找是 O(1)O(1),双向链表插入删除也是 O(1)O(1),所以 getput 都完美满足了题目要求。

希望这篇笔记能帮你彻底搞定 LRU!下次面试官问起,直接甩出这段代码,稳!