第1章 LRU缓存原理与淘汰策略
LRU(Least Recently Used,最近最少使用)缓存是现代系统中最经典的缓存淘汰策略之一。它的核心思想很简单:当缓存空间满时,优先淘汰那些最长时间未被访问的数据。这一策略基于时间局部性原理——刚刚被访问的数据,在不久的将来很可能再次被访问;而那些长时间未被触及的数据,未来被访问的概率也较低。
💡 核心要点:LRU 不关心数据被访问了多少次,只关心数据被访问的“时间先后”。在 LRU 中,最近被访问过的数据永远保留在缓存头部,而最久未使用的数据沉到尾部,成为被淘汰的候选者。
1.1 最近最少使用淘汰策略详解
LRU 淘汰策略维护一个严格的“访问时间轴”。每当数据被访问(get 或 put 命中),该数据就会被提升到时间轴的最前端;当缓存达到容量上限时,尾部的数据被移除。这一机制保证:
- 热点数据驻留:高频访问的数据始终靠近头部,不会被误淘汰。
- 冷数据自然淘汰:长时间未被访问的数据逐渐移动到尾部,在缓存满时被清理。
- 无需预知访问模式:LRU 完全基于历史访问记录做决策,适用于大多数通用场景。
⚠️ 常见误区:不要将 LRU 与 LFU(Least Frequently Used,最不经常使用)混淆。LFU 基于访问频次淘汰,LRU 基于访问时间淘汰。例如:一个数据被频繁访问但最近几分钟未使用,在 LRU 中可能被淘汰,而在 LFU 中则可能保留。两种策略适用于不同业务特征。
1.2 双向链表 + 哈希表:LRU 的经典实现组合
纯数组或单链表无法在 O(1) 时间内同时完成“查找”、“插入”和“删除”三种操作。LRU 缓存需要一种复合数据结构:双向链表(Doubly Linked List)+ 哈希表(HashMap)。二者分工明确:
| 数据结构 | 职责 | 关键能力 |
|---|---|---|
| 双向链表 | 维护数据的时间访问顺序 | O(1) 时间将节点移动到头部;O(1) 时间删除尾部节点 |
| 哈希表 | 存储 key 到链表节点的映射 | O(1) 时间通过 key 找到对应的链表节点 |
为什么必须用双向链表?因为删除一个节点时,需要同时修改其前驱和后继节点的指针。单向链表无法 O(1) 获取前驱节点,必须遍历查找,导致删除操作退化为 O(n)。双向链表每个节点都持有 prev 和 next 两个指针,可在常数时间内完成节点摘除。
🛠 设计技巧:在双向链表中使用伪头节点(dummy head)和伪尾节点(dummy tail)作为哨兵。它们不存储实际数据,只作为边界标识。这样做的好处是:插入和删除时无需处理空指针边界情况,代码更简洁,鲁棒性更强。
1.3 核心数据结构定义
以下 C++ 代码展示了 LRU 缓存的核心节点结构和类定义。注意哨兵节点的初始化方式:
// 双向链表节点定义
struct DLinkedNode {
int key;
int 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) {}
};
// LRU 缓存类
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);
void put(int key, int value);
private:
void removeNode(DLinkedNode* node);
void moveToHead(DLinkedNode* node);
DLinkedNode* removeTail();
};
哈希表 unordered_map<int, DLinkedNode*> 存储 key 到链表节点的映射。这样,通过 key 查找节点的时间为 O(1),然后借助链表指针完成节点移动或删除。
1.4 get / put 操作流程与时间复杂度分析
两个核心操作完全依赖上述组合结构,每一步都能在常数时间内完成:
get(key) 操作
- 在哈希表中查找 key:若不存在,返回 -1。
- 若存在,通过哈希表拿到对应的链表节点指针。
- 将该节点从链表中当前位置摘除(
removeNode)。 - 将该节点移动到链表头部(
moveToHead),表示最近被使用。 - 返回节点的 value 值。
put(key, value) 操作
- 在哈希表中查找 key:若存在,更新节点 value,然后将其移动到头部。
- 若不存在:
- 创建新节点,插入链表头部,同时添加哈希表映射。
size++。若size > capacity,则淘汰尾部节点:- 调用
removeTail()摘除尾节点(伪尾节点的前驱)。 - 从哈希表中删除该节点的 key。
delete节点释放内存,size--。
- 调用
| 操作 | 平均时间复杂度 | 最坏时间复杂度 | 关键步骤 |
|---|---|---|---|
get(key) |
O(1) | O(1) | 哈希查找 + 链表摘除 + 头部插入 |
put(key, value) |
O(1) | O(1) | 哈希插入 + 链表头部插入(可能触发尾部淘汰) |
removeNode(node) |
O(1) | O(1) | 双向链表指针修改 |
moveToHead(node) |
O(1) | O(1) | 摘除 + 头部插入两步组合 |
📊 复杂度结论:LRU 缓存的 get 和 put 操作均能在 O(1) 平均时间复杂度内完成。这是双向链表+哈希表组合的最大优势——所有核心操作都不涉及遍历或全量扫描。
下面的 SVG 结构图直观展示了 LRU 缓存中哈希表与双向链表的协作关系。左侧哈希表存储 key 到链表节点的映射,右侧双向链表维护数据的时间顺序,黄色说明框概括了淘汰策略:
🧠 理解映射关系:上图中,哈希表每个槽位存储一个 key 对应的链表节点指针(如 key=1 → Node1)。当需要访问 key=1 时,哈希表以 O(1) 定位到 Node1,然后通过链表指针将 Node1 移动到 Head 之后。这种“哈希表定位 + 链表重排”的组合是 LRU 实现的核心范式。