如果你刷 LeetCode 热门 100 题,LRU 缓存(LeetCode 146)大概率会被你翻到不止一次。题目本身只要求实现 get 和 put 两个接口,却把哈希表、双向链表、时间局部性、缓存淘汰策略全揉在了一道题里。更直白一点说,这是面试里少数“代码量不算大、但每个细节都能拿出来追问”的题目,也是我见到的从初中级到资深级别都常被要求手写的基础题。
你能拿它做什么?最直接的就是搞懂“缓存命中”和“缓存失效”背后的数据结构逻辑。日常开发里,浏览器缓存、Redis 缓存治理、本地 KV 缓存、token 缓存命中和不命中,底层都会遇到 LRU 或类似 LRU 的淘汰问题。所以这篇不打算只给最终代码,我会把为什么必须用哈希表加双向链表、手写时容易踩的边界坑、工程里用 OrderedDict / std::list 的实现思路,以及从 LRU 延伸到缓存一致性和其他淘汰策略的内容都讲一遍。适合正准备刷题的读者,也适合想把手写缓存能力用到真实项目里的人。
1. 从真实缓存场景说起:为什么 LRU 是 LeetCode 146 的核心
1.1 缓存命中的本质是用空间换时间
任何缓存的出发点都一样:把经常读取的数据放在更快、更近的地方。CPU 有 L1/L2/L3 缓存,操作系统有页缓存,业务系统里有本地缓存、Redis 缓存,本质上都在做同一件事——假设“刚被访问过的数据,接下来还可能被访问”,也就是时间局部性。
我刚工作那会儿第一次做接口性能优化,发现一个接口每次都要查数据库,压测的时候数据库连接被全部打满。后来在服务里加了一层本地缓存,热点数据直接命中内存,数据库压力立刻就降下来了。但内存不是无限的,缓存容量必须有限制。于是问题变成了:容量满了之后,到底把谁踢出去?
最朴素的做法是先进先出(FIFO),谁先进来就先把谁淘汰。但实际业务里,某些 key 可能只在初始化的时候用一次,以后再也不用了;而另一些 key 每次请求都命中。FIFO 不会区分这两者,它只看插入时间。LRU 的思路则更合理:如果某个 key 最近被访问过,说明它很可能短时间内还会被访问,应该留得久一点;如果某个 key 很久没被访问,那就优先淘汰它。LeetCode 146 让你实现的就是这个策略。
1.2 题目要求为什么是 get 和 put 都 O(1)
LeetCode 146 的约束很硬:
get(key):如果 key 存在,返回对应值;不存在返回 -1。put(key, value):如果 key 存在,更新值;不存在则插入。如果缓存达到容量上限,淘汰最久没被访问的 key。get和put的时间复杂度都要求 O(1)。
这个约束直接淘汰了很多“看起来能用”的方案。比如用数组记录每次访问时间,每次淘汰时遍历找最小时间戳,那淘汰是 O(n);再比如用一个 Queue 存储访问顺序,但为了把某个 key 从队列中间移到队首,又可能要 O(n)。所以这道题真正的考点不是“能不能实现 LRU 语义”,而是“能不能在 O(1) 时间内完成查询、更新、删除、插入这四件事”。
从工程角度看,O(1) 也意味着你设计的数据结构不会随着缓存容量变大而明显变慢。一个本地缓存可能存几万、几十万个 key,如果淘汰时每次都要遍历一遍,整体吞吐就崩了。这逼着你必须同时用好哈希表的高效查找和链表的快速插入删除。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先踩一遍“看起来能行”的方案:数组时间戳和单链表为什么挂
2.1 数组 + 时间戳:查找 OK,淘汰不 OK
我见过不少第一次写 LRU 的人,第一反应是维护一个数组或字典,给每个 key 记录 last_access_time。伪代码大概是:
code复制访问 key:
dict[key].value = v
dict[key].timestamp = now
需要淘汰时:
遍历 dict,找到 timestamp 最小的 key,删除
这个方案语义上完全正确,但时间复杂度是硬伤。每次淘汰要 O(n),如果缓存容量很大,淘汰操作就会成为性能瓶颈。而且为了更新时间戳,你还得依赖一个全局时钟,多线程环境下还要考虑时间戳更新的原子性,问题会越来越多。
LeetCode 判题器对时间要求比较严格,但更关键的是一旦你这么做,面试官追问“复杂度是多少”时,这个问题就很难圆回来。
2.2 哈希表 + 单链表:删除任意节点时卡住了
另一个常见思路是:哈希表负责 O(1) 找到 key,链表负责维护访问顺序。每次访问一个 key,就把它移到链表头部;链表尾部就是最久没被访问的 key。这个方向是对的,但如果你用单链表,会在这里碰壁。
单链表上有 next 指针,没有 prev 指针。你要把某个节点移动到头部,得知道这个节点的前一个节点是谁。可你只知道当前节点,无法 O(1) 拿到前驱节点。除非你从头遍历,那又是 O(n)。在很多实现里,最后都会退化成“用哈希表存 key 对应的数据,用数组存访问次数”,然后淘汰时遍历找最小值。这本质上还是 O(n)。
所以结论很明确:要支持 O(1) 的删除操作,你需要能在 O(1) 时间内拿到当前节点的前驱。双向链表正好解决这个问题,每个节点同时保存 prev 和 next,删除一个节点时只需要改前后两个邻居的指针。
2.3 “哈希表 + 双向链表”是最小可行组合
最终确定的数据结构是:
- 哈希表:
key -> 链表节点,负责 O(1) 找到某个 key 对应的节点。 - 双向链表:维护节点的新旧顺序,头部是最新访问,尾部是最久未访问。
对应到四个操作:
get(key):哈希表查一下,没找到返回 -1;找到了,把对应节点移动到链表头部,返回节点值。put新 key:创建节点放到链表头部,然后写哈希表;如果超容量,删除链表尾部节点,并同步删除哈希表里的对应 k
