Java版LeetCode热题100之「LRU 缓存」详解
本文约9200字,全面深入剖析 LeetCode 第146题《LRU 缓存》。涵盖题目解析、哈希表+双向链表解法、复杂度分析、面试高频问答、实际开发应用场景、相关题目推荐等,助你彻底掌握缓存淘汰算法的核心实现技巧。
一、原题回顾
题目描述:
请你设计并实现一个满足LRU (最近最少使用) 缓存约束的数据结构。
实现LRUCache类:
LRUCache(int capacity)以正整数作为容量capacity初始化 LRU 缓存int get(int key)如果关键字key存在于缓存中,则返回关键字的值,否则返回 -1void put(int key, int value)- 如果关键字
key已经存在,则变更其数据值value - 如果不存在,则向缓存中插入该组
key-value - 如果插入操作导致关键字数量超过
capacity,则应该逐出最久未使用的关键字
- 如果关键字
约束条件:
- 函数
get和put必须以O(1) 的平均时间复杂度运行
示例:
输入 ["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"] [[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]] 输出 [null, null, null, 1, null, -1, null, -1, 3, 4] 解释 LRUCache lRUCache = new LRUCache(2); lRUCache.put(1, 1); // 缓存是 {1=1} lRUCache.put(2, 2); // 缓存是 {1=1, 2=2} lRUCache.get(1); // 返回 1 lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3} lRUCache.get(2); // 返回 -1 (未找到) lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3} lRUCache.get(1); // 返回 -1 (未找到) lRUCache.get(3); // 返回 3 lRUCache.get(4); // 返回 4提示:
1 <= capacity <= 30000 <= key <= 100000 <= value <= 10⁵- 最多调用
2 * 10⁵次get和put
二、原题分析
LRU(Least Recently Used)缓存是一种经典的缓存淘汰策略,核心思想是:当缓存满时,优先淘汰最久未使用的数据。
核心挑战:
- 快速查找:需要 O(1) 时间判断 key 是否存在
- 快速更新使用顺序:每次访问都要将数据标记为"最近使用"
- 快速淘汰:当容量超限时,快速找到并删除最久未使用的数据
- O(1) 时间复杂度:所有操作必须在常数时间内完成
关键观察:
- 哈希表:提供 O(1) 的 key 查找能力
- 双向链表:提供 O(1) 的节点移动和删除能力
- 组合使用:哈希表存储 key → 节点的映射,双向链表维护使用顺序
📌为什么需要双向链表?
- 单向链表无法 O(1) 删除任意节点(需要前驱指针)
- 双向链表可以 O(1) 删除任意位置的节点
三、答案构思
核心数据结构设计
1. 双向链表节点定义:
classDLinkedNode{intkey;intvalue;DLinkedNodeprev;// 前驱指针DLinkedNodenext;// 后继指针}2. 整体结构:
- 头部:最近使用的数据
- 尾部:最久未使用的数据
- 哈希表:
Map<Integer, DLinkedNode>,快速定位节点
3. 操作流程:
get(key) 操作:
- 哈希表查找 key
- 不存在 → 返回 -1
- 存在 → 将对应节点移动到头部 → 返回 value
put(key, value) 操作:
- 哈希表查找 key
- key 存在:
- 更新 value
- 移动节点到头部
- key 不存在:
- 创建新节点
- 添加到头部
- 添加到哈希表
- 检查容量:
- 超出 → 删除尾部节点 + 哈希表中对应项
辅助方法设计:
addToHead(node):添加节点到头部removeNode(node):删除指定节点moveToHead(node):移动节点到头部(删除 + 添加)removeTail():删除尾部节点并返回
💡伪头尾节点技巧:使用 dummy head 和 dummy tail 简化边界处理
四、完整答案(Java 实现)
/** * LRU 缓存实现 */publicclassLRUCache{/** * 双向链表节点 */classDLinkedNode{intkey;intvalue;DLinkedNodeprev;DLinkedNodenext;publicDLinkedNode(){}publicDLinkedNode(intkey,intvalue){this.key=key;this.value=value;}}// 哈希表:key -> 节点privateMap<Integer,DLinkedNode>cache;// 当前缓存大小privateintsize;// 缓存容量privateintcapacity;// 伪头部和伪尾部节点privateDLinkedNodehead;privateDLinkedNodetail;/** * 构造函数 */publicLRUCache(intcapacity){this.size=0;this.capacity=capacity;this.cache=newHashMap<>();// 初始化伪头尾节点this.head=newDLinkedNode();this.tail=newDLinkedNode();head.next=tail;tail.prev=head;}/** * 获取缓存值 */publicintget(intkey){DLinkedNodenode=cache.get(key);if(node==null){return-1;}// 移动到头部(标记为最近使用)moveToHead(node);returnnode.value;}/** * 插入或更新缓存 */publicvoidput(intkey,intvalue){DLinkedNodenode=cache.get(key);if(node==null){// key 不存在,创建新节点DLinkedNodenewNode=newDLinkedNode(key,value);cache.put(key,newNode);addToHead(newNode);size++;// 检查容量限制if(size>capacity){// 删除尾部节点(最久未使用)DLinkedNodetailNode=removeTail();cache.remove(tailNode.key);size--;}}else{// key 存在,更新值并移动到头部node.value=value;moveToHead(node);}}/** * 添加节点到头部 */privatevoidaddToHead(DLinkedNodenode){node.prev=head;node.next=head.next;head.next.prev=node;head.next=node;}/** * 删除指定节点 */privatevoidremoveNode(DLinkedNodenode){node.prev.next=node.next;node.next.prev=node.prev;}/** * 移动节点到头部 */privatevoidmoveToHead(DLinkedNodenode){removeNode(node);addToHead(node);}/** * 删除尾部节点并返回 */privateDLinkedNoderemoveTail(){DLinkedNoderes=tail.prev;removeNode(res);returnres;}}五、代码分析
1. 伪头尾节点的作用
// 初始化head=newDLinkedNode();tail=newDLinkedNode();head.next=tail;tail.prev=head;优势:
- 避免空指针异常
- 统一处理插入/删除逻辑
- 无需特殊处理头尾边界情况
链表结构示意图:
head <-> [最近使用] <-> ... <-> [最久未使用] <-> tail2. addToHead 方法详解
privatevoidaddToHead(DLinkedNodenode){node.prev=head;node.next=head.next;head.next.prev=node;head.next=node;}执行步骤:
node.prev = head→ 节点前驱指向 headnode.next = head.next→ 节点后继指向原第一个数据节点head.next.prev = node→ 原第一个数据节点的前驱指向新节点head.next = node→ head 的后继指向新节点
效果:新节点成为第一个数据节点
3. removeNode 方法详解
privatevoidremoveNode(DLinkedNodenode){node.prev.next=node.next;node.next.prev=node.prev;}执行步骤:
- 前驱节点的 next 指向后继节点
- 后继节点的 prev 指向前驱节点
- 被删除节点从链表中断开
关键:双向链表才能实现 O(1) 删除任意节点
4. 容量控制逻辑
if(size>capacity){DLinkedNodetailNode=removeTail();cache.remove(tailNode.key);size--;}淘汰策略:总是删除 tail.prev(最久未使用的数据)
六、时间复杂度和空间复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| get | O(1) | 哈希表查找 + 链表移动 |
| put | O(1) | 哈希表操作 + 链表操作 |
| 总体 | O(1) | 满足题目要求 |
空间复杂度:O(capacity)
- 哈希表最多存储
capacity个键值对 - 双向链表最多存储
capacity个节点 - 伪头尾节点占用常数空间
✅完全满足题目要求的 O(1) 时间复杂度!
七、常见问题解答(FAQ)
Q1:为什么不能只用哈希表?
答:
哈希表虽然提供 O(1) 查找,但无法维护使用顺序。
当需要淘汰数据时,无法知道哪个是最久未使用的。
必须配合链表来维护访问顺序。
Q2:为什么不能只用双向链表?
答:
双向链表虽然能维护顺序,但查找特定 key 需要 O(n) 时间遍历。
无法满足 O(1) 查找的要求。
必须配合哈希表来快速定位节点。
Q3:能否用 LinkedHashMap 实现?
答:可以,但不符合面试要求!
classLRUCacheextendsLinkedHashMap<Integer,Integer>{privateintcapacity;publicLRUCache(intcapacity){super(capacity,0.75F,true);// accessOrder = truethis.capacity=capacity;}@OverrideprotectedbooleanremoveEldestEntry(Map.Entry<Integer,Integer>eldest){returnsize()>capacity;}}面试官期望:考察你对底层数据结构的理解,而不是 API 调用。
Q4:如果 key 为负数怎么办?
答:
题目已限定0 <= key <= 10000,所以无需考虑负数。
但在实际工程中,应该使用能处理任意整数的哈希表(Java HashMap 支持)。
八、优化思路
1. 线程安全版本
在多线程环境下,需要加锁:
publicsynchronizedintget(intkey){// ...}publicsynchronizedvoidput(intkey,intvalue){// ...}缺点:性能下降
改进:使用读写锁或分段锁
2. 内存优化
- 使用
LinkedHashMap的内部实现(但面试不推荐) - 对于大量小对象,考虑对象池减少 GC 压力
3. 扩展功能
- TTL(Time To Live):支持过期时间
- 统计信息:记录命中率、访问次数等
- 批量操作:支持批量 get/put
4. 工程化增强
// 输入校验publicvoidput(intkey,intvalue){if(capacity<=0){thrownewIllegalStateException("Capacity must be positive");}// ... 原逻辑}// 单元测试@TestvoidtestLRUCache(){LRUCachecache=newLRUCache(2);cache.put(1,1);cache.put(2,2);assertEquals(1,cache.get(1));cache.put(3,3);assertEquals(-1,cache.get(2));}九、数据结构与算法基础知识点回顾
1. LRU 缓存原理
- 核心思想:最近使用的数据更可能再次被使用
- 淘汰策略:当缓存满时,淘汰最久未使用的数据
- 应用场景:操作系统页面置换、数据库缓存、Web 缓存
2. 哈希表(HashMap)
- 时间复杂度:平均 O(1) 查找/插入/删除
- 空间复杂度:O(n)
- Java 实现:基于数组 + 链表/红黑树
3. 双向链表
- 特点:每个节点有 prev 和 next 指针
- 优势:O(1) 插入/删除任意位置
- 劣势:额外的内存开销(prev 指针)
4. 伪节点技巧(Dummy Node)
- 作用:简化边界条件处理
- 应用:链表反转、合并、删除等操作
- 优势:避免大量 null 检查
十、面试官提问环节(模拟)
❓ 问题1:你的实现中,如果并发调用 get 和 put 会有什么问题?
回答:
会出现线程安全问题:
- 哈希表可能处于不一致状态
- 链表指针可能出现错乱
- size 计数可能不准确
解决方案:
- 简单方案:给 get 和 put 方法加 synchronized
- 优化方案:使用 ReadWriteLock,get 用读锁,put 用写锁
- 高级方案:参考 ConcurrentHashMap 的分段锁思想
❓ 问题2:为什么选择双向链表而不是其他数据结构?
回答:
对比几种方案:
数据结构 查找 删除任意元素 移动到头部 数组 O(1) O(n) O(n) 单向链表 O(n) O(n) O(1) 双向链表 O(1)* O(1) O(1) *配合哈希表实现 O(1) 查找
双向链表是唯一能同时满足 O(1) 删除任意元素和 O(1) 移动到头部的数据结构。
❓ 问题3:如果要求实现 LFU(最不经常使用)缓存,思路是什么?
回答:
LFU 需要维护每个 key 的访问频次,比 LRU 更复杂:核心思路:
- 哈希表1:key → (value, frequency)
- 哈希表2:frequency → 双向链表(存储相同频次的 key)
- 维护最小频次 minFreq
操作:
- get:频次+1,从旧频次链表移到新频次链表
- put:如果容量满,删除 minFreq 链表的尾部
时间复杂度:O(1)(需要精心设计数据结构)
❓ 问题4:你的实现空间复杂度真的是 O(capacity) 吗?
回答:
是的。具体分析:
- 哈希表:最多存储 capacity 个 entry
- 双向链表:最多存储 capacity 个数据节点 + 2 个伪节点
- 其他变量:常数空间
所以总空间复杂度是 O(capacity),符合缓存的预期行为。
十一、这道算法题在实际开发中的应用
LRU 缓存是计算机科学中最经典、应用最广泛的算法之一:
1.操作系统 - 页面置换
- 虚拟内存管理中,当物理内存不足时,需要将某些页面换出到磁盘
- LRU 是常用的页面置换算法之一
- 虽然精确 LRU 实现代价高,但有近似算法(如 Clock 算法)
2.数据库系统 - Buffer Pool
- 数据库将磁盘页缓存在内存中
- 使用 LRU 或其变种(如 LRU-K)管理缓存页
- 提高查询性能,减少磁盘 I/O
3.Web 开发 - HTTP 缓存
- 浏览器缓存、CDN 缓存都使用 LRU 策略
- Redis、Memcached 等缓存系统内置 LRU 淘汰策略
- Spring Cache 也支持 LRU 缓存实现
4.移动开发 - 图片缓存
- Android 的 LruCache 类就是 LRU 缓存的标准实现
- 用于缓存 Bitmap,避免频繁加载图片导致 OOM
- Glide、Picasso 等图片加载库都基于 LRU 缓存
💡核心价值:理解时间和空间的权衡,这是系统设计的基础。
十二、相关题目推荐
掌握本题后,可挑战以下 LeetCode 题目:
| 题号 | 题目 | 关联点 |
|---|---|---|
| 460. LFU 缓存 | 扩展 | 更复杂的缓存策略 |
| 155. 最小栈 | 技巧 | 辅助数据结构 |
| 225. 用队列实现栈 | 基础 | 数据结构组合 |
| 232. 用栈实现队列 | 基础 | 数据结构组合 |
| 380. 常数时间插入、删除和获取随机元素 | 应用 | 哈希表+数组 |
| 707. 设计链表 | 基础 | 链表操作 |
建议先掌握基础数据结构操作,再挑战复杂的系统设计题。
十三、总结与延伸
✅ 本题核心收获
- LRU 原理:最近最少使用淘汰策略
- 数据结构组合:哈希表 + 双向链表的经典应用
- O(1) 操作实现:通过精心设计数据结构达到性能要求
- 伪节点技巧:简化链表操作的边界处理
🔮 延伸思考
- 近似 LRU:在大规模系统中,精确 LRU 代价太高,常用近似算法
- 分布式 LRU:如何在分布式环境中实现一致性 LRU 缓存?
- 硬件实现:CPU 缓存、TLB 等硬件缓存如何实现 LRU?
🌟 最后建议
- 手写代码:在白板上完整写出双向链表操作
- 讲清思路:面试时先解释 LRU 原理,再说明数据结构选择
- 主动讨论:提出线程安全、扩展性等工程考虑,展现深度
“缓存虽小,学问很大;LRU 之道,在于平衡。”
掌握本题,你就拥有了设计高效缓存系统的基础。继续前行,系统设计之路越走越宽!