JavaScript LRU缓存
诸葛小愚 人气:0LRU全称为Least Recently Used,即最近使用的。针对的是在有限的内存空间内,只缓存最近使用的数据(即get和set的数据),超过有限内存空间的数据将会被删除。这个在面试题中也是常会被问到的内容,接下来就看看怎么来实现。
分析
从定义来看,LRU至少有两个特性:通过键值对读写、有序。实现键值对读写,一般我们会使用哈希表来表示,注意哈希表是一个逻辑结构,实际上我们需要使用object
、map
等来实现;数据有序,即最近使用的数据放在前面,已经过时的数据放在后面或被删除,并且支持数据是可排序的,我们可以想到数组、链表、map之类的数据格式。因此,我们有三种方式可以实现LRU缓存:
- Map
- Object + Array
- LinkedList
使用Map实现LRU缓存
Map
对象保存的是键值对,并且可以记住键的原始插入顺序。
const map = new Map(); map.set(2, 2); map.set(1, 2); console.log(map); // Map(2) {2 => 2, 1 => 2},按照原始的插入顺序 const obj = new Object(); obj[2] = 2; obj[1] = 1 console.log(obj); // {1: 1, 2: 2},不会按照原始的插入顺序
那么我们就可以根据Map
的特性实现LRU,下面是原理图:
实现代码:
class LRUCache { data = new Map(); // 数据map constructor(length) { if (length < 1) throw new Error('长度不能小于1') this.length = length } set(key, value) { const data = this.data; // 如果存在该对象,则直接删除 if (data.has(key)) { data.delete(key); } // 将数据对象添加到map中 data.set(key, value); if (data.size > this.length) { // 如果map长度超过最大值,则取出map中的第一个元素,然后删除 const delKey = data.keys().next().value data.delete(delKey); } } get(key) { const data = this.data; // 数据map中没有key对应的数据,则返回null if (!data.has(key)) return null; const value = data.get(key); // 返回数据前,先删除原数据,然后在添加,就可以保持在最新 data.delete(key); data.set(key, value); return value; } }
测试一下:
const lruCache = new LRUCache(2); lruCache.set('1', 1); // Map(1) {1 => 1} lruCache.set('2',2); // Map(2) {1 => 1, 2 => 2} console.log(lruCache.get('1')); // Map(2) {2 => 2, 1 => 1} lruCache.set('3',3); // Map(2) {1 => 1, 3 => 3} console.log(lruCache.get('2')); // null lruCache.set('4',4); // Map(2) {3 => 3, 4 => 4} console.log(lruCache.get('1')); // null console.log(lruCache.get('3')); // Map(2) {4 => 4, 3 => 3} console.log(lruCache.get('4')); // Map(2) {3 => 3, 4 => 4}
运行结果:
使用Map
基本上就可以实现LRU缓存,并且其兼容性还可以,除了IE兼容性差点:
如果不使用Map,那么还可以使用什么方式实现LRU缓存呢?
使用Object + Array实现LRU缓存
刚刚我们也分析了,LRU需要满足两点:键值对和可排序,这两点可以分别对应到Object
和Array
,那么我们是不是就可以以此为思路实现呢?答案是可以的,实现代码:
class LRUCacheObjArr { length = 0; // 定义列表最大长度 dataObj = {}; // 使用对象暂存数据,在可保证get时间复杂度为O(1) dataArr = []; // 使用数组解决有序的问题 constructor(length) { if (length < 1) throw new Error('参数非法') this.length = length; } get(key) { if (!this.dataObj[key] || !this.dataArr.length) return null; // 需要将访问到的值,重新放在数组的最末尾,表示最新的数据 const index = this.dataArr.findIndex(item => item.key === key); // 先删除原数据,然后push到数组末尾 this.dataArr.splice(index, 1); this.dataArr.push(this.dataObj[key]); // 返回值,对象是无序的,我们只需要保证里面的数据是最新的即可 return this.dataObj[key].value; } set(key, value) { // 定义对象数据 const obj = {key, value}; const index = this.dataArr.findIndex(item => item.key === key); // 判断是否为新增还是修改 if (index !== -1) { // 如果已存在数据,则先删除,然后push到末尾 this.dataArr.splice(index, 1); this.dataArr.push(obj); } else { // 如果不存在数据,则数组直接push this.dataArr.push(obj); } // 对象新增或者修改同一个对象 this.dataObj[key] = obj; // 判断新增数据后,是否超过最大长度 if (this.dataArr.length > this.length) { // 数组是有序的,超长后直接从头部删除,并且获取删除的数据 const tmp = this.dataArr.shift(); // 数据对象里面也应该删除当前删除的对象,避免被再次取到 delete this.dataObj[tmp.key]; } } }
我们使用同样的测试案例测试一下:
const lruCache = new LRUCacheObjArr(2); lruCache.set('1', 1); // dataArr(1) [obj1], dataObj(1) {'1': obj1} lruCache.set('2',2); // dataArr(2) [obj1, obj2], dataObj(2) {'1': obj1, '2': obj2} console.log(lruCache.get('1')); // dataArr(2) [obj2, obj1], dataObj(2) {'1': obj1, '2': obj2} lruCache.set('3',3); // dataArr(2) [obj1, obj3], dataObj(2) {'1': obj1, '3': obj3} console.log(lruCache.get('2')); // null lruCache.set('4',4); // dataArr(2) [obj3, obj4], dataObj(2) {'3': obj3, '4': obj4} console.log(lruCache.get('1')); // null console.log(lruCache.get('3')); // dataArr(2) [obj4, obj3], dataObj(2) {'3': obj3, '4': obj4} console.log(lruCache.get('4')); // dataArr(2) [obj3, obj4], dataObj(2) {'3': obj3, '4': obj4}
运行结果:
使用对象+数组的方式,虽然可以实现效果,但是由于会频繁操作数组,因此会牺牲一些性能,而且实现起来也没有Map
方便。除了使用数组+对象,其实我们还可以使用双向链表的方式实现LRU。
使用双向链表实现LRU
双向链表,顾名思义就是两个方向的链表,这有别于单向链表。直接看图可能更直观一点:
双向链表在移动元素时,会比单向链表复杂一点,,例如我们想把B和C节点交换一下位置,其过程如下:
这对应到LRU有什么意义呢?双向链表是有序的,每一个节点都知道其上一个或者下一个节点;其存值的方式也是使用键值对的方式,因此完全可以实现LRU。
实现代码:
class LRUCacheLinked { data = {}; // 链表数据 dataLength = 0; // 链表长度,使用变量保存,可以更快访问 listHead = null; // 链表头部 listTail = null; // 链表尾部 length = 0; // 链表最大长度 // 构造函数 constructor(length) { if (length < 1) throw new Error('参数不合法') this.length = length; } set(key, value) { const curNode = this.data[key]; if (curNode == null) { // 新增数据 const nodeNew = {key, value}; // 移动到末尾 this.moveToTail(nodeNew); // 将新增的节点保存到数据对象中,其pre或next将在moveToTail中设置 this.data[key] = nodeNew; // 链表长度递增 this.dataLength++; // 初始化链表头部 if (this.dataLength === 1) this.listHead = nodeNew; } else { // 修改现有数据 curNode.value = value; // 移动到末尾 this.moveToTail(curNode); } // 添加完数据后可能会导致超出长度,因此尝试着删除数据 this.tryClean(); } get(key) { // 根据key值取出对应的节点 const curNode = this.data[key]; if (curNode == null) return null; if (this.listTail === curNode) { // 如果被访问的元素处于链表末尾,则直接返回值,且不用修改链表 return curNode.value; } // 如果是中间元素或者头部元素,则在获取前需要将其移动到链表尾部,表示最新 this.moveToTail(curNode); return curNode.value; } // 移动节点到链表尾部 moveToTail(curNode) { // 获取链表尾部 const tail = this.listTail; // 如果当前节点就是链表尾部,则不做处理,直接返回 if (tail === curNode) return; // 1. preNode和nextNode断绝与curNode之间的关系 const preNode = curNode.pre; const nextNode = curNode.next; if (preNode) { if (nextNode) { // 当前元素的上一个节点指向其下一个 preNode.next = nextNode; } else { // 断开当前元素与上一个节点的联系 delete preNode.next; } } if (nextNode) { if (preNode) { // 当前元素的下一个节点指向其上一个 nextNode.pre = preNode; } else { // 断开当前元素与下一个节点的关系 delete nextNode.pre; } // 如果当前节点是链表头部,则需要重新赋值 if (this.listHead === curNode) this.listHead = nextNode; } // 2. curNode断绝与preNode和nextNode之间的关系 delete curNode.pre delete curNode.next // 3. 在list末尾,重新建立curNode的新关系 if (tail) { tail.next = curNode; curNode.pre = tail; } // 重新赋值链表尾部,保持最新 this.listTail = curNode; } tryClean() { while(this.dataLength > this.length) { const head = this.listHead; if (head == null) throw new Error('链表缺少头部'); const headNext = head.next; if (headNext == null) throw new Error('链表头部缺失下一个节点'); // 1. 断绝head和headNext之间的关系 delete head.next; delete headNext.pre; // 2. 重新赋值listHead this.listHead = headNext; // 3. 清理data delete this.data[head.key]; // 4. 重新计数 this.dataLength = this.dataLength - 1; } } }
这么一看,双向链表是这三种方式中最复杂的一个。我们使用同样的案例测试一下:
const lruCache = new LRUCacheLinked(2); lruCache.set('1', 1); lruCache.set('2',2); console.log(lruCache.get('1')); lruCache.set('3',3); console.log(lruCache.get('2')); lruCache.set('4',4); console.log(lruCache.get('1')); console.log(lruCache.get('3')); console.log(lruCache.get('4'));
实现结果:
总结
本文总结了三种实现LRU缓存的方法,其中使用Map
是最佳的方式。使用其他两种方式,虽然可以实现效果,但是从效率、可读性上来看,还是Map
更胜一筹。这三种方式你都学会了吗?
加载全部内容