如果你刷 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:创建节点放到链表头部,然后写哈希表;如果超容量,删除链表尾部节点,并同步删除哈希表里的对应 key。put已有 key:更新节点值,移动到链表头部。- 淘汰:删除链表尾部节点,复杂度 O(1)。
哈希表解决了“查找”的速度,双向链表解决了“删除任意节点”的速度。两者合起来,才满足题目要求的 O(1)。
这个组合也是生产环境里 LRU 缓存最常见的内部结构。比如 Redis 的近似 LRU 实现虽然没用严格的双向链表做全量排序,但很多本地缓存框架、Guava Cache 之类的实现,底层思路都是往这个方向靠的。
3. 手写实现逐行拆解:哈希表 + 双向链表如何做到 O(1)
3.1 节点和初始化代码
我先给一个可运行的手写版本,语言用 Python,方便直接跑测试:
python复制class Node:
def __init__(self, key=0, value=0):
self.key = key
self.value = value
self.prev = None
self.next = None
class LRUCache:
def __init__(self, capacity: int):
self.capacity = capacity
self.cache = {}
self.head = Node()
self.tail = Node()
self.head.next = self.tail
self.tail.prev = self.head
def _remove_node(self, node):
node.prev.next = node.next
node.next.prev = node.prev
def _add_to_head(self, node):
node.prev = self.head
node.next = self.head.next
self.head.next.prev = node
self.head.next = node
def _move_to_head(self, node):
self._remove_node(node)
self._add_to_head(node)
def _pop_tail(self):
node = self.tail.prev
self._remove_node(node)
return node
def get(self, key: int) -> int:
if key not in self.cache:
return -1
node = self.cache[key]
self._move_to_head(node)
return node.value
def put(self, key: int, value: int) -> None:
if key in self.cache:
node = self.cache[key]
node.value = value
self._move_to_head(node)
return
new_node = Node(key, value)
self.cache[key] = new_node
self._add_to_head(new_node)
if len(self.cache) > self.capacity:
tail_node = self._pop_tail()
del self.cache[tail_node.key]
这里我用了两个哨兵节点:head 和 tail。初始化时 head.next 指向 tail,tail.prev 指向 head。它们不存真实数据,只是帮我们把边界条件统一掉,省去大量空指针判断。
3.2 get 的完整链路
假设缓存容量 2,先执行:
python复制lru = LRUCache(2)
lru.put(1, 10)
lru.put(2, 20)
lru.get(1)
第一次 put(1, 10) 后:
- 创建节点
node1 = Node(1, 10)。 - 哈希表
cache[1] = node1。 _add_to_head(node1)把它插到 head 和 tail 之间。
第二次 put(2, 20) 后,节点顺序是 head -> node2 -> node1 -> tail。此时最久未使用的是 key=1。
然后执行 get(1):
- 哈希表里找到
node1。 _move_to_head(node1),先把node1从链表中摘掉,再插到 head 后面。- 节点顺序变成 head -> node1 -> node2 -> tail。
- 返回
node1.value。
所以 get 除了返回数据,还会改变链表的顺序。这也是 LRU 和普通哈希表的区别:哪怕只是读一下,也会提升这个 key 的“新鲜度”。
3.3 put 的两条路线:新增和更新
put 要先判断 key 是否已经存在,因为已有 key 的更新操作不会让容量增加,也不需要触发淘汰。
更新场景:比如 lru.put(1, 99),key=1 已经存在。直接改节点的 value,再把这个节点移动到头部。这里不能只改值不移动位置,因为“更新值”本身也算一次访问,应该让它变成最新访问的 key。
新增场景:lru.put(3, 30),key=3 不存在。创建新节点,插入头部,写入哈希表。然后判断 len(self.cache) > self.capacity。这里用字典的长度,而不是手动维护一个 size 变量,避免状态不一致。
如果超容量,就调用 _pop_tail(),它是真正的淘汰操作:
- 拿到
tail.prev,也就是当前最久未访问的节点。 - 把它从链表中删除。
- 用
tail_node.key作为 key,从哈希表里删除。
这里有个很多人容易写错的地方:删除哈希表时,要用被淘汰节点的 key,而不是当前 put 方法里的 key。因为触发淘汰时,put 里的 key 是刚插入的 key,你要删的其实是尾部节点的 key。如果不小心用了变量 key,就会出现缓存里残留脏数据,后续 get 能读到但链表顺序已经乱掉的情况。
3.4 为什么哨兵节点能省掉大量判空
没有哨兵节点的版本,代码里一定会出现大量这样的判断:
- 链表为空时,头插、尾删怎么办。
- 只有一个节点时,移动它是否需要特殊处理。
- 删除时是否需要判断
prev或next是否为 None。
这些分支写多了容易出错。用哨兵节点后,链表永远不为空,真实的头节点至少会有 head.next 指向它或 tail,真实的尾节点至少会有 tail.prev 指向它或 head。所有插入和删除都可以用同样的指针操作完成,不用关心边界。我推荐刷题和面试手写时都用这个写法。
4. 边界条件排雷:容量为 0、覆盖旧值、淘汰节点这些细节
4.1 容量为 0 的场景
题目没有说 capacity 一定大于 0,LeetCode 官方测试用例里也出现过 capacity = 0。如果初始化后立刻 put(1, 1),按上面代码逻辑:
- key=1 不存在,创建节点,插入链表,写入哈希表。
- 判断
len(self.cache) > 0,成立。 _pop_tail()把这个唯一节点弹出,同时从哈希表删除。
结果是对的,缓存里什么都不留,后续 get(1) 返回 -1。但有些实现会在 __init__ 里直接 assert capacity > 0,这反而容易挂在隐藏用例上。更稳妥的做法是支持 capacity 为 0,代码里也不需要额外 if,因为“插入后再淘汰”正好能把缓存清空。
4.2 更新已有 key 时不能忘了换新值
一个很隐蔽的 bug 是:put 更新已有 key 时,只移动了节点位置,忘了更新 node.value。这样缓存命中后返回的还是旧值,测试用例会直接报错。
还有另一种写法是:不管 key 存不存在,先移除旧节点,再创建新节点插入头部。这种写法在逻辑上可行,但会带来不必要的对象创建和哈希表操作。手写版本直接原地更新更干净,也方便面试官看到你的状态管理能力。
4.3 不能把“访问顺序”和“插入顺序”搞混
LRU 里的“最近使用”包含了读和写两个动作。一个 key 即使一直没被重新写入,只要它被 get 过,也算最近使用。所以在 get 和 put 更新两个入口都必须调用 _move_to_head。
有些初版实现只在 put 时维护链表顺序,get 只查询不移动,结果就是:一个 key 刚被高频读到,却因为“写入时间早”被淘汰掉了。这违背了 LRU 的核心语义,也是很多错误实现最后通过不了测试的原因。
4.4 淘汰节点时注意链表为空的情况
在正常流程下,_pop_tail() 只在插入新节点之后调用,所以链表里至少会有一个真实节点,调用是安全的。但如果你在代码里主动调用了 _pop_tail() 做测试,或者在更新已有 key 的分支里误调用,就可能导致 tail.prev 指向 head,删除哨兵节点。
我的经验是给链表操作单独封装成函数,不要在主流程里直接改 node.prev.next 这种指针。封装之后,每个函数只做一件事,指针操作的复现性会好很多。
4.5 手动构造测试用例时,覆盖这几种模式
我刷这道题时习惯用这几个用例自检:
python复制# 基本流程
lru = LRUCache(2)
lru.put(1, 1)
lru.put(2, 2)
assert lru.get(1) == 1
lru.put(3, 3)
assert lru.get(2) == -1
# 覆盖更新
lru.put(1, 100)
assert lru.get(1) == 100
# 容量为1
lru2 = LRUCache(1)
lru2.put(1, 1)
lru2.put(2, 2)
assert lru2.get(1) == -1
assert lru2.get(2) == 2
更重要的是,跑完一个用例后,要手动模拟一遍链表顺序。LeetCode 不会打印链表,但你自己心里要有数:当前 head 到 tail 的顺序到底是怎样的。很多时候,代码逻辑没报错,但顺序已经错了,只是测试数据恰好没触发。
5. 工程落地时的三种形态:手写链表、OrderedDict 与 C++ list
5.1 Python 的 dict 本身有序,为什么还要手写链表
Python 3.7 起,dict 的插入顺序是有保证的。你可能会想到:直接用 dict 删除 key 再重新插入,不就能表示“最近使用”吗?比如:
python复制cache[key] = value
# 访问后
cache.pop(key)
cache[key] = value
这样确实能让 key 跑到结尾,复杂度也是 O(1)。但问题是,dict 的顺序代表的是插入顺序,不是维护链表节点时的任意移动顺序。而且你很难在不创建新对象的同时,把某个元素移动到尾部。最接近的是 OrderedDict,它提供了专门的 move_to_end。
所以,如果你只是想快速写出一个能提交的 Python 版本,我建议直接用 OrderedDict。如果你是想理解 LRU 的本质,还是应该手写一遍双向链表。面试官在见到 OrderedDict 版本时,大概率会继续追问“内部怎么实现的”。
5.2 用 OrderedDict 的三行版核心代码
python复制from collections import OrderedDict
class LRUCache:
def __init__(self, capacity: int):
self.capacity = capacity
self.cache = OrderedDict()
def get(self, key: int) -> int:
if key not in self.cache:
return -1
self.cache.move_to_end(key)
return self.cache[key]
def put(self, key: int, value: int) -> None:
if key in self.cache:
self.cache[key] = value
self.cache.move_to_end(key)
return
self.cache[key] = value
if len(self.cache) > self.capacity:
self.cache.popitem(last=False)
这个版本很简洁,可读性高,而且时间复杂度同样是 O(1)。OrderedDict 内部就是哈希表加双向链表,和我们在第 3 节手写的结构几乎一致。LeetCode 上如果用 Python 提交,这个版本完全够用。
但要注意,OrderedDict 的 move_to_end 和 popitem(last=False) 是封装好的操作,你不一定理解它内部发生了什么。面试时如果被问“如果让你不用 OrderedDict 实现”,你得能落到手写链表上。
5.3 C++ 版:unordered_map<int, list<pair<int,int>>::iterator> 是老标准答案
C++ 写这道题,最常见的套路是用 std::list 存键值对,用 unordered_map 存 key 到迭代器的映射。哈希表的值不是节点本身,而是 list 迭代器,这样就能在 O(1) 时间内操作链表中的任意位置。
cpp复制#include <list>
#include <unordered_map>
class LRUCache {
public:
LRUCache(int capacity) : cap_(capacity) {}
int get(int key) {
auto it = pos_.find(key);
if (it == pos_.end()) {
return -1;
}
data_.splice(data_.begin(), data_, it->second);
return it->second->second;
}
void put(int key, int value) {
auto it = pos_.find(key);
if (it != pos_.end()) {
it->second->second = value;
data_.splice(data_.begin(), data_, it->second);
return;
}
data_.emplace_front(key, value);
pos_[key] = data_.begin();
if (data_.size() > cap_) {
auto key_to_evict = data_.back().first;
pos_.erase(key_to_evict);
data_.pop_back();
}
}
private:
int cap_;
std::list<std::pair<int, int>> data_;
std::unordered_map<int, std::list<std::pair<int, int>>::iterator> pos_;
};
这里需要注意 splice 的用法:它可以把 list 内的某个节点转移到另一个位置,并且不复制数据,只调整指针,复杂度 O(1)。it->second 存的是迭代器,指向 list 中的节点。更新值时,直接通过迭代器修改 second 字段。
C++ 版本比 Python 版本多了一层“迭代器有效性”的考量。只要 list 节点没有被删除,迭代器就是有效的;但也正因如此,淘汰节点后,pos_ 里对应的迭代器必须同步 erase,否则后续 find 会访问悬空的迭代器,产生未定义行为。
5.4 手写时机怎么选
如果是日常项目,我不建议自己重复造轮子。Python 用 OrderedDict / functools.lru_cache,Java 用 LinkedHashMap,C++ 用上面的 list 组合,Guava 或者 Caffeine 这类库还能提供带 TTL、统计、并发控制的缓存。但刷题和面试场景不一样,手写链表是基本功,可以帮你理解这些库的设计取舍,也能在面试官追问时直接给出“内部是双向链表 + 哈希表”的答案。
6. 从 LRU 到缓存治理:并发、一致性和其他淘汰策略
6.1 多线程环境下的 LRU 是否还安全
上面的代码没有任何锁,单线程下没问题,多线程下就危险了。真实项目里,缓存通常会被多个线程同时访问。如果两个线程同时 put,或者一个线程 get 的同时另一个线程在淘汰节点,链表指针可能被改坏,哈希表也可能出现数据竞争。
最简单的做法是把 get 和 put 都放到同一个锁里面。比如 C++ 里加 std::mutex,Python 里加 threading.Lock。但要小心:如果缓存访问量很大,锁竞争会成为新瓶颈。更精细的做法是分段锁或者分片缓存,把 key 用哈希散列到多个 LRU 实例上,每个实例各有自己的锁。这样总吞吐量能提升,但会牺牲一部分全局 LRU 精确性。
还需要注意缓存命中率指标。生产环境里,大家会关注“缓存命中率”,命中了说明数据在缓存里,没命中就得回源。如果并发淘汰策略写错,缓存可能频繁抖动,命中率掉得厉害。线上排查时,不能只看代码逻辑,还要看指标曲线。
6.2 缓存一致性:LRU 只负责淘汰,不负责正确性
很多初学者会误以为有了 LRU 缓存,数据就一定是最新的。这完全是两码事。LRU 决定的是“容量满了踢谁”,但数据什么时候更新、什么时候作废,属于缓存一致性治理的范畴。
最常见的模型是 Cache Aside:读的时候先读缓存,没命中再读数据库,然后回填缓存;写的时候先写数据库,然后删掉缓存或更新缓存。这样做的目的是尽量保证缓存和数据库最终一致。如果在写操作后只更新缓存而不作废缓存,其他线程可能读到旧值。
还有几个经典问题:
| 场景 | 成因 | 常见应对 |
|---|---|---|
| 缓存穿透 | 查询一个不存在的 key,缓存和数据库都没有 | 布隆过滤器、缓存空值 |
| 缓存击穿 | 热点 key 过期瞬间,大量请求打到数据库 | 互斥锁、热点数据不过期 |
| 缓存雪崩 | 大量 key 同时过期,数据库压力激增 | 过期时间加随机值、多级缓存 |
这些都不是 LeetCode 146 直接考的点,但面试官很可能会从 LRU 的自然延伸开始问:“缓存满了怎么办”“缓存过期怎么办”“数据库更新了缓存怎么办”。所以刷完这道题,最好把缓存治理的基本模型也过一遍。
6.3 LRU 不是唯一选择:LFU 和 ARC 的取舍
LRU 非常适合“短时间热点集中”的业务。如果一个 key 突然爆火,它会被移动到链表头部,很难在短时间内被淘汰。但它对“历史高频但最近不再使用”的 key 并不友好。比如一个 key 曾经每秒被访问一万次,后来一小时没人访问了,LRU 也可能把它淘汰掉。LFU 就不一样,它统计每个 key 的访问频率,保留访问次数最多的 key。
LFU 的代价是实现复杂度高很多:需要维护频率到 key 集合的映射,还要处理频率变化。LeetCode 460 题就是这么一道更难的版本。ARC(Adaptive Replacement Cache)则会动态调整最近使用和历史使用之间的权重,是更高阶的缓存策略。
实际工程里选哪种,取决于你的访问分布。如果业务有明显的热点突发,LRU 就够用;如果希望给常驻低频 key 更长生命周期,可以考虑 LFU 或者带 TTL 的混合策略。LeetCode 146 的价值正是帮你先把最经典的 LRU 吃透,后面再看 LFU 和 ARC 时,理解成本会低很多。
6.4 从“手写 LRU”到“设计一个缓存系统”
如果你面对的是系统设计题,比如“设计一个本地缓存”,LRU 只是其中一个模块。你还需要考虑:
- 容量上限怎么配置,按 key 数量还是按内存大小。
- 是否需要 TTL,过期 key 如何处理。
- 淘汰策略是 LRU、LFU 还是混合。
- 并发控制选单锁、分段锁、还是无锁结构。
- 是否要异步持久化、统计命中率、监控淘汰数量。
很多生产级缓存框架,比如 Caffeine,内部已经把这些问题处理得很成熟。但你刷完 LeetCode 146 后再去读这些框架的设计,会发现它的核心数据结构就是你手写的这一套,只是在外围加了很多控制逻辑和优化。
7. 一点个人刷题体感
这道题我第一次手写的时候,犯过两个错:一是更新已有 key 时忘了改 value,二是淘汰节点后忘了同步删哈希表。这俩 bug 在 LeetCode 上都能跑过部分用例,但一旦触发就会非常隐蔽。后来我给自己定了个规矩:写完代码不要立刻提交,先在草稿纸上画一遍 head、tail、两个真实节点的指针变化,确认节点顺序最终是符合预期的。
如果你也在刷题,我建议把这道题当成“背模板”的入门题。很多题解会给双向链表版本,不会解释为什么用 list + unordered_map。你照着抄一遍能过,但如果不理解 _move_to_head 拆成的两步操作,面试时换个问法就容易卡住。我的经验是:第一遍手写时把链表操作拆成 remove 和 add_to_head 两个函数,第二遍再尝试闭着眼睛重写,直到能在五分钟内写出完整无 bug 的版本。这道题的代码量不大,但它值得你多刷几遍,把指针操作变成肌肉记忆。
