别再死记硬背了,从双向链表到 HashMap,手写一个工业级 LRU 其实很简单
前言:当缓存满了,该踢谁出去?
想象一下这个场景:你的手机内存只有 128GB,但你想装的 App、拍的照片、缓存的视频远远超过这个数。操作系统每天都要面临一个“灵魂拷问”——新数据要存进来,到底该删掉哪个旧数据?
在计算机世界里,这个问题叫做缓存淘汰策略。而今天我们要聊的 LRU(Least Recently Used,最近最少使用)正是其中最经典、最接地气的一种思路,它的核心哲学非常朴素:
如果一个数据最近被访问过,那么它将来被访问的概率也更高;反之,如果一个数据很久没被碰过,那它大概率可以“卷铺盖走人”了。
LRU 的身影无处不在——Redis 的内存淘汰机制、MySQL 的 Buffer Pool、操作系统的页面置换、浏览器的历史记录……几乎所有需要“有限空间存无限数据”的场景,都有它的影子。
今天,我们不只讲概念,我会带你从零手写一个完整的 LRU 缓存,把里面的设计细节、坑点、优化思路全都掰开揉碎讲清楚。
一、从需求出发:LRU 到底要干什么?
我们先明确一下 LRU 缓存的核心功能需求:
- 固定容量:缓存有上限
n,满了就不能再随意往里塞。 - 快速读取:
get(key)—— 如果 key 存在,返回 value,并且这次访问要让它变成“最近使用过”;如果不存在,返回 -1。 - 快速写入:
put(key, value)—— 如果 key 已存在,更新 value 并标记为最近使用;如果不存在,插入新节点,但若容量超限,淘汰掉最久未使用的那一个。 - 所有操作的时间复杂度都必须是 O(1)。
最后这一点是硬骨头。如果只是用数组或普通链表,查找和移动都得 O(n),在大数据量下根本扛不住。那怎么办?——空间换时间。
二、数据结构选型:为什么是“双向链表 + HashMap”?
我们先从直觉出发。
方案一:单用数组
用数组按访问时间顺序存储 key,每次访问就把该元素移到末尾,淘汰时删掉头部。但问题是——在数组中间删除/移动元素是 O(n) 的,而且查找一个 key 的位置还得遍历,太慢。
方案二:单用链表
用链表确实可以 O(1) 删除和移动节点(前提是知道节点位置),但查找 key 对应的节点仍然需要遍历,还是 O(n)。
方案三:HashMap + 双向链表 —— 完美组合
- HashMap:负责 O(1) 查找,key → 节点引用。
- 双向链表:负责维护“新旧程度”的顺序,头部是最久未用,尾部是最近刚用。
为什么一定要双向链表?因为我们要在 O(1) 时间内删除任意节点(比如移动节点到尾部时,需要知道它的前驱和后继),单链表做不到这一点。
于是,经典结构跃然纸上:
+--------+ +----------+ +----------+ +----------+ +--------+
| head | <-> | Node A | <-> | Node B | <-> | Node C | <-> | tail |
|(dummy) | | (最近最少)| | (中间) | | (最近最多)| |(dummy) |
+--------+ +----------+ +----------+ +----------+ +--------+
^ ^
| |
+------------------ HashMap: key -> Node引用 -----------------------+
两个哨兵节点(dummy head/tail)让边界处理变得干干净净,再也不用写一堆 if (node == null) 的判断。
三、代码实战:从 0 到 1 实现一个工业级 LRU
下面我们一步步实现,我会边写边解释为什么这么设计。
第一步:定义节点类
class ListNode {
constructor(key, value) {
this.key = key
this.value = value
this.prev = null
this.next = null
}
}
每个节点既存 key 也存 value,key 是为了在淘汰时能从 HashMap 中删除对应的条目。
第二步:LRU 主框架
class LRUCache {
constructor(capacity) {
this.capacity = capacity
this.map = new Map()
// 初始化两个哨兵节点,它们不存实际数据
this.head = new ListNode(null, null)
this.tail = new ListNode(null, null)
this.head.next = this.tail
this.tail.prev = this.head
}
// ... 后续方法
}
这里的 map 存的是 key -> node 的映射,保证 get 能瞬间拿到节点。
第三步:核心辅助方法 —— 链表的增删改
先把最底层的链表操作写好,后面所有逻辑都基于它们。
// 删除链表中的任意节点(O(1))
removeNode(node) {
node.prev.next = node.next
node.next.prev = node.prev
}
// 将节点添加到链表尾部(紧挨着 tail 哨兵之前)
addToTail(node) {
node.prev = this.tail.prev
node.next = this.tail
this.tail.prev.next = node
this.tail.prev = node
}
// 把某个节点移动到尾部 —— 先删后加
moveToTail(node) {
this.removeNode(node)
this.addToTail(node)
}
这三个方法虽短,但却是整个 LRU 的“发动机”。注意顺序:addToTail 里一定要先改新节点的前后指针,再改旧节点的指针,否则容易丢失引用。
第四步:get 方法 —— 访问即“续命”
get(key) {
if (!this.map.has(key)) {
return -1
}
const node = this.map.get(key)
this.moveToTail(node) // 这次访问让它变成“最新”
return node.value
}
逻辑简单到只有三行:查不到返回 -1,查到了就移到尾部并返回值。
第五步:put 方法 —— 插入、更新、淘汰三位一体
put(key, value) {
if (this.map.has(key)) {
// 场景1:更新已有 key
const node = this.map.get(key)
node.value = value
this.moveToTail(node)
} else {
// 场景2:插入新 key
const node = new ListNode(key, value)
this.map.set(key, node)
this.addToTail(node)
// 如果容量超标,淘汰最久未使用的(即 head 后面的那个)
if (this.map.size > this.capacity) {
this.removeHead()
}
}
}
removeHead() {
const node = this.head.next // 第一个真实节点
this.removeNode(node)
this.map.delete(node.key) // 别忘了从 HashMap 里也删掉
}
这里有个容易踩的坑:必须先判断 key 是否存在,再决定是更新还是插入。如果先检查容量再更新,会导致明明 key 已存在却误判为超容,从而错误地淘汰其他节点。
四、手写模拟:用例子走一遍流程
假设容量 n = 3,我们按顺序执行:
put('A', 1)→ 链表:[A]put('B', 2)→[A, B]put('C', 3)→[A, B, C]get('A')→ A 被移到尾部,链表变成[B, C, A]put('D', 4)→ 容量超了,移除头部 B,链表变成[C, A, D]
你会发现,被淘汰的永远是那个最久没被“翻牌子”的,这就是 LRU 的精髓。
五、复杂度分析与工程化思考
- 时间复杂度:
get和put都是 O(1),因为 HashMap 查找是 O(1),链表增删改也是 O(1)。 - 空间复杂度:O(n),存储 n 个节点和对应的 Map 映射。
但实战中,这个基础版本还有几个可以优化的点:
- 内存占用:每个节点都有前后指针,对于 int 类型的 value 来说,指针的开销甚至比数据本身还大。在 Java 中可以用
LinkedHashMap直接开箱即用,在 Redis 中则用更紧凑的结构。 - 并发安全:上述代码在多线程环境下会出问题,需要加锁或使用
ConcurrentHashMap+ 原子操作。 - 缓存污染:如果一次批量查询了大量冷数据,可能会把热数据挤出去。解决方案有 LRU-K、Two-Queue 等变种。
六、延伸思考:LRU 的“亲戚们”
- LFU(Least Frequently Used):看的是“访问频率”而非“访问时间”,用计数器来淘汰访问次数最少的。适合热点非常稳定的场景。
- FIFO(先进先出):最简单的队列,但完全无视访问模式,命中率往往不如 LRU。
- ARC(自适应缓存替换):同时维护 LRU 和 LFU,动态调整比例,性能更优但实现复杂。
七、写在最后:LRU 教会我们的不只是算法
写完这 200 多行代码,你会发现 LRU 其实就干了两件事:用 Map 保证速度,用链表保证顺序。它背后蕴含的是一种非常普世的资源管理哲学——有限空间里,把资源留给最有价值(最近被需要)的数据。
这不光适用于缓存,也适用于我们的生活:时间有限,精力有限,把注意力留给最近重要的事;手机桌面有限,把最常用的 App 放在最顺手的位置。LRU 不只是一个算法,更是一种关于“取舍”的智慧。
所以下次当你写缓存的时候,不妨想想这个简单的原则——最近用过的,别丢;很久没碰的,该放就放。
你的代码如此,你的人生亦如此。
附录:完整代码(无注释版)
class ListNode {
constructor(key, value) {
this.key = key
this.value = value
this.prev = null
this.next = null
}
}
class LRUCache {
constructor(capacity) {
this.capacity = capacity
this.map = new Map()
this.head = new ListNode(null, null)
this.tail = new ListNode(null, null)
this.head.next = this.tail
this.tail.prev = this.head
}
get(key) {
if (!this.map.has(key)) return -1
const node = this.map.get(key)
this.moveToTail(node)
return node.value
}
put(key, value) {
if (this.map.has(key)) {
const node = this.map.get(key)
node.value = value
this.moveToTail(node)
} else {
const node = new ListNode(key, value)
this.map.set(key, node)
this.addToTail(node)
if (this.map.size > this.capacity) {
this.removeHead()
}
}
}
removeNode(node) {
node.prev.next = node.next
node.next.prev = node.prev
}
addToTail(node) {
node.prev = this.tail.prev
node.next = this.tail
this.tail.prev.next = node
this.tail.prev = node
}
moveToTail(node) {
this.removeNode(node)
this.addToTail(node)
}
removeHead() {
const node = this.head.next
this.removeNode(node)
this.map.delete(node.key)
}
}
如果这篇文章帮你彻底搞懂了 LRU,欢迎点赞收藏,也欢迎在评论区聊聊你在项目中是如何使用缓存的 —— 我们下篇见!🚀