1. 核心概念 (Core Concept)
LRU (Least Recently Used) 缓存是一种基于**“最近最少使用”**原则的缓存淘汰策略。当缓存容量达到上限,需要淘汰旧数据时,LRU 算法会优先移除那些最近最久未被访问的数据项。
2. 为什么需要它? (The "Why")
- 提高数据访问效率: 将常用数据保存在快速访问的缓存中,避免频繁从慢速存储(如磁盘、网络)中读取,显著提升系统性能。
- 控制内存使用: 在有限的内存空间内,通过淘汰不常用的数据,确保缓存不会无限增长,避免内存溢出或过度占用。
- 命中率优化: LRU 策略基于局部性原理,认为最近访问的数据在未来很可能再次被访问,从而提高缓存命中率。
3. API 与用法 (API & Usage)
模拟实现 LRU 缓存通常需要支持以下两个核心操作:
put(key, value): 将键值对添加到缓存中。如果key已存在,则更新其对应的值,并将其标记为“最新使用”。如果key不存在且缓存已满,则淘汰最近最少使用的数据项,然后添加新的键值对。get(key): 获取指定key对应的值。如果key存在,则返回对应值,并将其标记为“最新使用”;如果key不存在,则返回特殊值(如 -1 或 null)。
为了高效地实现这两个操作,通常会结合使用哈希表(用于快速查找)和双向链表(用于维护访问顺序)。哈希表的键是缓存的 key,值是指向双向链表中对应节点的指针。双向链表则按照访问的新旧顺序存储数据,链表头部是最近使用的,尾部是最近最少使用的。
以下是一个使用 JavaScript 模拟实现的经典 LRU 缓存结构示例:
javascript
class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.cache = new Map(); // 哈希表:用于存储 key 到链表节点的映射
// 双向链表,用于维护访问顺序
this.head = new Node(); // 哨兵节点:链表头部,表示最新使用
this.tail = new Node(); // 哨兵节点:链表尾部,表示最近最少使用
this.head.next = this.tail;
this.tail.prev = this.head;
}
// 双向链表节点结构
class Node {
constructor(key, value) {
this.key = key;
this.value = value;
this.prev = null;
this.next = null;
}
}
// 将节点移动到链表头部(表示最新使用)
_moveToHead(node) {
this._removeNode(node);
this._addNode(node);
}
// 在链表头部添加节点
_addNode(node) {
node.prev = this.head;
node.next = this.head.next;
this.head.next.prev = node;
this.head.next = node;
}
// 移除指定节点
_removeNode(node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
// 移除链表尾部节点(最近最少使用)
_removeTail() {
const nodeToRemove = this.tail.prev;
this._removeNode(nodeToRemove);
return nodeToRemove;
}
get(key) {
if (this.cache.has(key)) {
const node = this.cache.get(key);
this._moveToHead(node); // 访问后移动到头部
return node.value;
}
return -1; // 或 null,表示未找到
}
put(key, value) {
if (this.cache.has(key)) {
const node = this.cache.get(key);
node.value = value; // 更新值
this._moveToHead(node); // 更新后移动到头部
} else {
// 新建节点
const newNode = new Node(key, value);
this.cache.set(key, newNode);
this._addNode(newNode); // 添加到链表头部
// 检查容量,如果超出则淘汰尾部节点
if (this.cache.size > this.capacity) {
const tailNode = this._removeTail();
this.cache.delete(tailNode.key); // 从哈希表中删除
}
}
}
}
// 示例用法
const lruCache = new LRUCache(2);
lruCache.put(1, 1); // 缓存: {1=Node(1,1)},链表: head <-> Node(1,1) <-> tail
lruCache.put(2, 2); // 缓存: {1=Node(1,1), 2=Node(2,2)},链表: head <-> Node(2,2) <-> Node(1,1) <-> tail
console.log(lruCache.get(1)); // 输出 1,访问 1,1 移动到头部
// 缓存: {1=Node(1,1), 2=Node(2,2)},链表: head <-> Node(1,1) <-> Node(2,2) <-> tail
lruCache.put(3, 3); // 容量已满,淘汰最近最少使用的 2
// 缓存: {1=Node(1,1), 3=Node(3,3)},链表: head <-> Node(3,3) <-> Node(1,1) <-> tail
console.log(lruCache.get(2)); // 输出 -1,2 已被淘汰
lruCache.put(4, 4); // 容量已满,淘汰最近最少使用的 1
// 缓存: {4=Node(4,4), 3=Node(3,3)},链表: head <-> Node(4,4) <-> Node(3,3) <-> tail
console.log(lruCache.get(1)); // 输出 -1
console.log(lruCache.get(3)); // 输出 3,访问 3,3 移动到头部
// 缓存: {4=Node(4,4), 3=Node(3,3)},链表: head <-> Node(3,3) <-> Node(4,4) <-> tail4. 关键注意事项 (Key Considerations)
- 数据结构选择: LRU 的高效实现依赖于哈希表 O(1) 的查找速度以及双向链表 O(1) 的节点插入和删除操作。单链表或仅使用数组是不行的。
- 链表头尾: 双向链表的头部通常表示最近使用的元素,尾部表示最近最少使用的元素(准备被淘汰)。哨兵节点(Dummy Head/Tail)可以简化边界情况的处理。
- 哈希表存储链表节点引用: 哈希表的值不是数据本身,而是指向双向链表中对应数据节点的引用。这样通过 key 找到节点后,可以直接操作链表来更新访问顺序。
- 操作复杂度: 采用哈希表和双向链表结合的实现,
get和put操作的时间复杂度均为 O(1)。
5. 参考资料 (References)
- LeetCode 146. LRU 缓存 - LeetCode 上的经典题目,提供多种语言的实现思路和解法。
- 如何实现一个 LRU 缓存 - 知乎 - 一篇关于 LRU 原理和实现的讲解,有助于理解其背后的思想。
- 各种高级编程语言(如 Java, Python, 等)的标准库中可能有 LinkedList 和 HashMap/Dict 的实现,可作为参考底层数据结构选择的依据。
