📌 题目链接:146. LRU 缓存 - 力扣(LeetCode) 🔍 难度:中等 | 🏷️ 标签:设计、哈希表、链表、双向链表
⏱️ 目标时间复杂度:O(1)
💾 空间复杂度:O(capacity)
✅ 本题是面试中的 经典数据结构设计题,常出现在大厂(如字节、腾讯、阿里、Google、Meta)的系统设计或高级算法面试中。
🎯 考察点:如何在 O(1) 时间内实现缓存淘汰策略?
💡 关键在于理解 “最近最少使用” 的语义,并用合适的数据结构支撑。
🔍 题目分析
我们要实现一个 LRU(Least Recently Used)缓存机制,满足以下要求:
- 支持
get(key)和put(key, value)操作; - 所有操作必须在 平均 O(1) 时间复杂度完成;
- 当容量满时,移除最久未使用的元素(即最后一次访问时间最早的元素);
⚠️ 注意:
- 如果
key不存在,get返回-1; put时若key已存在,则更新值并标记为“最近使用”;- 若超出容量,删除最久未使用的节点(尾部),再插入新节点到头部。
📌 核心挑战:
我们需要快速定位某个 key 是否存在 → 哈希表;
同时要维护访问顺序 → 双向链表;
两者结合才能做到 O(1) 的插入、删除和查找。
🔧 核心算法及代码讲解
🔄 核心思想:哈希表 + 双向链表
| 数据结构 | 功能 |
|---|---|
unordered_map<int, DLinkedNode*> cache | 快速查找 key 对应的节点位置(O(1)) |
DLinkedNode* head, DLinkedNode* tail | 伪头尾节点,避免边界判断 |
size 和 capacity | 控制缓存大小 |
🎯 为什么用双向链表?
- 单向链表只能向前遍历,无法高效地从中间删除节点;
- 双向链表支持 O(1) 删除任意节点(只要知道指针);
- 结合哈希表,我们可以先通过
cache[key]定位节点,再将其移到头部表示“最近使用”。
🧠 关键操作说明:
void moveToHead(DLinkedNode* node) {
removeNode(node);
addToHead(node);
}
👉 先删后插,保证顺序正确。
DLinkedNode* removeTail() {
DLinkedNode* node = tail->prev;
removeNode(node);
return node;
}
👉 尾部节点是最久未使用的,直接返回即可。
✅ 代码实现(完整版)
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
// 定义双向链表节点
struct DLinkedNode {
int key, value;
DLinkedNode* prev;
DLinkedNode* next;
DLinkedNode(): key(0), value(0), prev(nullptr), next(nullptr) {}
DLinkedNode(int _key, int _value): key(_key), value(_value), prev(nullptr), next(nullptr) {}
};
class LRUCache {
private:
unordered_map<int, DLinkedNode*> cache; // 哈希表:key -> 节点指针
DLinkedNode* head; // 伪头部
DLinkedNode* tail; // 伪尾部
int size; // 当前节点数
int capacity; // 最大容量
public:
LRUCache(int _capacity): capacity(_capacity), size(0) {
// 使用伪头部和伪尾部节点,简化边界处理
head = new DLinkedNode();
tail = new DLinkedNode();
head->next = tail;
tail->prev = head;
}
int get(int key) {
if (!cache.count(key)) {
return -1; // key 不存在
}
// 如果 key 存在,先通过哈希表定位,再移到头部
DLinkedNode* node = cache[key];
moveToHead(node);
return node->value;
}
void put(int key, int value) {
if (!cache.count(key)) {
// 如果 key 不存在,创建新节点
DLinkedNode* node = new DLinkedNode(key, value);
// 添加进哈希表
cache[key] = node;
// 添加至双向链表头部
addToHead(node);
++size;
// 检查是否超容量
if (size > capacity) {
// 删除双向链表尾部节点
DLinkedNode* removed = removeTail();
// 删除哈希表中对应项
cache.erase(removed->key);
// 防止内存泄漏
delete removed;
--size;
}
}
else {
// 如果 key 存在,更新值并移到头部
DLinkedNode* node = cache[key];
node->value = value;
moveToHead(node);
}
}
// 在链表头部添加节点
void addToHead(DLinkedNode* node) {
node->prev = head;
node->next = head->next;
head->next->prev = node;
head->next = node;
}
// 删除指定节点(已知指针)
void removeNode(DLinkedNode* node) {
node->prev->next = node->next;
node->next->prev = node->prev;
}
// 移动节点到头部:先删除,再添加
void moveToHead(DLinkedNode* node) {
removeNode(node);
addToHead(node);
}
// 删除尾部节点并返回
DLinkedNode* removeTail() {
DLinkedNode* node = tail->prev;
removeNode(node);
return node;
}
};
// 测试
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
// 示例测试
LRUCache lRUCache(2);
lRUCache.put(1, 1); // {1=1}
lRUCache.put(2, 2); // {1=1, 2=2}
cout << lRUCache.get(1) << endl; // 输出: 1
lRUCache.put(3, 3); // {3=3, 2=2},淘汰 1
cout << lRUCache.get(2) << endl; // 输出: 2
lRUCache.put(4, 4); // {4=4, 3=3},淘汰 2
cout << lRUCache.get(1) << endl; // 输出: -1
cout << lRUCache.get(3) << endl; // 输出: 3
cout << lRUCache.get(4) << endl; // 输出: 4
return 0;
}
🧩 解题思路(分步详解)
-
初始化结构体
- 创建
head和tail两个伪节点,构成空链表; - 初始化
cache哈希表,size = 0,capacity由构造函数传入;
- 创建
-
get 操作流程
- 查看
cache是否包含 key; - 若无 → 返回 -1;
- 若有 → 获取节点,调用
moveToHead(),返回其值;
- 查看
-
put 操作流程
- 若 key 不存在:
- 新建节点;
- 插入哈希表;
- 加入链表头部;
- 若 size > capacity → 删除尾部节点 + 删除哈希表项 + 释放内存;
- 若 key 存在:
- 更新 value;
- 移动到头部;
- 若 key 不存在:
-
链表操作细节
addToHead():将节点插入到head后面;removeNode():断开当前节点与前后节点的连接;moveToHead():组合操作,用于刷新“最近使用”状态;removeTail():返回尾部前一个节点(真实最后一个节点);
📊 算法分析
| 操作 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
get(key) | O(1) | O(1) | 哈希表查找 + 链表移动 |
put(key) | O(1) | O(1) | 插入/更新 + 可能删除尾部 |
| 构造函数 | O(1) | O(1) | 只初始化几个变量 |
| 总体空间 | O(capacity) | — | 存储最多 capacity 个节点 |
✅ 为什么是 O(1)?
- 哈希表查找:O(1)
- 链表增删:O(1),因为有指针直接定位
- 不需要遍历整个链表!
🔧 优化点:
- 使用伪头尾节点 → 避免对边界条件的判断(如
head == nullptr); - 手动管理内存(
delete removed)→ 防止内存泄漏(C++ 特性); - 哈希表存储的是指针 → 避免拷贝大量数据;
💡 面试常见问题 & 拓展知识
❓ Q1:为什么不直接用 map 或 vector?
map:虽然可以按顺序存储,但插入删除不是 O(1),且无法高效维护访问顺序;vector:随机访问快,但插入删除慢(O(n)),不适合频繁变动场景;- ✅ 哈希表 + 双向链表 是最优解!
❓ Q2:有没有其他方式实现 LRU?
是的!以下是替代方案:
| 方案 | 优缺点 |
|---|---|
| OrderedDict(Python) | 内置有序字典,自动维护顺序,简洁易懂 |
| Java LinkedHashMap | 提供 accessOrder=true 参数,可模拟 LRU |
| 跳表 / 平衡树 | 复杂度高,不推荐用于此场景 |
但在手写面试中,哈希表 + 双向链表 是标准答案。
❓ Q3:如果要用多线程怎么办?
- 需要加锁保护共享资源(
cache,head,tail); - 可以使用
std::mutex; - 或者使用线程安全容器(如
concurrent_unordered_map); - 但这超出了本题范围,属于系统设计范畴。
❓ Q4:LRU 有什么变种?
- LFU(Least Frequently Used):淘汰使用频率最低的;
- ARC(Adaptive Replacement Cache):更智能的替换策略;
- TTL 缓存:基于过期时间淘汰;
- 这些都是高级缓存系统的核心技术!
🧠 设计模式视角
本题体现了典型的 “组合模式” + “职责分离”:
cache负责快速查找;- 双向链表负责维护顺序;
- 二者协同工作,各司其职;
这也是很多高性能缓存系统(如 Redis、Memcached)的底层思想之一。
✅ 实际应用价值
| 应用场景 | 说明 |
|---|---|
| 浏览器缓存 | 页面加载时优先读取最近访问的内容 |
| 数据库查询缓存 | 经常执行的 SQL 查询结果缓存 |
| 操作系统页面置换 | 内存不足时淘汰最久未使用的页 |
| CDN 缓存服务器 | 用户请求时优先返回热门内容 |
👉 LRU 是现代计算机系统中最基础也最重要的缓存策略之一!
🌟 本期完结,下期见!🔥
👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!
💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪
📣 下一期预告:LeetCode 热题 100 第36题 —— 4.二叉树的中序遍历(简单)
🔹 题目:给定一个二叉树的根节点,返回其中序遍历的结果。
🔹 核心思路:使用递归或栈实现左→根→右的遍历顺序。
🔹 考点:递归、栈、树的遍历、深度优先搜索(DFS)。
🔹 难度:简单,但却是所有树遍历的基础,务必掌握!
💡 提示:不要只记住递归模板,要理解栈是如何模拟递归过程的!
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!