什么是LRU算法
首先要了解的是 LRU 算法是缓存淘汰策略中的经典方案;因为计算机系统中,缓存的容量是有限的,当缓存空间不足的时候,需要根据特定的算法淘汰一些部分数据,LRU算法就是其中之一。
LRU全称:Least Recently Used。直接翻译过来就是最近最少使用 ; 这样理解可能有点模糊,可以理解为最久未使用 ;
从概念中也可以了解到,LRU的核心思想就是:淘汰最久未使用的数据 。我们要实现这个算法, 就要维护一个有序列表,将这些缓存根据访问的时间进行排序,当空间不足的时候,将最后面的数据删除掉。
可以举个例子:
- 初始状态:head ↔ tail
- 插入A:head ↔ A ↔ tail
- 插入B:head ↔ B ↔ A ↔ tail
- 访问A:head ↔ A ↔ B ↔ tail
- 容量满时插入C:淘汰B → head ↔ C ↔ A ↔ tail
现在我们捋一下,实现LRU要解决的问题:
- 维护数据使用的顺序
- 当数据被访问时。有两种情况:
-
- 如果数据在缓存中,将它移到头部
- 如果数据不在缓存中,将它直接添加到头部。并且需要判断缓存是否已经满了,如果满了则需要淘汰
- 当数据被淘汰时:拿到尾部的数据进行淘汰,
有了思路代码实现起来就方便很多了。
基于Map的简单实现
因为ES6的Map可以保持插入顺序,可以方便地维护访问顺序。
这里最新的数据放在了最后面,最久未使用的放在最前面。
js
class LRUCache {
constructor(capacity) {
this.capacity = capacity; // 缓存的最大容量
this.cache = new Map();// 使用Map作为缓存存储,记住键的原始插入顺序
}
// 获取缓存中的值
get(key) {
if (!this.cache.has(key)) return -1;
// 获取值
const value = this.cache.get(key);
// 因为这个数据被访问了,所以先将这个数据再原有的位置删除,重新排序。
this.cache.delete(key);
// 重新设置键值对,
this.cache.set(key, value);
return value;
}
// 向缓存中添加键值对
put(key, value) {
// 判断键是否存在
if (this.cache.has(key)) {
// 如果已经存在,先删除
this.cache.delete(key);
} else if (this.cache.size >= this.capacity) {
// 如果不存在,并且内存空间已满,就需要拿到最久未使用的值,将他删除。
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
// 设置被访问的键值对顺序,
this.cache.set(key, value);
}
}
基于双向链表+哈希表经典实现
js
// 双向链表节点类
class ListNode {
constructor(key, value) {
this.key = key; // 存储键
this.value = value; // 存储值
this.prev = null; // 前指针
this.next = null; // 后指针
}
}
class LRUCache {
constructor(capacity) {
this.capacity = capacity; // 缓存容量限制
this.map = new Map(); // 哈希表,
// 创建虚拟头尾节点,这样跟容易处理再边界的时候
this.head = new ListNode(-1, -1); // 虚拟头节点
this.tail = new ListNode(-1, -1); // 虚拟尾节点
// 初始化双向链表关系
this.head.next = this.tail;
this.tail.prev = this.head;
}
// 将节点移动到链表头部,表示最近使用
_moveToHead(node) {
this._removeNode(node); // 先从链表中移除
this._addToHead(node); // 再添加到头部
}
// 从链表中移除指定节点
_removeNode(node) {
// 修改前后节点的指针,跳过当前节点
node.prev.next = node.next;
node.next.prev = node.prev;
}
// 将节点添加到链表头部
_addToHead(node) {
// 将节点插入到虚拟头节点和第一个实际节点之间
node.next = this.head.next; // 新节点的next指向原第一个节点
node.prev = this.head; // 新节点的prev指向虚拟头节点
// 调整原第一个节点的prev指针和新节点的next指针
this.head.next.prev = node; // 原第一个节点的prev指向新节点
this.head.next = node; // 虚拟头节点的next指向新节点
}
// 获取缓存中的值
get(key) {
if (!this.map.has(key)) return -1; // 键不存在
const node = this.map.get(key); // 从哈希表获取节点
this._moveToHead(node); // 移动到头部表示最近使用
return node.value; // 返回值
}
// 向缓存中添加键值对
put(key, value) {
if (this.map.has(key)) {
// 键已存在,更新值并移动到头部
const node = this.map.get(key);
node.value = value; // 更新值
this._moveToHead(node); // 移动到头部
} else {
if (this.map.size >= this.capacity) {
// 缓存已满,需要淘汰最久未使用的节点,也就是尾节点
const lastNode = this.tail.prev; // 获取虚拟尾节点的前一个节点
this._removeNode(lastNode); // 从链表中移除
this.map.delete(lastNode.key); // 从哈希表中删除
}
// 创建新节点并添加到头部
const newNode = new ListNode(key, value);
this.map.set(key, newNode); // 加入哈希表
this._addToHead(newNode); // 添加到链表头部
}
}
}
看到这里你可能会有疑问,明明第一种实现方案中使用了 Map 就已经实现了,为什么第二种还要添加链表呢?多次一举吗?
这个可能是陷入了一种误区,错把 哈希表 和 Map 划为等号了。 第二种方式,我们只是使用JS中的Map来模拟了哈希表,也可以使用Object来模拟。哈希表是一种根据键值对来直接访问数据的数据结构,是一种映射关系。
所以第二种实现中, 双向链表维护了顺序 ,哈希表维护了值;