面试必杀技:手撕LRU缓存,打通哈希表与双向链表的“任督二脉”
摘要:还在为 LRU 缓存算法头秃吗?本文带你彻底吃透“哈希表+双向链表”的黄金组合。通过引入虚拟头尾节点简化边界逻辑,配合清晰的代码注释,助你轻松搞定 LeetCode 146 题,从此面试不慌!
📚 核心知识点:为什么是“哈希表 + 双向链表”?
在开始写代码之前,咱们得先聊聊为什么。LRU(Least Recently Used,最近最少使用)算法是面试中的“老熟人”了,它的核心需求有两个,而且非常“苛刻”:
- 查找要快:
get(key)操作必须是 。 - 顺序调整要快:每次访问或插入数据,都要把它标记为“最近使用”,这也必须是 。
这就引出了我们的“黄金搭档”:
-
哈希表(字典) :用来实现 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
🚀 总结与复盘
这道题虽然标为“中等”,但却是考察数据结构的经典题目。
- 为什么要存 Key?
在Node类中存储key是为了在删除链表节点时,能快速去self.cache中把对应的映射删掉。否则我们只拿到节点对象,却不知道它的 Key 是多少。 - 哨兵节点的好处
head和tail两个空节点,让add和remove操作不需要判断“是不是第一个节点”或“是不是最后一个节点”,极大地减少了 Bug。 - 时间复杂度
由于哈希表查找是 ,双向链表插入删除也是 ,所以get和put都完美满足了题目要求。
希望这篇笔记能帮你彻底搞定 LRU!下次面试官问起,直接甩出这段代码,稳!