LRU 缓存,这个在面试和工程里都高频出现的名字,作为整套算法课的最后一课,我的第一反应是“课程设计的人很懂行”。前面学了那么多数组、链表、哈希表、树,最后用一个 LRU 缓存把哈希表和双向链表串起来,既复习了之前的基础,又给了你一个能直接拿出来讨论、甚至能落到生产环境的设计原型。你去看 LeetCode 146,这题收藏量常年靠前,几乎每个大厂面试官题库里都有它的影子。
这篇文章我就按手写实现的角度,把这个题拆开揉碎:为什么 LRU 是最常用的淘汰策略,为什么标准答案一定是哈希表加双向链表,动手写的时候要处理哪些边界细节,以及我在自己代码里踩过的坑。你不用把它当成一篇“题解”来读,我更希望你看完之后能不看任何参考,自己把代码默写出来,并且能回答面试官后续追问的“为什么不用数组”“为什么不用单向链表”“如果并发怎么办”。
1. 为什么 LRU 缓存是最后一课的完美收官
我之前带过不少新人,发现一个规律:如果让人一口气学完哈希表和链表,他多半会觉得这两个东西是独立的知识点。LRU 这道题最巧妙的地方,就是强迫你把它们组合成一个整体。哈希表负责快速找到数据,链表负责记录数据的“新旧程度”,两者一配合,读写效率都能到 O(1),这个设计思路本身比代码重要得多。
1.1 从缓存淘汰说起
先聊一个最朴素的问题:为什么需要缓存?因为 IO 慢、算力贵。CPU 访问寄存器是纳秒级,访问内存可能要上百纳秒,如果每次都去数据库读一条记录,高并发下系统会被拖垮。所以我们会把热点数据放在一个访问更快的地方,比如 Redis、本地内存 Cache,但存储空间是有限的,不可能把所有数据都塞到缓存里。当缓存满了,又要写入新数据,就必须淘汰一个“旧数据”。淘汰哪个?这里就引出了各种策略。
FIFO(先进先出)按到达时间淘汰,谁先来谁先走,完全不考虑数据是不是刚被访问过;LFU(最不经常使用)按访问次数淘汰,谁用得少谁先走,但需要维护频率统计,而且存在“历史高频但最近已不再使用”的冷数据滞留问题。而 LRU 的思路更符合大多数业务场景的局部性原理:如果一个数据在最近一段时间被频繁访问,那么未来被再次访问的概率也高;如果一个数据很长时间没被碰过,那未来大概率也用不到了,所以优先淘汰它。
拿手机后台应用来类比就很直观。很多人用 iPhone 或 Android 的习惯是上滑清理后台卡片,系统其实也是按“最近最少使用”的思路来管理内存的——如果内存不够,优先杀掉最久没有打开过的 App,而不是杀掉刚刚还在用的那个。你在 App 间切换时,最近用的 App 会排在前面,这就是 LRU 的“最近”权重。
1.2 LRU 的核心思想与应用场景
LRU 是“Least Recently Used”的缩写,完整的决策逻辑只有两条:数据被访问时,把它标记为最近使用;缓存满了需要淘汰时,移除最久没被使用的那条数据。你可以想象成维护一条队伍,队头是最新被使用的人,队尾是最久没被使用的人。每当有人被叫到名字(get 命中),他就从原来位置跑到队头重新排队;新来的人直接站到队头;如果队伍长度超过上限,就把队尾的人踢出去。
这套机制不只是面试题,它在真实系统里用得非常多。Redis 的 maxmemory-policy 支持 allkeys-lru,操作系统页面置换算法里有 LRU 近似实现,MySQL 的 Buffer Pool 也是基于改进版 LRU 管理缓存页。我们平时做业务,如果要对一个外部 API 做短期缓存,或者手写一个最近浏览记录列表,LRU 都是非常自然的选择。所以学会自己实现一个 LRU Cache,并不是刷题刷了个寂寞,而是真正能在组件设计里用上的东西。
2. 方案选型:为什么是哈希表加双向链表
LRU 这个需求一听,很多人第一反应是“这有什么难的?我用数组、用队列都能实现”。没错,功能上都能实现,但性能完全不是一个量级。LeetCode 146 要求的是 get 和 put 的平均时间复杂度都是 O(1),这一个条件就把很多朴素方案挡在了门外。
2.1 先想想暴力解法的问题
假设我们用一个普通的数组来存数据,并且按访问顺序排列。get 一个 key 时,得先遍历找 key,数组遍历是 O(n),找到之后还要把它挪到数组最前面,又涉及后续元素的搬移,还是 O(n)。put 时如果 key 已存在,同样要找到并移动;如果 key 不存在且容量满了,删除最后一个元素是 O(1),但新元素要从头部插入,仍然要移动所有元素,O(n)。当数据量到十万、百万级别,这种方案基本不可用。
如果用 Java 的 LinkedList,插入删除是 O(1),但查找某个 key 依然是 O(n),因为链表不支持随机访问,必须从头遍历。反过来,如果我们只用 HashMap,查找是 O(1),但哈希表本身不维护顺序,我无法知道哪个 key 是“最久没被使用”的。所以问题的本质是:我需要一个结构,既能按 key 快速查找,又能按时间顺序维护全局序列。单独一种基础结构做不到,那就组合。
2.2 哈希表负责 O(1) 查找,双向链表负责 O(1) 淘汰
标准设计是:哈希表 + 双向链表。哈希表的 key 存题目给定的 key,value 存链表节点的引用;链表节点存 key 和 value,同时是全局访问顺序的载体。所有数据都放在链表里,链表头表示最近使用,链表尾表示最久未使用。每次访问一个 key,就从哈希表拿到它的节点,然后把节点从链表当前位置摘下来,放到链表头部;每次新写入,就先建节点放到头部,再写入哈希表;如果容量已满,就把链表尾部的节点删掉,同时删除哈希表里对应的 key。
这样每个操作都只需要常数次指针操作和哈希表读写。get 命中时,哈希表查一次 O(1),链表节点移除并头插是 O(1);put 新增时,头插和可能发生的尾部删除都是 O(1)。之所以不用单向链表,是因为删掉一个节点需要知道它的前驱节点才能把前驱的 next 指向它的后继,单向链表必须遍历才能找到前驱,而双向链表每个节点都额外存了 prev 指针,删除任意节点都不需要遍历,时间复杂度保持 O(1)。
这里顺便解释一下面试官特别爱问的一个点:为什么哈希表的 value 不直接存业务 value,而是存链表节点?因为如果我们只知道 key,想把它对应的链表节点“移动到头”,就必须能直接定位到这个节点。如果 Map 只存 value,你还是要去链表里遍历找“哪个节点的 key 等于 target”,那就又退化成 O(n) 了。把节点引用放在 Map 的 value 里,相当于给链表加了一个“任意跳转”的索引,这才是组合结构的关键。
2.3 为什么不选数组、单向链表、LinkedHashMap 源码思路
有些聪明人会说:Java 里有现成的 LinkedHashMap,它内部就是哈希表加双向链表,重写 removeEldestEntry 就能得到 LRU Cache。这确实是工程上的常见捷径,但放到面试或自我提升的场景,我建议你还是先手写一遍,因为 LinkedHashMap 把最关键的“访问后把节点移到末尾”的操作封装在黑盒里了,你不知道它怎么做到 O(1),也不知道它的链表顺序是插入序还是访问序,很容易翻车。在 LeetCode 上能用一行 LinkedHashMap 秒掉的题,在大厂面试官面前反而可能暴露薄弱点。
数组方案为什么不行,上面已经说过,核心问题是“移动元素”的代价太高。单向链表虽然头插和删除首节点很快,但删除任意节点时需要找到前驱,这一条就否掉了它在 LRU 里的应用。还有一个容易踩的误区:有人觉得可以用双向链表配合一个 Map 存“key 到前驱节点的映射”,这样单向链表也能 O(1) 删除,但你要额外维护前驱映射本身的正确性,节点一旦移动,很多前驱关系都要变,复杂度一点都不比双向链表低,代码还更难懂。所以业界标准就是哈希表 + 双向链表,没有之一。
3. 手写实现:哈希表 + 双向链表的完整细节
到这一步,理论已经清楚,我们来写代码。我这里的实现用 Java,但不依赖任何高级容器,连泛型都尽量简洁,重点是让你看清楚指针的每一步变化。等你看明白以后,再用 Python、Go 甚至 C++ 各写一遍,这个知识点才算真正焊死在脑子里。
3.1 数据结构定义与 dummy 节点技巧
先定义节点类。每个节点有四个字段:key、value、prev、next。在真实的缓存做淘汰时,需要知道要删除节点的 key,才能同步清理哈希表,所以节点里必须存 key,不能只存 value。
java复制class Node {
int key;
int value;
Node prev;
Node next;
public Node(int key, int value) {
this.key = key;
this.value = value;
}
}
接下来是 LRUCache 类。核心成员有三个:HashMap、双向链表(用头尾哨兵连接)、容量 capacity。我特别强调“哨兵节点”这种写法,因为它能帮你消灭所有“头节点为空”“尾节点为空”“链表只有一个节点”这类边界判断。
java复制class LRUCache {
private Map<Integer, Node> map;
private int capacity;
private Node head;
private Node tail;
public LRUCache(int capacity) {
this.capacity = capacity;
this.map = new HashMap<>();
// 使用虚拟头尾节点,不用判空
this.head = new Node(-1, -1);
this.tail = new Node(-1, -1);
head.next = tail;
tail.prev = head;
}
为什么用 -1 这种无意义的哨兵?想象你本来要维护一条空链表,如果没有哨兵,插入第一个节点时 head = tail = newNode,后续所有判断都要考虑“是不是第一个”。有了哨兵,head 和 tail 永远存在,链表的真实节点永远夹在 head 和 tail 之间,插入和删除操作的代码结构统一,非常稳。
接下来是四个最基础的操作,也是整个实现的地基:
removeNode(Node node):把某个节点从链表中摘除。addToHead(Node node):把节点插入到 head 和原第一个真实节点之间。moveToHead(Node node):先摘除再插入,两步组合。removeTail():删除 tail 的前一个节点,并返回它,方便调用方清理 map。
java复制 private void removeNode(Node node) {
Node prev = node.prev;
Node next = node.next;
prev.next = next;
next.prev = prev;
}
private void addToHead(Node node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
private void moveToHead(Node node) {
removeNode(node);
addToHead(node);
}
private Node removeTail() {
Node realTail = tail.prev;
removeNode(realTail);
return realTail;
}
注意这四个方法内部默认调用方已经把节点接到正确的 map 里了。这个方法拆解是工程上的惯用做法,既避免重复代码,也方便测试每个小步骤。
3.2 get 和 put 的核心流程与边界条件
get 的逻辑非常直接:调用 map.get(key) 如果为空,返回 -1;如果存在,把这个节点 moveToHead,表示它刚刚被访问过,然后返回它的 value。这里有个很容易忽略的细节:在 LRU 里,“访问”不只是 get,put 一个已存在的 key 也算访问,同样要更新它的新鲜度。
put 逻辑要分情况讨论:
- key 已存在:更新 value,然后
moveToHead。 - key 不存在:判断当前容量。如果 map 大小已经等于 capacity,先调用
removeTail()得到被淘汰节点,再从 map 里移除它的 key;随后创建新节点放入 map,并addToHead。 - 有一个边界是 capacity 可能为 0。如果构造 LRU Cache 时传入 0,任何 put 操作都不应该真正写入。后面代码要加这一层保护。
把这些逻辑串起来:
java复制 public int get(int key) {
Node node = map.get(key);
if (node == null) {
return -1;
}
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
if (capacity <= 0) return;
Node node = map.get(key);
if (node != null) {
node.value = value;
moveToHead(node);
return;
}
if (map.size() == capacity) {
Node removed = removeTail();
map.remove(removed.key);
}
Node newNode = new Node(key, value);
map.put(key, newNode);
addToHead(newNode);
}
}
3.3 完整代码与执行过程演示
把上面的代码块拼起来就是完整实现。我当时在本地跑 LeetCode 的时候,最常用来验证的一个用例是:
code复制capacity = 2
put(1, 1)
put(2, 2)
get(1) // 应该返回 1,此时链表顺序从旧到新是 2 -> 1
put(3, 3) // 容量满了,淘汰 key=2,链表变成 3 -> 1
get(2) // 返回 -1,因为已经被淘汰
put(4, 4) // 淘汰 key=1,链表变成 4 -> 3
get(1) // 返回 -1
get(3) // 返回 3
get(4) // 返回 4
我建议你拿纸笔,逐行手动模拟这个用例。第一次写的时候很容易绕晕,但模拟过两次就能体会到:map 里记录的是“逻辑位置”,链表的 next 和 prev 记录的是“物理顺序”,两者必须保证同步。所有 bug 基本都是“map 里有但链表没有”或者“链表里有但 map 没有”这种不同步问题。
Python 版本也顺手贴一个,它很适合你验证思路。关键思路完全一样,只是对象引用要小心:
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 = dict()
self.head = DLinkedNode()
self.tail = DLinkedNode()
self.head.next = self.tail
self.tail.prev = self.head
def _remove_node(self, node):
node.prev.next = node.next
node.next.prev = node.prev
def _add_to_head(self, node):
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:
if len(self.cache) >= self.capacity:
tail = self._pop_tail()
self.cache.pop(tail.key)
node = DLinkedNode(key, value)
self.cache[key] = node
self._add_to_head(node)
这段代码是 LeetCode 146 的经典解法,核心 API 全被拆成了小函数,所以读起来很直观。实际工程里如果担心 Python 递归深度或对象开销,可以直接用 collections.OrderedDict,它内部就是双向链表加哈希表。但出于学习目的,刚才这份手工实现能帮你看到底层道理。
4. 实操中的坑与排查心得
LeetCode 上这道题的通过率并不算高,原因就是它的代码量不大,但指针操作极其容易出错。我面试候选人的时候,见过很多能把“哈希表 + 双向链表”八股背得很熟,但一写代码就出现编译错误或者死循环。这一节我把自己反复踩过的坑系统性列一遍。
4.1 最容易翻车的三个细节
第一个坑是移动节点时忘记更新相邻节点的指针。很多人写 removeNode 只写了 prev.next = next,忘了 next.prev = prev。在双向链表中,断一个节点的连接必须同时处理两个方向,只改一边会让链表结构损坏。这个错误特别隐蔽,因为当链表只有一两个节点时可能碰巧能跑通,一旦节点多了,就会出现遍历不到某些节点或出现环的诡异现象。
第二个坑是把哨兵节点当成真实节点淘汰掉。假如你直接在 removeTail 里写 tail.prev = ... 但没判断 tail.prev 是否是 head,当缓存为空时,你可能会删除 head。一个保护方法是在任何 removeNode 执行前加断言“node 不能是 head 或 tail”,或者记住这个不变量:head.next 永远指向真实节点,tail.prev 永远指向真实节点,真实节点数量等于 map.size()。
第三个坑是容量判断和淘汰顺序的搭配。有些新手会在 put 不存在的 key 时,先 put 到 map,再判断容量删除,结果新节点可能刚插入就被自己淘汰了。正确顺序一定是:先判断要不要淘汰旧节点,淘汰完了再插入新节点。另外要记得淘汰时要同时从链表和 map 中删除,缺一个都会让下一次查询出现脏数据。
我整理了一张速查表,方便你自查。
| 操作 | 链表变化 | 哈希表变化 | 典型错法 |
|---|---|---|---|
| get 命中 | 节点移到头部 | 无 | 忘了 moveToHead,导致 LRU 顺序不更新 |
| get 未命中 | 无 | 无 | 返回了错误的值,或忘记判断 key 不存在 |
| put 已存在 | 节点更新值并移到头 | 无 | 忘了更新 value,或只更新值没移动 |
| put 新数据且未满 | 头部插入新节点 | 新增映射 | 插入链表时指针顺序写错 |
| put 新数据且已满 | 删除尾部,再头部插入 | 先删旧 key,再增加新 key | 先加新 key 再删旧 key,顺序颠倒 |
4.2 再进一步:如果并发场景怎么办
手写 LRU 本身是单线程题,但面试官经常会追加一句:HashMap 是线程不安全的,你的 LRU Cache 能用在多线程环境吗?答案是不能。那么该怎么改?
最粗暴的办法是在 get 和 put 方法上加 synchronized,让整个缓存串行化。这样能保证一致性,但高并发下吞吐量会很差,因为读操作也被锁互斥了。稍微优化一点,可以使用 ReadWriteLock:读多写少的场景下,多个 get 可以共享读锁,只有 put 才需要写锁,能明显提升并发读的性能。值得注意的是,在 put 中可能同时涉及淘汰和插入,写锁范围必须覆盖整个操作。
如果要做到更细粒度,可以看 ConcurrentHashMap + 双向链表的组合,但链表的全局顺序本身是一个共享可变结构,想彻底无锁非常困难。实际工程中,Caffeine 这类本地缓存框架用的是分段锁、环形缓冲区等更精细的方案。所以面试时你能说出“synchronized 可以但性能差,ReadWriteLock 适合读多写少,彻底无锁实现需要额外设计”就已经甩开大多数背答案的候选人了。真到了要引入第三方库的场合,直接用 Caffeine 就好,别自己重复造轮子。
4.3 从 LRU 到 LFU:一个自然的扩展题
LRU 是很多缓存面试题的起点,它最常见的变体是 LFU(Least Frequently Used,最不经常使用)。LFU 判断淘汰的依据不再是“最近没访问”,而是“访问次数最少”。如果一个数据被访问了一万次,它是最热的;如果一个数据只被访问了一次且很久没再访问,它反而是优先淘汰对象。
LFU 实现起来更复杂,因为你需要维护“频率 -> 一个按访问时间排序的 LRU 队列”这样的二级结构。LeetCode 460 就是 LFU Cache,它相当于 LRU 的进阶版。我建议你把 LRU 吃透以后再试着做 LFU,你会发现很多思路是相通的——比如用 Map 定位节点、用双向链表维护时间局部性,只是 LFU 需要为每个频率段都维护一条这样的链表。能自己画出 LFU 的结构图,你对缓存算法的理解就真的立体起来了。
5. 关于这一课,再说点题外话
这套算法课最后落在 LRU 缓存上,我觉得是个很巧妙的安排。它既不是那种背下来就能对付的算法模板,也不是纯理论推导,它是数据结构组合拳的一次集中演练。链表让你练指针操作,哈希表让你练“空间换时间”,两者结合又逼着你思考操作顺序和边界条件。我见过太多人刷题只刷个数量,问到他 LRU,能说出要“哈希表 + 双向链表”,但让他现场解释为什么哈希表 value 里要存 Node 而不是 value,就支支吾吾了。建议你学完这一课后,把 LinkedHashMap 的源码打开对照着读一遍,看看官方是怎么实现 accessOrder 的,再尝试用 Rust 的 HashMap 手动写一个内存安全的版本,感受一下所有权和借用规则在链表操作里带来的额外挑战。
至少先把 Java 或 Python 这套写法练到闭着眼能写出来。笔试可能只要求通过用例,但面试官追问的每一个“为什么”,才是真正拉开差距的地方。我自己的习惯是每过一段时间重新手写一次 LRU,每次都能发现一些新的体会,比如把哨兵节点写法换成更“节省”的空指针判断写法,结果常常引入难查的 bug。所以如果你还没动手敲,建议你现在就打开编辑器,把这篇文章里的代码亲手过一遍。
