🔥个人主页: Milestone-里程碑
❄️个人专栏: <<力扣hot100>> <<C++>><<Linux>>
🌟心向往之行必能至
LRU(Least Recently Used,最近最少使用)缓存是操作系统、数据库和后端开发中的经典缓存淘汰策略,也是面试中高频手撕算法题。本文将从设计思路、数据结构选型到代码实现,一步步拆解,让你彻底掌握 LRU 缓存的核心逻辑。
一、什么是 LRU 缓存?
LRU 缓存的核心思想是:当缓存容量不足时,优先删除最近最少使用的数据,保证热点数据始终留在缓存中。
它需要满足两个核心操作:
get(key):查询 key 对应的 value,若不存在则返回 -1;若存在,将该数据标记为「最近使用」。put(key, value):插入或更新 key-value 对;若缓存已满,先删除「最近最少使用」的数据,再插入新数据。
性能要求 :get 和 put 操作的时间复杂度必须为 O(1),否则无法满足高并发场景下的性能需求。
二、数据结构选型:为什么是「双向链表 + 哈希表」?
要实现 O (1) 复杂度,单一数据结构无法满足需求,我们需要组合使用两种结构:
表格
| 数据结构 | 作用 | 优势 |
|---|---|---|
| 双向链表 (std::list) | 维护数据的「使用时序」 | 头部存最近使用数据,尾部存最久未使用数据;支持 O (1) 时间内移动节点、删除尾部节点、头部插入节点 |
| 哈希表 (std::unordered_map) | 快速定位数据 | 以 key 为键,存储对应链表节点的迭代器,实现 O (1) 时间查找任意节点 |
为什么不用单向链表?
单向链表无法在 O (1) 时间内删除中间节点(需要遍历找到前驱节点),而双向链表可以直接通过节点指针找到前驱,完美适配「移动节点到头部」和「删除尾部节点」的操作。
为什么哈希表要存迭代器?
迭代器是指向链表节点的「指针」,移动节点时迭代器不会失效,因此无需更新哈希表,直接复用即可,保证了操作的高效性。
三、C++ 代码实现(可直接用于面试)
cpp
运行
#include <list>
#include <unordered_map>
using namespace std;
class LRUCache {
private:
// 双向链表:存储 <key, value> 对,头部 = 最近使用,尾部 = 最久未使用
list<pair<int, int>> cache_list;
// 哈希表:key -> 链表节点迭代器,O(1) 定位节点
unordered_map<int, list<pair<int, int>>::iterator> cache_map;
// 缓存最大容量
int capacity;
public:
// 构造函数:初始化缓存容量
LRUCache(int cap) : capacity(cap) {}
int get(int key) {
// 1. 在哈希表中查找 key
auto it = cache_map.find(key);
// 2. 若不存在,返回 -1
if (it == cache_map.end()) return -1;
// 3. 若存在,将节点移动到链表头部(标记为最近使用)
cache_list.splice(cache_list.begin(), cache_list, it->second);
// 4. 返回对应 value
return it->second->second;
}
void put(int key, int value) {
// 1. 先查找 key 是否存在
auto it = cache_map.find(key);
if (it != cache_map.end()) {
// 1.1 存在:更新 value,并移动到头部
it->second->second = value;
cache_list.splice(cache_list.begin(), cache_list, it->second);
return;
}
// 2. 不存在:插入新节点到链表头部
cache_list.emplace_front(key, value);
// 2.1 在哈希表中记录节点迭代器
cache_map[key] = cache_list.begin();
// 3. 检查容量是否超限,超限则删除最久未使用的节点(链表尾部)
if (cache_map.size() > capacity) {
// 3.1 先删除哈希表中的映射(通过尾部节点的 key)
cache_map.erase(cache_list.back().first);
// 3.2 再删除链表尾部节点
cache_list.pop_back();
}
}
};
四、核心 API 深度解析
1. get(int key):查询 + 刷新使用时序
- 查找 :通过
cache_map.find(key)快速定位节点,时间复杂度 O (1)。 - 不存在:直接返回 -1。
- 存在 :
- 调用
splice函数将节点从当前位置移动到链表头部,标记为「最近使用」。 splice是 O (1) 操作,仅修改指针,无数据拷贝,效率极高。- 返回节点的 value。
- 调用
2. put(int key, int value):插入 / 更新 + 淘汰逻辑
分两种场景处理:
- 场景 1:key 已存在
- 更新节点的 value。
- 调用
splice将节点移动到头部,刷新使用时序。
- 场景 2:key 不存在
- 在链表头部插入新节点
(key, value)。 - 在哈希表中记录 key 对应的节点迭代器。
- 容量检查:若缓存大小超过容量,先删除哈希表中尾部节点的 key 映射,再删除链表尾部节点(最久未使用数据)。
- 在链表头部插入新节点
五、关键细节与面试考点
1. 为什么链表要存 key?
淘汰尾部节点时,需要通过节点的 key 去哈希表中删除对应的映射,否则会导致哈希表中存在无效键值对,造成内存泄漏。
2. splice 函数的妙用
cpp
运行
cache_list.splice(cache_list.begin(), cache_list, it->second);
- 第一个参数:目标位置(链表头部)。
- 第二个参数:源链表(当前链表自身)。
- 第三个参数:要移动的节点迭代器。
- 效果:将节点从原位置移动到头部,迭代器不会失效,无需更新哈希表。
3. 淘汰顺序的保证
链表尾部始终是「最近最少使用」的数据,因此 pop_back() 直接删除尾部节点,完美符合 LRU 策略。
4. 时间复杂度分析
get:哈希表查找 O (1) + 节点移动 O (1) → 总复杂度 O (1)。put:哈希表查找 O (1) + 节点插入 / 移动 O (1) + 容量检查 O (1) → 总复杂度 O (1)。
六、常见易错点
- 迭代器失效问题:移动节点时迭代器不会失效,因此哈希表无需更新;删除节点时,必须先删除哈希表映射,再删除链表节点。
- key 丢失问题:链表节点必须存储 key,否则无法在淘汰时删除哈希表映射。
- 容量判断 :判断缓存是否超限应使用
cache_map.size(),与链表大小完全一致,避免逻辑错误。