LRU
写在前面
近期在面试过程中,常会问到LRU这一算法。然而往往得到的答案并不如意,特此总结。
LRU(Least Recently Used) 是一种常见的 缓存淘汰算法,其基本思想是,如果数据最近被访问过,那么将来被访问的几率也比较高。可见,LRU有三个特点:
- 缓存空间有限
- 根据访问时间来决策是否从缓存中剔除某项
- 缓存中的每一项需根据时间进行排序
在日常开发中较为常用。举个例子,vue内置的 keep-alive 组件就是 LRU 的实际应用。
开始实现
分析
既然是缓存,那么主要实现的无非是三个内容(对于前端的简单应用来说):
- 缓存的数据结构:需要保证有序
- 缓存的 getter
- 缓存的 setter(add/put)
数据结构
什么样的数据结构,是有顺序的呢?第一个蹦出来的大概会是 array。
但是仅仅用array就行了吗?再分析分析:
对于JavaScript来说,array可以保证顺序,确实是我们需要的。然而对于一个缓存算法来说,取值、更新值的效率也很重要。当根据数组下标去获取 arrayItem 的时候,时间复杂度为 O(1),但实际情况往往是根据某个 id 或 key 去取值,此时array的时间复杂度即为 O(n)。
所以,要么需要更换一个数据结构,要么需要设置一些辅助变量,来帮助array提高 getter 的效率。
这里最容易想的,是同时用 array 和 object 来存储数据。array保证顺序,object保证 getter 效率。
当然还有更简单的方式:利用 es6 的 Map 结构。
The Map object holds key-value pairs and remembers the original insertion order of the keys.
想一下具体的逻辑步骤
- 初始化一个 Map,越新访问的值,越靠近 map 的尾部
- 在 getter 中,根据传入的key,查找是否存在对应的数据;如果有,则将其移动到 map 的尾部;如果没有,则返回 undefined
- 在 setter 中,先要根据传入的key,判断是否已存在同 key 的值;如果有,则更新其值,并移动到尾部;如果没有;则需要判断当前 map 的大小是否达到了
缓存空间的上限,达到了需要先丢弃 map 首部的元素,再往 map 尾部插入新的元素
talk is cheap, show me the code
下面的代码可以通过 leetcode-460 lru-cache
class LRUCache<T = any> {
private capacity: number;
private cache: Map<number, T>;
constructor(capacity: number) {
this.capacity = capacity;
this.cache = new Map();
}
get(key: number): T | number {
if (!this.cache.has(key)) {
return -1;
}
/**
* 将缓存中的值移动到 map 的尾部,需要拆解为
* 1. 移除元素
* 2. 在尾部插入该元素
*/
const tmp = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, tmp);
return tmp;
}
put(key: number, value: T): void {
/**
* 对于setter操作来说,有两种情况都需要移除原本的元素
* 1. 缓存中有同 key 的值
* 2. 缓存已达到存储上线
*/
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.capacity) {
/**
* this.cache.keys().next() 是 Iterator 接口定义的方法,Map 继承了 Iterator 接口
* 不清楚的话可以 [查看MDN文档](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/keys)
*/
this.cache.delete(this.cache.keys().next().value);
}
this.cache.set(key, value);
}
}
另一种实现思路 - 双向链表法
利用 Map (array + object同理)的方式更多的是体现在 JavaScript 语言中的快速实现。还有另一种更为经典的方案:双向链表
在 双向链表 法中,我们需要用一个 hash表(也就是一个object)来存储数据并保证 getter 的时间复杂度为 O(1),用双向链表来控制 setter 的时间复杂度为 O(1)。
具体的实现思路和 Map 基本一致,区别是链表中每一个节点的数据结构不再直接是需要缓存的值,以及操作的对象变成了链表(下面的代码也可以通过 leetcode 的测试)。具体过程不再赘述,看代码吧:
/**
* 首先定义链表节点的数据结构
* 因为是双向链表,所有会有 prev、next 两个指针
*/
class LinkNode<T = any> {
public key: number;
public value: T;
public prev: LinkNode<T> | null;
public next: LinkNode<T> | null;
constructor(key: number, value: T) {
this.key = key;
this.value = value;
this.prev = null;
this.next = null;
}
}
// 辅助函数和一些常量设置。常量的作用在下面注释中查看
const isNodeExist = (node?: LinkNode<any>) => typeof node !== 'undefined';
const fakeNodeKey = -1;
const fakeNodeValue = -1;
class LRUCache<T = any> {
private capacity: number;
private cache: Record<number, LinkNode<T>> = {};
/**
* 这里初始化两个虚拟的首尾节点,是一个小技巧
* 利用它们,在编写代码的过程中,就不需要过多关心首尾节点的边界问题
*/
private head = new LinkNode(fakeNodeKey, fakeNodeValue as unknown as T);
private tail = new LinkNode(fakeNodeKey, fakeNodeValue as unknown as T);
constructor(capacity: number) {
this.capacity = capacity;
this.head.next = this.tail;
this.tail.prev = this.head;
}
public get(key: number): T | number {
const node = this.cache[key];
if (!isNodeExist(node)) {
return -1;
}
this._moveToHeader(node);
return node.value;
}
public put(key: number, value: T): void {
const node = this.cache[key];
const newNode = new LinkNode(key, value);
if (isNodeExist(node)) {
this._removeNode(node);
} else {
if (Object.keys(this.cache).length >= this.capacity) {
this._popTail();
}
}
this._addNode(newNode);
}
private _moveToHeader(node: LinkNode<T>) {
this._removeNode(node);
this._addNode(node);
}
private _popTail(): LinkNode<T> {
const tailNode = this.tail.prev;
this._removeNode(tailNode);
return tailNode;
}
private _addNode(node: LinkNode<T>) {
node.prev = this.head;
node.next = this.head.next;
this.head.next.prev = node;
this.head.next = node;
this.cache[node.key] = node;
}
private _removeNode(node: LinkNode<T>) {
if (!node) return;
const prevNode = node.prev;
const nextNode = node.next;
prevNode.next = nextNode;
nextNode.prev = prevNode;
node.prev = null;
node.next = null;
delete this.cache[node.key];
}
}