如果你在浏览器里刷网页,系统会在硬盘上悄悄缓存一份"最近访问过的页面或图片",下次再点同一个链接就直接从本地读,快上不少。这个"把最近用过的数据临时存起来、加速访问"的做法,就是缓存(Cache)的日常写照。而缓存一多、空间一满,就必须决定"该把谁腾出去"。今天这一课,我们就来讲清楚缓存淘汰里最经典、最常考、工程里也用得最广的一种策略——LRU Cache,并且要用 C++ 一步一步手写出一个 get、put 都是 O(1) 的实现。
先给你一个俯瞰:LRU Cache 的核心是两个数据结构——哈希表(Hash Table / unordered_map)和双向链表(Doubly Linked List)——分工合作,哈希表负责 O(1) 找到数据,双向链表负责 O(1) 记录"谁最近用过、谁最久没用过"。你千万别以为这是两个互不相干的东西叠在一起,恰恰相反,它们通过一个巧妙的"指针桥接"被焊成了一个整体。等我们把下面这些章节一一剥开,你会发现它比想象中简单,而且处处是"为什么这么设计"的讲究。
什么是 LRU:最久未使用优先淘汰
LRU 是英文 Least Recently Used 的缩写,字面意思是"最近最少使用"。初次看到这个名字你可能有点绕——既然叫"最少使用",怎么课件里又说它是"最久未使用"?其实两种说法指的是同一件事,只是站的角度不同。"最近最少使用"是说"在所有被用过的数据里,最近这段时间里用得最少/最久没用过的那个";而"最久未使用"则更直白——离上一次被使用间隔最久的东西。这么一翻译,你是不是觉得**"最久未使用"其实更形象**?一点没错。LRU 算法的淘汰原则说得再通俗一点就是:
当缓存满了、又要放入新数据时,优先把"距离现在时间最久、一直没被碰过"的那个数据项驱逐出去,腾出位置给新数据。
这里要顺带分清三个和 LRU 经常并列出现的词,它们是同一族的"淘汰算法":
| 算法 | 英文 | 淘汰依据 | 一句话直觉 |
|---|---|---|---|
| LRU | Least Recently Used | 离上次使用时间最久 | 扔掉"最久没碰"的 |
| LFU | Least Frequently Used | 使用频率最低 | 扔掉"用得最少"的 |
| FIFO | First In First Out | 先进先出 | 扔掉"最先进来"的 |
它们的差异在于"用什么标准衡量谁该死"。LRU 看的是"时间上的久不久",LFU 看的是"次数上的少不少",FIFO 看的是"进来的先后"。实际工程里三者各有适用,但考纲和面试里最热门的就是 LRU,所以今天我们把它吃透。
再补充一个词根的澄清,帮你彻底告别"LRU 到底淘汰哪个"的困惑。很多人背会了"LRU 淘汰最久未使用",一试手就懵。根源在于:LRU 这个缩写强调的是"Least Recently Used(最近最少使用的)",但真正被选中淘汰的,恰恰是那些"最久没被使用的"(Least Recently Used 的极端),也就是 Most Recently Used 的反面。 中文里把"Least Recently Used"译成"最近最少使用",字面上容易让人误以为是"最近用得少的"。其实淘汰的是整体上"最久没被用过"的那个,也就是在时间轴上离现在最远的那一次使用。你只要记住"谁最久没被碰,谁先出局"十个字,就再也不会搞反了。
现在我们把背景落到 Cache(缓存) 这个词上。它是怎么来的?计算机里有很多对"速度相差很大"的搭档——CPU 快但寄存器/内存慢、内存快但硬盘慢、硬盘快但网络慢。为了不让慢的一方拖垮快的一方,硬件工程师在两者之间加了一层"小但快"的临时存储,叫缓存。它的作用就是把经常用、最近用的数据就近放一份在快的地方,下次直接用,省去每次都去慢的地方取。狭义的 Cache 特指 CPU 和主存之间那块用昂贵 SRAM(一种速度快但成本高的静态随机存储器)做的快速 RAM;广义的 Cache 就宽泛多了——内存与硬盘之间的页缓存、硬盘与网络之间的"Internet 临时文件夹"/网络内容缓存,都算 Cache 的广义范畴。不管哪种,它们的共同点都是:容量有限,满了就要淘汰,而 LRU 就是决定"淘汰谁"的那把尺子。
Cache 命中与未命中:get 的两条路
在写任何缓存代码之前,有两个贯穿始终的概念必须先立起来——命中(Cache Hit)与未命中(Cache Miss)。它俩描述的是"你请求的数据到底在不在缓存里"。
先说命中:你要访问的 key,在缓存里找到了对应的 value,直接从缓存返回,速度极快。我们管这个过程叫"命中缓存",对应英文 hit。
再说未命中:你要的 key 在缓存里没有。这时缓存无法直接回答你,只能告诉调用者"我这里没有",调用者再去慢速的后备存储(数据库、磁盘、远端服务器)取真实数据。这一过程叫"未命中",对应英文 miss。经典缓存 API 里,未命中的约定返回是一个"哨兵值"——比如返回 -1 表示"没找到"。
这里就引出了 LRU Cache 中最容易被人忽略、却又是我老生常谈的一个重点:在 LRU 里,"命中"和"未命中"对缓存状态的影响是完全不同的。 未命中,说明这次访问没有命中任何已缓存项,状态没变(除了可能随后执行淘汰/插入);而命中,意味着这个数据"刚刚又被使用了",它就不再是"最久未使用"了,必须把它在链表里的"新鲜度"刷新到最前。这一段逻辑是 LRU 的灵魂,很多初学者实现时只在 put 里搬来搬去、忘了 get 也要搬,结果缓存彻底失去"LRU 意义"。这一块我们在代码节会重点拆。
现在,我们手上有了三个概念:LRU(淘汰策略)、Cache(缓存容器)、命中/未命中(访问结果)。接下来要回答的问题是:用什么数据结构,才能在 get 和 put 都保持 O(1) 的前提下,实现"命中就刷新新鲜度、满了就淘汰最久"?
为什么选哈希表 + 双向链表
实现 LRU Cache 的办法很多,暴力一点的做法是:用一个数组存数据,再用一个计数器记时间戳,每次访问就更新时间戳,满了就线性扫描找"时间戳最小的"淘汰。这个方案能正确,但每次淘汰要 O(N) 扫一遍、每次刷新时间戳也要找,效率太低。有没有办法让 get 和 put 都是常数的 O(1)?有,而且业界公认的经典答案是——双向链表 + 哈希表。
先说为什么是双向链表。链表擅长在"已知位置"做插入和删除——只要你手里握着某个结点的指针或迭代器,借、删它都只要改写前后两个指针,时间复杂度是 O(1)。而 LRU 的一切操作:把结点搬到"最新"位置(头)、把"最久"的结点删掉(尾),本质都是"在已知位置的一步插入/删除"。单链表行不行?也行,但麻烦——单链表删一个结点时你得从头找到它的前驱才能改指针,这一步就是 O(N);双向链表每个结点自带 prev 指针,删除时无需找前驱,直接一步到位。这就是为什么双向链表是标配。
再说为什么是哈希表。上面的链表逻辑再丝滑,也掩盖不了一个致命问题:怎么知道某个 key 对应的结点在链表的哪个位置? 如果用遍历去找,那就是 O(N)。哈希表(这里用 C++ 的 unordered_map,底层是哈希表,增删查的均摊时间复杂度都是 O(1))正好解决"按 key 快速定位"这个需求——哈希表把 key 映射成 value,而这里的最妙设计是:把 value 存成"key 在链表里对应的迭代器/指针"。这样,你给一个 key,哈希表直接吐给你"它在链表里的地址",有了地址,链表就能在 O(1) 内完成删除和搬家。
一句话总结这个组合的分工:
哈希表负责"按 key 找到结点在哪",双向链表负责"用 O(1) 的搬移表达先后顺序";两者通过"哈希表 value = 链表结点地址"这座桥,把"查找"和"排序"两个需求同时压到了 O(1)。
你可能会问:那为什么不用 vector/数组?因为数组中间插入删除要搬移元素,O(N);也不用 set/map?因为红黑树的增删查是 O(logN),虽然也很好,但 LRU 能压到 O(1),自然选更优的。哈希表的 O(1) 是"均摊"的(极端哈希冲突时可能退化,但 unordered_map 自带扩容和冲突处理,工程上按 O(1) 用是放心的),这就是"哈希 O(1)"三个字的精确含义——不是绝对常数,而是均摊到常数、几乎恒定。
现在,动手写之前,还有一件必须做的事:约定"哪边是最近、哪边是最久"。这是最容易让代码前后矛盾出 bug 的源头。
约定表头最近、表尾最久:契约先行
双向链表要表达"使用顺序",就必须定一条"方向契约",否则你会不知道该从哪头淘汰、搬到哪头。课件和我们下面的实现,统一采用这条约定:
链表头部是"最近刚被使用"的结点,链表尾部是"最久未被使用"的结点。 因此:
- 任何一次访问(get 命中)或插入(put 新 key),都把对应结点搬到头部,表示"它刚刚才被用过";
- 缓存满了,就从尾部把一个结点弹出,表示"最久没被用的该死"。
请你把这条契约刻在脑子里,后面所有代码都严格遵循它,前后一致就不会乱。代码里你会看到我们反复做两件事:push_front(头插=记为最新)和 pop_back(尾删=淘汰最久)。
这里插一句源材料里埋的一个小坑:课件那一版的 put 在"缓存已满"的注释里写的是"删除链表头的数据",但紧跟着的代码实际执行的是 _list.pop_back()(删的是表尾)。这就是注释和代码打架了——按我们刚定的契约,满的时候该删的是最久未使用的表尾,而不是表头。课件作者笔误了,你以代码(表尾淘汰)为准,这也正好提醒你:读任何 LRU 代码,第一件事就是确认它的方向契约,再看它的插删操作是否自洽。 我们这个系列会给你两份自洽的完整实现。
版本一:std::list + unordered_map(逐行拆解)
先在工程上最舒坦的方式——直接复用 STL 的 std::list(它本身就是双向链表)和 std::unordered_map。这一版能让你把"方向契约""指针桥接"这些抽象概念落在真实可运行的代码上。
先看成员设计(类声明 + 构造):
#include <list> // std::list:底层的双向链表
#include <unordered_map> // std::unordered_map:底层的哈希表
#include <utility> // std::pair、std::make_pair
#include <iostream> // std::cout
using namespace std;
class LRUCache
{
public:
// 构造:记录容量上限
LRUCache(int capacity)
: _capacity(capacity) // 容量由外部传入,_list 和 _map 用默认构造即可
{}
private:
// _list 存的是 <key, value>,表头最近、表尾最久(方向契约)
list<pair<int, int>> _list;
// 容量上限,超过就淘汰表尾
int _capacity;
// 哈希表:key -> 该 key 在 _list 中对应结点的迭代器(指针桥接)
// 有了这个迭代器,我们才能在链表里 O(1) 定位并删除/搬家
unordered_map<int, list<pair<int, int>>::iterator> _map;
};这里 _map 的 value 类型 list<pair<int,int>>::iterator 是整个设计最关键的一笔——它就是前面说的"哈希表 value = 链表结点地址"那座桥。如果 value 只存 value(数据本体),那命中后你还是得去链表里找位置,找不到就前功尽弃;把迭代器存进去,命中时哈希表直接掐着链表的"把儿",删除、搬家都顺手拈来。
接下来是 get。它的逻辑:查哈希表,命中就把那个结点搬到头部(刷新新鲜度)并返回值,未命中返回 -1。
// get:按 key 取值,命中并刷新为最近使用;未命中返回 -1
int get(int key)
{
// 1. 在哈希表里找这个 key
auto hashIt = _map.find(key);
// 2. 未命中:返回哨兵值 -1
if (hashIt == _map.end())
{
return -1; // Cache Miss,缓存里没有
}
// 3. 命中:拿到该 key 在链表中的迭代器
auto listIt = hashIt->second; // 通过"桥"取得链表位置
// 4. 把这个结点搬到链表头部,表示"刚刚又被使用了"
// 步骤:先拷贝出数据 -> 在原位置删除 -> 头插 -> 重新登记迭代器
pair<int, int> kv = *listIt; // 解引用迭代器,得到 <key, value>
_list.erase(listIt); // 从原位置删除,旧指针作废
_list.push_front(kv); // 插到链表头部成为最新
_map[key] = _list.begin(); // 重新登记:key 现在指向新的头部
// 5. 返回值
return kv.second;
}_map[key] = _list.begin() 这一步绝不能漏。因为 erase 之后,旧的迭代器已经失效(被删掉了),哈希表里还留着它的话,下次再用就是访问悬垂迭代器,崩溃或未定义行为。所以每一次"删除再搬家"之后,都必须把哈希表里这个 key 的迭代器更新成新的链表头部迭代器。这是版本一里最容易被初学者漏掉、却又是正确性命门的一步。
接着是 put。分两种情况:key 已存在(更新),key 不存在(新增,可能要淘汰)。
// put:写入 <key, value>;key 已存在则更新并刷新,否则新增并可能淘汰
void put(int key, int value)
{
// 1. 在哈希表里找这个 key
auto hashIt = _map.find(key);
// 情况 A:key 已经存在 -> 更新值,并搬到头部
if (hashIt != _map.end())
{
auto listIt = hashIt->second; // 通过桥拿到链表位置
pair<int, int> kv = *listIt; // 拷贝出原结点
kv.second = value; // 只改 value,key 不变
_list.erase(listIt); // 原位置删除(旧迭代器作废)
_list.push_front(kv); // 头插,记为最近使用
_map[key] = _list.begin(); // 重新登记迭代器
return; // 更新完成,直接返回
}
// 情况 B:key 不存在 -> 新增一个结点
// B1:如果已经满了,先淘汰表尾(最久未使用),给新数据腾位置
if ((int)_list.size() >= _capacity)
{
// 注意:删除表尾时,必须同步删掉哈希表中该 key 的登记,
// 否则哈希表里会残留一个"指向已删除结点"的失效迭代器
_map.erase(_list.back().first); // 取表尾结点的 key,从哈希表删除
_list.pop_back(); // 再删链表的表尾结点
}
// B2:插入新结点到头部,并在哈希表登记
_list.push_front(make_pair(key, value));
_map[key] = _list.begin();
}这段代码有两处"删除"必须配套执行,缺一不可:
- 淘汰表尾时:
_list.pop_back()删链表,同时必须_map.erase(_list.back().first)删哈希表登记。只删一边,另一边就会留下"失效引用/孤儿数据",要么下一次访问悬垂迭代器,要么缓存里多了一个永远找不到的幽灵 key。 - 更新已存在 key 时:同样的"erase 头删 + push_front 头插",配合
_map[key] = _list.begin()重新登记迭代器。
老规矩,任何"搬移之后哈希表还指向旧迭代器"都会出问题,记住一句话:链表里的迭代器和哈希表里的登记必须时刻同步,谁动链表,谁就要回头更新哈希表。
这版完整代码已附在文末(见"版本一:可直接编译运行的代码"一节),你直接把 LRUCache 类拷进一个 .cpp,配一个 main 就能跑。
版本二:手写双向链表 + unordered_map
STL 的 std::list 是把双向链表的实现细节藏起来了。但面试和手撕场景常常要求你自己把双向链表写出来,尤其力扣的链表题默认不允许你依赖 STL 的 list。所以这一节,我们把双向链表从零手写,用裸的 Node* prev/next 指针,配合哨兵结点(sentinel),实现一个更自主、也更接近"硬核手写"的版本。
先讲手写版的两个改进点,它们让代码更稳:
- 引入哨兵结点(头哨兵
_head、尾哨兵_tail)。 所谓哨兵结点,就是不存实际数据、纯粹当"边界锚点"的哑结点。有了它们,头插、尾删、搬移时都不需要单独判断"链表是不是空的""被删的是不是头结点"这些边界,统一按"在哨兵之间操作"来写,代码少一大截边界分支,也不会出现对nullptr瞎操作的悬垂。这是很多工程链表实现的惯用技巧。 - 哈希表 value 存
Node*指针而非迭代器。 对应关系与版本一的"迭代器桥接"完全同构,只不过这里是裸指针。
先看结点类型的定义:
#include <unordered_map> // std::unordered_map
#include <iostream>
using namespace std;
class LRUCache
{
private:
// 双向链表结点:除了数据,还持有前后指针
struct Node
{
int key; // 键
int value; // 值
Node* prev; // 前驱指针
Node* next; // 后继指针
// 构造函数:用 key/value 初始化,前后指针先置空
Node(int k, int v)
: key(k)
, value(v)
, prev(nullptr)
, next(nullptr)
{}
};
int _capacity; // 容量上限
unordered_map<int, Node*> _map; // 哈希表:key -> 链表结点指针
Node* _head; // 头哨兵,不存数据;head->next 是最近使用
Node* _tail; // 尾哨兵,不存数据;tail->prev 是最久未使用再看三个私有工具函数,它们是手写版的"手术刀",先把它们想透,get/put 就只是"调用这些手术刀"而已。
// 工具一:从双向链表中摘除一个结点(O(1),因为用的是裸指针、无需找前驱)
void removeNode(Node* node)
{
node->prev->next = node->next; // 让前驱的 next 跳过当前结点
node->next->prev = node->prev; // 让后继的 prev 跳过当前结点
// 注意:这里不 delete,只是"拆下来",由调用方决定是否释放
}
// 工具二:把一个结点插到头部哨兵后面,表示"刚被使用"
void insertFront(Node* node)
{
node->next = _head->next; // node 的后继:原来的队首
node->prev = _head; // node 的前驱:头哨兵
_head->next->prev = node; // 原来的队首的前驱改为 node
_head->next = node; // 头哨兵的后继改为 node
}
// 工具三:把"已存在"的结点搬到头部 = 先摘除 + 再头插(仍然 O(1))
void moveToFront(Node* node)
{
removeNode(node);
insertFront(node);
}哨兵的价值此刻就显现出来了:removeNode 里直接写 node->prev->next 而不用担心 node->prev 是空——因为任何真实结点的前后总有哨兵或兄弟结点兜底;insertFront 里 _head->next 也永远存在(最坏是指向 _tail),所以无需任何空指针分支。边界被哨兵吸收掉了,代码自然就干净、少 bug。 这就是手写链表时"宁可多申请两个哑结点,也不要满屏判空"的工程哲学。
有了这三把手术刀,构造、析构、get、put 就很直白了。
public:
// 构造:分配头尾哨兵并互连,初始时它俩形成空环链表(list 为空)
LRUCache(int capacity)
: _capacity(capacity)
, _head(new Node(0, 0)) // 哨兵不存真实数据,(0,0) 只是占位
, _tail(new Node(0, 0))
{
_head->next = _tail; // 头哨兵的后继指向尾哨兵
_tail->prev = _head; // 尾哨兵的前驱指向头哨兵
}
// 析构:释放所有结点(包括哨兵),防止内存泄漏
~LRUCache()
{
Node* cur = _head; // 从头部哨兵开始遍历
while (cur)
{
Node* next = cur->next; // 先记下后继,否则删除当前结点后指针就悬了
delete cur; // 释放当前结点
cur = next; // 顺着后继前进
}
}
// get:命中则搬到头部并返回值;未命中返回 -1
int get(int key)
{
auto it = _map.find(key); // 哈希表找 key
if (it == _map.end())
{
return -1; // 未命中
}
Node* node = it->second; // 拿到链表结点指针
moveToFront(node); // 命中即刷新为"最近使用"
return node->value; // 返回值
}
// put:key 已存在则更新值并刷新;不存在则新增,满了先淘汰
void put(int key, int value)
{
auto it = _map.find(key);
if (it != _map.end())
{
// 情况 A:key 已存在 -> 更新值 + 搬到头部
Node* node = it->second;
node->value = value; // 原地改值(key 不变)
moveToFront(node); // 刷新新鲜度
return;
}
// 情况 B:key 不存在 -> 新增
if ((int)_map.size() >= _capacity)
{
// 缓存已满:淘汰表尾(最久未使用)
Node* last = _tail->prev; // 尾哨兵的前一个就是最久未使用
_map.erase(last->key); // 先从哈希表删登记
removeNode(last); // 再从链表摘除
delete last; // 释放内存(这是手写版多的一环)
}
// 新增结点,插入头部并在哈希表登记
Node* node = new Node(key, value);
_map[key] = node; // 登记:key 指向这个新结点
insertFront(node); // 插到头部哨兵之后,记为最近使用
}注意手写版相比版本一多出的两个细节:
- 析构必须手动释放每个结点,因为手写链表没有
std::list的自动管理。漏写析构就是内存泄漏,这是手写版特有的责任。 - 淘汰时
delete last释放内存,因为removeNode只"拆链"不"析构",这句不能省。顺序必须是:先_map.erase(哈希表不再引用,防止之后访问悬垂指针)→removeNode(链表断开)→delete(真正归还内存)。
你看,除了多管内存,手写版的 get/put 骨架和版本一一模一样——因为核心思路本就是同一个:"哈希表定位 + 双向链表搬移 + 满则淘汰表尾"。这也从另一个角度印证了:LRU 的难度不在写链表,而在想清楚"什么时候搬、什么时候淘、哈希表怎么和链表同步"。 链表你拿来即用(版本一)或自己造轮子(版本二)都行,思想一致。
版本二的完整可运行代码见文末"版本二:可直接编译运行的代码"一节。
边界与坑:get 更新、put 满淘汰、指针更新与 nullptr
写到这里,算法骨架你已经会了。可真正的功力都藏在边界和细节里。我把这两版实现里反复出现的坑集中列出来,一一讲透,这些也是面试官最爱在 LRU 上设的雷。
坑一:get 命中也必须把结点搬到头部。 这是新手最常犯的错——以为 get 只是"找一下、返回一下"。错!get 命中意味着这个 key 刚刚被使用过一次,按照 LRU 的语义,它已经不再是"最久未使用",必须立刻把它刷新到链表头部。如果你 get 只返回值、不搬家,那么"最久未使用"的顺序就失真了,一旦缓存满了,本不该被淘汰的 key 会被误杀。记住:get 和 put 在"刷新新鲜度"这件事上地位完全平等。
坑二:put 已存在的 key,也是"更新 + 搬家",不淘汰。 注意区分:put(旧key, 新value) 时,缓存里这个 key 已经在了,它不额外占新空间,所以不需要触发淘汰,只需把 value 更新并搬到头部。只有"插入一个全新的、不存在的 key"才可能触发淘汰。把这一点和坑一放在一起,结论就是:所有对已存在 key 的操作(get 命中、put 更新)都只搬家不淘汰;只有真正引入新 key 才看容量、决定要不要淘汰。
坑三:容量判断的临界点。 每个版本都写的是 size() >= capacity 才淘汰。为什么要用 >= 而不是 >?因为你想让数据最多放 capacity 个,多一个都不行,所以是"满了(恰好等于)就要先淘汰再插入"。如果你写 >,那么缓存会短暂放到 capacity+1 个,超员了——不优雅,还可能让后续判断错乱。临界值用 >= 就是"整装待发时先把多余的踢掉"。
坑四:删除/搬移后,哈希表里旧迭代器/指针作废,必须同步更新。 版本一的 _map[key] = _list.begin()、版本二的 _map[key] = node(新结点)或淘汰时 _map.erase,这些都是为了"链表动了、哈希表跟着动"。漏更新 = 哈希表指向已删除结点 = 悬垂指针/悬垂迭代器 = 下一次访问即 UB(未定义行为,可能崩溃、可能读到垃圾)。这是 C++ 里最容易出隐患的地方,务必养成"动链表必同步哈希表"的习惯。
坑五:nullptr / 空链表边界由哨兵消解。 手写版如果你不用哨兵,就得在"第一次插入""删到最后一个结点""get 空缓存"等处逐个判空,极易漏。用哨兵后,_head->next 永远不会是空指针(最坏指向 _tail),_tail->prev 同理,removeNode/insertFront 根本不需要判空分支。这是手写链表推荐哨兵设计的核心理由。
坑六:方向契约必须全程自洽。 "头最近、尾最旧" 决定了——头插记为最新、尾删记为淘汰、命中搬头部。任何一处把方向搞反(比如删了头、或搬到了尾部),整套逻辑就全乱了,而且 bug 往往不在当场爆发,而是在后面某次淘汰时才露馅,极难排查。写之前先把契约写死在注释里(就像我们的代码第一个注释那样),写的时候对着契约核对。
坑七:capacity 为 0 或异常小。 如果题目/调用方传了 capacity = 0(一个不合理的缓存),那任何 put 都该"立即淘汰"——因为我们用 size() >= capacity 判断,capacity=0 时任何一次插入都会先走进淘汰分支(此时链表为空,_tail->prev/_list.back() 引用空),处理不好就可能崩。工程健壮实现应对 capacity 做非法值兜底(例如小于 1 时按 1 对待)。力扣原题保证 capacity >= 1,所以 OJ 上不用额外处理,但你要意识到这个边界存在,这是"为什么教科书代码在真实工程里不能直接照抄"的典型例证。
这七个坑,覆盖了 LRU 里所有会"静默出错"的角落。你把它们背熟,写任何语言/任何版本的 LRU 都能避开 90% 的雷。下面我们把这些正确性要求落到最实战的场景——力扣 146。
力扣 146 LRU Cache 题解
力扣 146「LRU 缓存」是这道题的教材级出处,题目给你的就是这一套接口:构造器接收容量;get(key) 命中返回值、未命中返回 -1,且命中要把它更新为最近使用;put(key, value) 若 key 已存在则更新值(并把该 key 标记为最近使用),不存在则插入,若容量已满,在插入前需先删除最久未使用的 key。它要求的正是我们一直在讲的"双向链表 + 哈希表",get/put 都得 O(1)。
下面给出一份可直接提交、编译运行的力扣版本,用意是:结构清晰、命名语义化、含 main 自测,方便你本地跑通再理解。就用手写版的思路(最贴近链表手撕场景)。
#include <unordered_map>
#include <iostream>
using namespace std;
class LRUCache {
private:
// 双向链表结点:存 key 与 value,并带前后指针
struct Node {
int key;
int value;
Node* prev;
Node* next;
Node(int k, int v) : key(k), value(v), prev(nullptr), next(nullptr) {}
};
int capacity_; // 容量上限
unordered_map<int, Node*> mp; // 哈希表:key -> 链表结点指针
Node* head; // 头哨兵,head->next 为最近使用
Node* tail; // 尾哨兵,tail->prev 为最久未使用
// 从链表中摘除结点(O(1))
void removeNode(Node* node) {
node->prev->next = node->next;
node->next->prev = node->prev;
}
// 把结点插到头哨兵后面(记为最近使用)
void insertHead(Node* node) {
node->next = head->next;
node->prev = head;
head->next->prev = node;
head->next = node;
}
// 把已存在结点搬到头部:摘除 + 头插
void moveToHead(Node* node) {
removeNode(node);
insertHead(node);
}
public:
// 构造:初始化容量,并让头尾哨兵互连为"空链表"
LRUCache(int capacity) : capacity_(capacity) {
head = new Node(0, 0);
tail = new Node(0, 0);
head->next = tail;
tail->prev = head;
}
~LRUCache() { // 释放全部结点,避免内存泄漏
Node* cur = head;
while (cur) {
Node* nxt = cur->next;
delete cur;
cur = nxt;
}
}
// 取 key 对应的值;命中则标记为最近使用;未命中返回 -1
int get(int key) {
// 用哈希表定位,O(1)
if (mp.find(key) == mp.end())
return -1; // 未命中
Node* node = mp[key];
moveToHead(node); // 命中即刷新为最近使用
return node->value;
}
// 写入;key 已存在则更新并刷新;否则新增,满了先淘汰最久未使用
void put(int key, int value) {
if (mp.count(key)) { // 已存在:更新值 + 搬到头部
Node* node = mp[key];
node->value = value;
moveToHead(node);
return;
}
if ((int)mp.size() >= capacity_) { // 已满:淘汰尾部结点
Node* last = tail->prev; // 最久未使用的结点
mp.erase(last->key); // 从哈希表删登记
removeNode(last); // 从链表摘除
delete last; // 释放内存
}
Node* node = new Node(key, value); // 新建结点
mp[key] = node; // 哈希表登记
insertHead(node); // 头插为最近使用
}
};
// 自测用例:跑一遍就知道结果对不对
int main() {
LRUCache cache(2);
cache.put(1, 1);
cache.put(2, 2);
cout << cache.get(1) << endl; // 输出 1;此刻缓存里 2 变成最久
cache.put(3, 3); // 满了,淘汰 key 2
cout << cache.get(2) << endl; // 输出 -1(key 2 已被淘汰)
cache.put(4, 4); // 再满,淘汰 key 1(因 get(1) 很久没用)
cout << cache.get(1) << endl; // 输出 -1(key 1 已被淘汰)
cout << cache.get(3) << endl; // 输出 3
cout << cache.get(4) << endl; // 输出 4
return 0;
}把这段提交到力扣 146,去掉 main 就能过;把 main 留着本地跑,输出应该是 1 / -1 / -1 / 3 / 4。你完全可以拿这个格式去对照课件 4.1 节给的那串演示(1 → get1 得 1 → put3 淘汰 2 → get2 得 -1 → put4 淘汰 1 → get1 得 -1 → get3 得 3 → get4 得 4),语义完全对得上。
完整可运行代码(版本一与版本二)
下面把两个版本都做成"可以直接复制成一个 .cpp 文件编译运行"的完整程序,方便你亲手跑、亲手改序列验证。文件开头加 #include <iostream> 等头文件、结尾配一个 main 打自测用例。
版本一:STL 的 list + unordered_map
#include <iostream> // std::cout
#include <list> // std::list 双向链表
#include <unordered_map> // std::unordered_map 哈希表
#include <utility> // std::pair / std::make_pair
using namespace std;
class LRUCache
{
public:
LRUCache(int capacity)
: _capacity(capacity) // 记住容量上限
{}
// get:命中则刷新为最近使用并返回值;未命中返回 -1
int get(int key)
{
auto hashIt = _map.find(key); // 哈希表定位
if (hashIt == _map.end())
return -1; // 未命中
auto listIt = hashIt->second; // 拿到链表迭代器(桥接)
pair<int, int> kv = *listIt; // 拷贝出数据
_list.erase(listIt); // 原位置删除
_list.push_front(kv); // 头插为最近使用
_map[key] = _list.begin(); // 重新登记迭代器(关键,勿漏)
return kv.second;
}
// put:key 在则更新并刷新;不在则新增,满了先淘汰表尾
void put(int key, int value)
{
auto hashIt = _map.find(key);
if (hashIt != _map.end())
{
// 已存在:只更新 value,并搬到头部
auto listIt = hashIt->second;
pair<int, int> kv = *listIt;
kv.second = value;
_list.erase(listIt);
_list.push_front(kv);
_map[key] = _list.begin();
return;
}
// 已满:先淘汰表尾(最久未使用),并同步删哈希表登记
if ((int)_list.size() >= _capacity)
{
_map.erase(_list.back().first); // 删哈希表登记
_list.pop_back(); // 删链表表尾
}
// 新增:头插 + 登记
_list.push_front(make_pair(key, value));
_map[key] = _list.begin();
}
private:
list<pair<int, int>> _list; // 链表:表头最近、表尾最久
int _capacity; // 容量
unordered_map<int, list<pair<int, int>>::iterator> _map; // key -> 链表迭代器
};
int main()
{
LRUCache cache(2);
cache.put(1, 1);
cache.put(2, 2);
cout << "get(1) = " << cache.get(1) << endl; // 1
cache.put(3, 3); // 淘汰 2
cout << "get(2) = " << cache.get(2) << endl; // -1
cache.put(4, 4); // 淘汰 1
cout << "get(1) = " << cache.get(1) << endl; // -1
cout << "get(3) = " << cache.get(3) << endl; // 3
cout << "get(4) = " << cache.get(4) << endl; // 4
return 0;
}版本二:手写双向链表 + unordered_map(含哨兵)
#include <iostream> // std::cout
#include <unordered_map> // std::unordered_map
using namespace std;
class LRUCache
{
private:
// 双向链表结点
struct Node
{
int key;
int value;
Node* prev;
Node* next;
Node(int k, int v) : key(k), value(v), prev(nullptr), next(nullptr) {}
};
int _capacity;
unordered_map<int, Node*> _map; // key -> 链表结点指针
Node* _head; // 头哨兵:head->next 是最近使用
Node* _tail; // 尾哨兵:tail->prev 是最久未使用
// 摘除一个结点(O(1))
void removeNode(Node* node)
{
node->prev->next = node->next;
node->next->prev = node->prev;
}
// 头插(记为最近使用)
void insertFront(Node* node)
{
node->next = _head->next;
node->prev = _head;
_head->next->prev = node;
_head->next = node;
}
// 已存在结点搬到头部
void moveToFront(Node* node)
{
removeNode(node);
insertFront(node);
}
public:
LRUCache(int capacity)
: _capacity(capacity)
, _head(new Node(0, 0))
, _tail(new Node(0, 0))
{
_head->next = _tail; // 空链表初始互连
_tail->prev = _head;
}
~LRUCache()
{
Node* cur = _head;
while (cur)
{
Node* nxt = cur->next;
delete cur;
cur = nxt;
}
}
int get(int key)
{
auto it = _map.find(key);
if (it == _map.end())
return -1; // 未命中
Node* node = it->second;
moveToFront(node); // 命中刷新
return node->value;
}
void put(int key, int value)
{
auto it = _map.find(key);
if (it != _map.end())
{
Node* node = it->second;
node->value = value; // 更新值
moveToFront(node); // 搬到头部
return;
}
if ((int)_map.size() >= _capacity)
{
Node* last = _tail->prev; // 最久未使用
_map.erase(last->key); // 删哈希表
removeNode(last); // 拆链
delete last; // 释放
}
Node* node = new Node(key, value);
_map[key] = node; // 登记
insertFront(node); // 头插
}
};
int main()
{
LRUCache cache(2);
cache.put(1, 1);
cache.put(2, 2);
cout << "get(1) = " << cache.get(1) << endl; // 1
cache.put(3, 3); // 淘汰 2
cout << "get(2) = " << cache.get(2) << endl; // -1
cache.put(4, 4); // 淘汰 1
cout << "get(1) = " << cache.get(1) << endl; // -1
cout << "get(3) = " << cache.get(3) << endl; // 3
cout << "get(4) = " << cache.get(4) << endl; // 4
return 0;
}两个版本跑出来的输出完全一致:1 / -1 / -1 / 3 / 4。你可以把哨兵版和不哨兵版逻辑逐行对上,会发现骨架一一镜像——再次印证"LRU 的困难不在链表本身,而在同步与契约"。
思考题与详解
学完别急着走,下面几道题把最容易混淆、最容易写错的地方重新敲一遍。每题都给了完整详解,看完才算是真的吃透了。
思考题 1:为什么用"双向链表"而不是"单链表"来维护使用顺序?
详解:LRU 的核心操作之一是"把一个命中/更新的结点从链表中删除,再头插"。单链表删除一个结点时,因为你只知道"当前结点指针",而它的前驱需要通过从头遍历才能找到——这一步就是 O(N)。双向链表每个结点自带 prev 指针,删除时直接 node->prev->next = node->next 一步完成,是 O(1)。正是这一项差别决定了双向链表才是 LRU 的正解。换句话说:单链表删结点要先找前驱(O(N)),双向链表删结点有前驱指针(O(1))。
思考题 2:哈希表(unordered_map)的 value 为什么要存"链表迭代器/结点指针",而不是直接存 value?
详解:如果 value 只存数据本体(比如直接存 int value),那么命中后我们还是不知道这个 key 在链表的哪个位置,要"搬家"就得遍历链表找,又退化成 O(N)。把 value 存成"链表迭代器/结点指针"后,哈希表一查到 key,等价于直接把链表的"座位"精确定位到手——删除、头插、搬家全是 O(1)。这就是"哈希表 value = 链表结点地址"的桥接思想,是整个 LRU 能实现双 O(1) 的支点。
思考题 3:get 命中一个已存在的 key,为什么要立刻把它搬到链表头部?不搬行不行?
详解:不搬不行。LRU 的语义是"最久未使用优先淘汰",而"使用"既包括 put,也包括 get。一次 get 命中,就意味着这个 key 刚刚被访问了一次,它不该再被认为是"最久未使用"的。如果 get 不更新新鲜度,那么缓存里的顺序就会失真——某些其实被频繁 get 的 key 会被错误地判为"老数据"而遭淘汰,缓存命中率骤降。所以,get 命中必须像 put 一样把结点搬到头部。 这也是力扣 146 题目里明确写了"get 操作会使得该 key 成为最近使用"的原因。
思考题 4:新增一个 key 时,为什么插在链表头部而不是尾部?淘汰为什么删尾部?
详解:根据我们定下的方向契约"头部最新、尾部最旧"。新插入的数据,"刚刚才被使用过",理所当然是最新的,所以插到头部;而最久没被用的数据会随着时间沉到尾部。于是满了需要腾位置时,淘汰最久的自然就从尾部下手。这条契约只要前后一致即可(你完全可以反过来:头部最旧、尾部最新,配套操作也全部镜像对应),关键是全程自洽,不能"头插结点却又删头部"这样自相矛盾。
思考题 5:手写链表版本里,头哨兵(_head)和尾哨兵(_tail)到底有什么用?没有它们行不行?
详解:哨兵是"不存数据、纯当边界锚点"的哑结点。它的价值在于:头插、删结点这些操作有了哨兵后,代码里就不需要区分"链表空不空、删的是不是头结点、插入位置是否越界"等边界分支——_head->next 和 _tail->prev 永远至少指向哨兵,永远不是空指针。没有了哨兵,你就得在每个操作前加 if (node == nullptr)、if (链表为空) 之类的判空,既啰嗦又容易漏(漏了就是空指针解引用崩溃)。所以哨兵是用"多分配两个结点"的微小代价,换来"边界逻辑归零"的干净代码,是手写链表(尤其这种频繁头插删的)强烈推荐的写法。
思考题 6:为什么淘汰判断用 size() >= capacity,而不是 >?
详解:缓存最多允许放 capacity 个数据,所以只要"当前已满(等于 capacity)"并要插入新数据,就必须先淘汰一个腾位置。用 >= 保证任意时刻缓存规模都不超过上限;若误用 > 则会在"恰好满了"时继续插入、暂时多出一个数据(超员到 capacity+1),既打破了容量语义,也可能让后续其他假设失真。临界比较统一用 >= 是"满了就先出手"的稳妥写法。
思考题 7:put 一个"已经存在"的 key,这次操作会不会触发淘汰?容量会变吗?
详解:不会触发淘汰,容量也不变。因为 key 已存在,相当于"原地更新值 + 刷新新鲜度",并没有引入新的数据项,缓存里还是原来那批 key,数量没有增加,自然也就没有"满"的问题。只有插入一个全新的 key、且此时恰好满了,才会在插入前淘汰最久未使用的那一个。区分"更新(不占新空间、不淘汰)"与"新增(占新空间、可能淘汰)",是 put 里最容易写砸的分支。
思考题 8:用 std::list 的实现(版本一)里,_map[key] = _list.begin() 这句为什么不能少?
详解:因为 _list.erase(listIt) 会把原结点从链表删除,那个旧迭代器随之失效。如果哈希表里还存着这个失效迭代器,下一次关于这个 key 的操作(get/put)就会去解引用一个"指向已释放/失效结点"的迭代器,行为未定义,轻则读到脏数据、重则崩溃。所以每次"删除再头插"之后,必须用 _list.begin() 更新哈希表登记,让"哈希表里的地址"始终是"当前有效链表结点"。这条纪律和手写版里"淘汰时先 _map.erase 再从链表删"是同一件事的两面——动链表,必同步哈希表。
思考题 9:目录/面试里常见的"LRU-K"和"LFU"是什么?和 LRU 有何不同?
详解:LRU 只看"距上次使用的时间长短";LRU-K 是它的改良,记录每个 key 最近 K 次访问的时间戳,只有访问次数达到 K 次才进入缓存、未满前不轻易淘汰——用来防止"偶发的一次冷访问"就把 LRU 的缓存搞乱,常用于数据库页缓存优化。**LFU(Least Frequently Used)**记录的是"使用频率",淘汰"单位时间内用得最少"的 key,适合"热点稳定"的场景,但实现更复杂(要额外维护频率桶)。三者面试里常被放到一起考概念辨析,你要能说出 LRU 看"时间近远"、LFU 看"次数多少、LRU-K 是"加上频次门槛的 LRU"。今天这篇是实现 LRU;想清楚它的取舍,以后再看 LFU/LRU-K 会轻松很多。
串一遍:把 LRU Cache 装进你的工具箱
最后我们把这一整节课串成一个整体。LRU Cache 要解决的是"缓存满了,把谁踢出去"的问题——答案是踢掉"最久未使用"的那个。 为了让它 get/put 都保持 O(1),我们用两大法宝:双向链表来维护"谁新谁旧"的顺序(头最新、尾最旧);哈希表来按 key O(1) 定位"某个 key 在链表的哪个位置",两者通过"哈希表 value = 链表结点地址"这座桥合成一套。任何一次命中或更新,都要把对应结点搬回头部刷新新鲜度;任何一次新增,满了就先从尾部淘汰最久,再在头部插入新结点。删除与搬移之后,必须同步更新哈希表,避免悬垂指针/失效迭代器。手写版再补上哨兵结点来吸收边界、析构函数来回收内存。
这件"数据结构的组合拳",其实背后是一条通用心法:单靠一种结构往往顾此失彼——链表修罗序、哈希快查询,两者通过"地址互指"取长补短。 类似的思路你在别的领域还会反复撞见(比如并查集、邻接表、索引结构),所以 LRU 学的绝不只是"背一道题",而是一种"用组合结构把多个需求同时压到 O(1)"的工程直觉。当你面试被问到"实现一个 LRU",能脱口而出"双向链表保顺序 + 哈希表保定位 + 命中就搬家 + 满就淘汰尾部 + 动链表必更新哈希表"——恭喜你,这一课算是真正落地了。
去把你自己的 .cpp 建起来,把两版代码敲一遍、把思考题的答案在注释里写一遍,再跑跑不同容量、不同的 put/get 序列。写通的那一刻,你就再也不会怵任何 LRU 变体了。
还没有评论 — 第一条由你来留。