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 存链表节
