先交代一个背景。之前给朋友做模拟面试,他说自己把LeetCode前50题刷了两遍,但一碰到LRU缓存还是容易写崩。我让他现场写一遍第146题,写完我看了一下,思路基本对,代码里少了两个指针,直接凉在那。后来我总结了一个结论:LRU缓存这道题,根本不是靠“聪明”过的,而是靠“肌肉记忆”过的。它属于LeetCode里公认的必刷基础算法题,也是很多大厂面试里出场率奇高的手撕题。今天这篇就把这道T0级高频题讲透,给出一版可以直接背的Java模板,再把底层原理、易错点、工程扩展一次说清楚。
这道题适合所有准备算法面试的人,无论你是后端、客户端还是测开,都值得花两天时间把它练成条件反射。阅读时我建议你准备一张纸,跟着第3章的代码画一遍链表指针,否则光看不写,面试时照样会手抖。
1. 为什么LRU缓存能成为手撕题的“T0”梯队
1.1 出场率和覆盖面
在我看过的各家题库和面经里,LRU缓存在手撕题里的地位基本和“反转链表”“二叉树层序遍历”一个级别。LeetCode原题编号146,题目名字就叫LRU Cache。它很少单独作为二面压轴题,更多是二面开场热身题,或者一面写代码环节的必答项。后端在问,客户端在问,前端也会问。原因很直接:缓存是几乎所有业务系统都绕不开的组件,面试官不需要额外解释背景,你一听就能明白题目在讲什么。
真正让它进T0的原因,是这道题考察维度足够全。LRU缓存不只是一个数据结构题,它同时要求你处理好“哈希表的查”“链表的增删”“节点访问热度的维护”“容量淘汰”这四件事。任何一块出问题,代码都会露怯。而且它没有复杂的数学推导,也没有位运算技巧,就是纯数据结构基本功,非常适合作为手撕门槛。
1.2 面试官真正想验证的点
大多数人以为面试官只看最终代码能不能跑通。其实不是。我观察到的真实情况是,面试官拿着这道题,心里有三个验证点。
第一,你有没有O(1)的意识。如果一个人上来就写数组加遍历,哪怕思路方向是对的,面试官也会追问“能不能优化”,然后一步步把话题引到哈希表加双向链表。第二,你处理链表指针时稳不稳。双向链表在面试白板上不像数组那么直观,大多数错误都出在指针翻转阶段。第三,你对边界条件的敏感度。容量满、key不存在、重复put,这些分支是不是都覆盖到了。
换句话说,这道题考的不是“你懂不懂LRU”,而是“你在压力下能不能把一个小而全的数据结构默写干净”。我确实见过候选人背了标准答案,但面试官随口问一句:删除尾部节点时,为什么能从节点本身拿到key去删哈希表?他答不上来。这说明是死背不是真懂。所以后面我会专门讲这个细节,帮大家把“背”和“懂”结合起来。
1.3 为什么“可直接背”在LRU这里是可行的
我很少劝人背题,因为大部分题背了不一定考,考了也不一定原封不动。但LRU缓存是个例外。它的标准最优解是唯一的,只要你选择的语言支持哈希表和双向链表,基本没有第二条同样好的路。既然没有多解空间,那就不存在“临场发挥”的余地,剩下的就是机械地把每一步写对。所以你可以像背单词一样把模板刻下来,但要注意,背的时候必须带逻辑地背,否则面试官一打岔你就乱了。
我后面给出的Java模板,是我自己面试时用过的版本,也参考了LeetCode讨论区的高赞写法。它在代码量上不算最精简,但每个方法职责清晰,适合在高压环境下一步步复现。先记住这个模板,再去理解为什么这么写,最后到考场里你会发现,写这道题的过程就像默写一段熟悉的旋律。
2. 标准解法的拆解:哈希表负责“找”,双向链表负责“序”
2.1 为什么不能用数组或纯哈希表
先看两个直觉方案。第一个是“数组存键值对,每次访问把被访问的key移到末尾”。这么做get可以通过扫描找到,put也可以通过扫描更新,但最坏情况下每次操作都是O(n)。n是缓存容量,可能还没到1000面试官就开始皱眉了。第二个是“纯HashMap存键值,再单独维护一个队列记录访问顺序”。这里有个致命问题:当你访问一个已存在的key时,你需要把它的旧位置从队列里删掉,而哈希表并不能告诉你这个key在队列里的下标,于是你又得O(n)扫描队列。
LRU的核心矛盾就在这里:既要在O(1)时间里根据key找到值,又要在O(1)时间里调整某个节点的访问顺序。单体数据结构很难同时满足,所以必须用两个结构配合。哈希表负责第一件事,双向链表负责第二件事。链表节点里同时存放key和value,这样哈希表映射到节点后,节点自带数据;节点里再保留key,这样淘汰时不用回查哈希表就能知道要删哪个键。
2.2 双向链表:为什么不是单链表
如果把访问顺序维护成一个单链表,删除尾部节点很简单,删除中间节点却很麻烦。你只知道当前节点的引用,但单链表只有后继指针,没有前驱指针,想删自己就得从头遍历找到前一个节点。这正是我们要避免的O(n)。双向链表解决得干净,每个节点都有prev和next,删除自己只需要动两个指针。
另一个细节是“移动到头部”。LRU对每次访问的处理,本质上就是“删除”加“在头部插入”。双向链表把这两个操作都压到了常数时间。你在面试时甚至可以直接给面试官总结一句:“删除和插入都是O(1),所以get和put整体O(1)。”这一句话就已经拿到这题一半的分了。
2.3 关键操作的执行顺序
get(key)的流程:从map里取节点。取不到直接返回-1;取到了就把节点从当前位置摘下来,再放到链表头部,最后返回value。这里“先取value再移动”还是“先移动再取value”不影响结果,但为了代码清晰,我习惯先存住value再移动。
put(key, value)的流程:先查map。节点已存在,说明是更新操作,直接改value,然后把节点移到头部。节点不存在,说明要新插入,此时如果当前元素个数等于容量,先把尾部前一个节点(也就是真正的最后访问节点)删掉,并从map里移除对应key;然后新建节点,插到头部,放进map,最后容量计数加一。
你心里要始终有一个画面:链表永远是“头最新,尾最旧”,每次访问都会刷新这个顺序。把这个画面固定住,写代码时就会顺畅很多。
3. 可直接背的T0模板:Java完整实现
先放完整代码,再逐段解释。
class LRUCache { class Node { int key, value; Node prev, next; Node() {} Node(int key, int value) { this.key = key; this.value = value; } } private final int capacity; private final Node head, tail; // 哨兵节点 private final Map<Integer, Node> map; private int size; public LRUCache(int capacity) { this.capacity = capacity; this.size = 0; this.map = new HashMap<>(); head = new Node(); tail = new Node(); head.next = tail; tail.prev = head; } 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) { Node node = map.get(key); if (node != null) { node.value = value; moveToHead(node); return; } if (size == capacity) { Node removed = tail.prev; removeNode(removed); map.remove(removed.key); size--; } Node newNode = new Node(key, value); addToHead(newNode); map.put(key, newNode); size++; } private void addToHead(Node node) { node.next = head.next; node.prev = head; head.next.prev = node; head.next = node; } private void removeNode(Node node) { node.prev.next = node.next; node.next.prev = node.prev; } private void moveToHead(Node node) { removeNode(node); addToHead(node); } }3.1 字段与哨兵节点说明
代码里第一个“反直觉”的设计是哨兵节点head和tail。head只是一个虚拟头,它的next才指向第一个真实节点;tail也是一个虚拟尾,它的prev指向最后一个真实节点。为什么不用真实节点直接作为头尾?因为插入和删除时,如果没有哨兵,你需要反复判断“链表是否为空”“删除的是不是头节点”“插入的是不是第一个节点”,到处都是if。用了哨兵之后,链表永远不为空,所有操作都变得统一。哪怕是删除尾部节点,直接拿tail.prev就能操作,不用判空。
第二个设计是map的值类型是Node而不是value。有人觉得可以HashMap<Integer, Integer>再额外维护一个队列,但那样你无法在O(1)时间内拿到链表节点对象。记住:map<Intege, Node>是标配,并且Node里同时带着key和value。你删链表节点的时候,需要node.key来删除map里的键;你get的时候,需要node.value来返回结果。
3.2 get和put的逐一解读
get方法只有三件事:从map拿节点,拿不到就返回-1;拿到了就moveToHead;返回value。注意,只要节点存在,就一定要移动,哪怕这个节点已经在头部,moveToHead也会先删再插,保持逻辑统一。面试时可以减少if判断,不用额外检查“node是不是已经在头部”。
put方法分支更完整。第一步先查map,如果key已存在,说明是纯更新操作,改value之后一定要moveToHead。这里是最容易漏的地方,很多人会写成node.value = value; return;,忘了移动节点。这样刚更新的key可能因为“很久没被访问”而被淘汰,逻辑就错了。
如果key不存在,先看容量是否已满。满了就先淘汰:tail.prev就是最久未使用的节点,removeNode把它从链表摘掉,map.remove(removed.key)把哈希表里的键删掉,size减一。然后创建新节点,addToHead插入头部,map.put登记,size加一。
3.3 辅助方法怎么写更稳
我建议按固定顺序写代码,能明显减少漏步骤的可能:
- 写Node内部类。
- 定义head、tail、map、capacity、size。
- 构造方法里初始化map,让head和tail互指。
- 写addToHead。
- 写removeNode。
- 写moveToHead。
- 写get。
- 写put。
为什么是这个顺序?因为get和put依赖前面三个辅助方法,如果把辅助方法最后写,写着写着可能忘了调用。按依赖关系写,面试时流程更顺。addToHead和removeNode两个方法是整道题的核心,指针顺序我在4.2节还会重点说,那里是全场最容易翻车的地方。
3.4 为什么这套代码适合“背”下来
因为它是纯语言层面的固定套路。Java的HashMap、自定义Node类、双向链表,组合起来就是为这道题量身定做的模板。你只要把addToHead和removeNode两个方法写对,剩下的都是简单组装。有人会说用LinkedHashMap几行就搞定了,我承认可以,但那属于用库规避考察,面试官多半会让你再手写一遍底层。所以LinkedHashMap当作了解可以,背题模板还是要以手写为准。
4. 手撕时最容易被扣分的边界与坑点
4.1 容量为0、null键、重复put
先看capacity为0。按照LeetCode题目描述,capacity至少是1,但面试官可能随口就问“如果容量为0呢”。如果你用的是哨兵节点,put任意一个新键时size==capacity成立,代码会去删除tail.prev。此时tail.prev是head,操作后head和tail会互连,虽然不一定会抛异常,但语义已经乱了,后续get也可能返回错误数据。比较稳的处理是在构造时做保护,或者put开头直接加一行if (capacity <= 0) return;。
再说null键。Java的HashMap允许null作为key,Node的key字段也可以是null,所以直接在map里操作不会崩。但工程上为了可读性,一般会约定key不为null。如果面试官追问,你可以说“这里约定key不能为null,否则直接返回-1”,现场加个判断即可。
重复put最容易被忽视。很多人只记得“不存在才插入”,忽略了“已存在时更新value并移动位置”。同一个key连续put两次,第二次如果不moveToHead,缓存淘汰顺序就错了。这个分支看似简单,但我在面试模拟里抓到过很多次,大家写完一跑用例挂了才想起来。
4.2 指针断链的典型错误与自检方法
addToHead最经典的错误是过早修改head.next。比如你先写head.next = node,这时候原链表最前面的节点A就“断”了,你后面想把A.prev指向node时,唯一的入口已经没了。正确顺序是:先让node.next指向head.next,node.prev指向head,再让head.next.prev指向node,最后让head.next指向node。核心原则是“先改新节点自身,再改相邻节点的指针,最后改head”,顺序绝对不能乱。
removeNode相对简单,只需要两行:node.prev.next = node.next; node.next.prev = node.prev;。这里常见的错误是漏掉其中一行,导致链表只剩下一个方向能遍历,一旦往后走就死循环。怎么自检?写完代码后,在脑子里跑一个最小案例:put(1,1)、put(2,2)、get(1)、put(3,3),假设容量为2。最后链表顺序应该是head -> 1 -> 2 -> tail,还是head -> 2 -> 1 -> tail?正确答案是head -> 2 -> 1 -> tail,因为get(1)把1移到了头部,put(3,3)淘汰了最旧的2。你只要能推出这个状态,指针基本没大问题。
还有个更实用的画图自检法:把head、A、tail三个点画在纸上,模拟插入node。每次改指针就画一条线,你会发现顺序一旦错了,图的形状会非常奇怪。这个方法笨,但面试时很有效。
4.3 面试官追问:节点里为什么要存key
这是一个好好背着代码也容易被问住的细节。删除尾部节点时,你拿到的不只是一个Node对象,还需要知道它对应的key是什么,才能map.remove(key),否则map里会残留一个指向已被摘除节点的引用,内存泄漏,逻辑也错。这个key从哪来?只能从node.key读。所以Node类里必须有key字段。如果节点里只存value,淘汰时你无法在O(1)时间反查key。
我建议背这份代码时把这条理由也背下来。它几乎是必问的追问点,答出来后面试官会觉得你是真懂,而不是背模板。哪怕你的代码是从网上抄来的,能把这一句说出来,就已经和“死背模板”的候选人拉开了差距。
4.4 从LinkedHashMap到线程安全版本
Java里其实有个原生解法思路:LinkedHashMap如果设置accessOrder为true,每次访问会自动重排节点顺序;再重写removeEldestEntry,让容量超限时返回true,就能实现LRU。代码很短,适合用来快速理解LRU语义。但正如前面说的,面试时第一次答这个会被追问底层实现,第二次可能会被要求手写底层结构,所以不能把它当成终点答案。
线程安全方面,如果面试官继续深挖,你可以说:并发场景下简单做法是对整个LRU加锁,比如Collections.synchronizedMap套一层;更好一点的做法是用Lock保护链表,哈希表用ConcurrentHashMap。但手撕题阶段一般不会要求真的写并发版本,能说出思路已经足够。这里不要硬背,理解“并发下需要锁保护共享链表”这个点即可。
5. 换个语言还得会写:Python/C++/Go的对照思路
5.1 Python:OrderedDict与手写版本
Python面试里最快的是用OrderedDict。它本质上就是哈希表加双向链表的结合,move_to_end可以一键移动节点,popitem(last=False)可以删除最早插入的项。代码非常短,但同样要理解底层,否则面试官问“为什么popitem(last=False)删的就是最旧的”,你答不上来。
如果面试官要求手写,思路和Java完全一致:Node类、dict、哨兵节点。Python写链表指针看起来比Java繁琐一些,因为缩进风格会让长链式代码显得不那么直观。但核心的addToHead和removeNode公式是一样的,我建议你在纸上把Python版也写一遍,能加深理解。
5.2 C++:list和unordered_map的组合
C++标准库里有list双向链表,搭配unordered_map可以实现LRU。具体做法是:list<pair<int,int>>存储数据,unordered_map<int, list<pair<int,int>>::iterator>建立key到迭代器的映射。访问时先通过map找到迭代器,erase掉旧位置,再push_front新节点;淘汰时pop_back,同时用尾部节点的key去删除map。
写C++的坑主要在迭代器。erase之后的迭代器不能再使用,但push_front会产生新迭代器,所以map里的迭代器要及时更新。这也是面试官经常挖的一个点:如果你erase了某个list迭代器又在map里保留旧值,后续访问就变成了悬空引用。C++考生还是提前热身一下list的迭代器语法比较稳妥。
5.3 Go:container/list与map
Go的container/list就是双向链表,配合map实现LRU很顺手。要记住list.Element的Value字段是个interface{},取出真实数据时需要类型断言。比如说e := l.Front().Value.(*entry),其中entry里存key和value。淘汰时同样用类型断言取出key,再去map里删除。
Go版本需要多写一点类型断言的代码,逻辑上仍然是哈希表负责查找、链表负责排序。我测试过几次,Go的list库在面试白板上写起来反而比Java手写Node更省事,但前提是你对container/list的API足够熟悉。
6. LRU在真实系统里的样子:面试追问的加分点
6.1 Redis的近似LRU
Redis在内存淘汰策略里有一个重要选项:allkeys-lru、volatile-lru。但Redis并不是严格的LRU,因为它在高并发、大流量下要节省内存和时间,没法维护一个包含所有key的双向链表。它的做法是“抽样淘汰”:每次随机采样几个键,从中选一个最久未访问的键淘汰。默认采样数为5,可以通过maxmemory-samples参数调整。
为什么抽样能行?因为在大量键的维度下,抽样产生的淘汰结果已经很接近真实LRU,而维护精确LRU的成本在大容量缓存下是不可接受的。面试官听到这里通常会很满意。一个很好的加分句是:Redis的近似LRU不代表它不准确,而是因为精确LRU带来的O(1)维护成本在超大缓存里不是免费的。
6.2 MySQL Buffer Pool的冷热分离
MySQL InnoDB的Buffer Pool同样用LRU,但有一个著名的改进:把链表分成young和old两个区域。新读入的页面先放在old区域头部,只有被再次访问才可能晋升到young区域。为什么?因为如果不这么做,一次全表扫描会连续读取大量页面,这些页面只在第一次访问时有热度,却会把真正的热点数据全部挤出去,导致后续热数据频繁刷盘。
了解这个细节,能让面试官相信你不是只背了LeetCode模板,而是对LRU在真实系统里的工程化有感知。你不需要能画出整个Buffer Pool的流程,只要能说清“冷热分离的动机”就够了。这个思路在系统设计面试里也很有用。
6.3 被追问时的话术
面试不像考试,不要求你背下所有系统的源码,但你要能描述思路。我常用的表达是:这道题的手撕版本是精确LRU,分布式缓存里往往用近似LRU,因为精确LRU在高并发下容易成为热点。然后简单举Redis和MySQL的例子。这样答完,即使细节不够深,也已经展示了辨识度。
千万不要在HR面或者工程面里把“LRU就是双向链表加哈希表”当成全部答案。加上工程视角的那一刻,你已经和只会刷题的人分开了。这条建议不仅针对LRU,很多经典题都适用。
7. 一点个人背题心得:怎么把这道题变成肌肉记忆
7.1 我建议的练习节奏
如果你离面试还有一周,我给一个可执行的计划。第一天到第二天:把Java模板默写三遍,第一遍照着抄,第二遍关掉代码手写,第三遍在15分钟内完成,写完跑一遍LeetCode用例。第三到第四天:换Python或C++再写一遍,主要给自己换个语言视角,避免被同一套写法框死。第五到第七天:每天轮流只做两件事——在纸上画出链表结构,再口述get和put的完整流程。
这样练完,面试时写这道题的体验会跟写自己的名字一样,基本不会卡壳。你会发现手撕不再是“想”,而是“流”。
7.2 手撕时的“心理检查清单”
最后分享一个我面试前会在脑子里默念的清单:
- 哨兵节点初始化了吗?head.next是不是指向tail。
- 辅助方法addToHead和removeNode写了没有。
- get时节点存在,一定调用了moveToHead。
- put时key已存在,更新value后记得moveToHead。
- 容量满时,先删tail.prev,再从map里删,再插新节点。
- size增减是否成对。
这一段不算总结,算是给你带进考场的工具。我自己的实际体会是,LRU这道题彻底搞定之后,很多链表题的恐惧感都会消掉一些,因为你对“怎么插入”“怎么删除”“怎么移动”有了清晰的肌肉记忆。后面再去练LFU,或者手写HashMap,都会更有底气。面试就是一个不停重复的过程,第一次写错没关系,重要的是找到一个能让你稳定复现的模板。这套方法我用了很久,希望对你有用。