实现LRU缓存的三种方式(建议收藏)

2,683 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第28天,点击查看活动详情

LRU全称为Least Recently Used,即最近使用的。针对的是在有限的内存空间内,只缓存最近使用的数据(即get和set的数据),超过有限内存空间的数据将会被删除。这个在面试题中也是常会被问到的内容,接下来就看看怎么来实现。

分析

从定义来看,LRU至少有两个特性:通过键值对读写、有序。实现键值对读写,一般我们会使用哈希表来表示,注意哈希表是一个逻辑结构,实际上我们需要使用objectmap等来实现;数据有序,即最近使用的数据放在前面,已经过时的数据放在后面或被删除,并且支持数据是可排序的,我们可以想到数组、链表、map之类的数据格式。因此,我们有三种方式可以实现LRU缓存:

  • Map
  • Object + Array
  • DoublyLinkedList

使用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,下面是原理图:

map-get-set.png

实现代码:

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}

运行结果:

image-20220627214821092.png

使用Map基本上就可以实现LRU缓存,并且其兼容性还可以,除了IE兼容性差点:

image-20220627215200825.png

如果不使用Map,那么还可以使用什么方式实现LRU缓存呢?

使用Object + Array实现LRU缓存

刚刚我们也分析了,LRU需要满足两点:键值对和可排序,这两点可以分别对应到ObjectArray,那么我们是不是就可以以此为思路实现呢?答案是可以的,直接看一下实现代码:

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}

运行结果:

image-20220627214821092.png

使用对象+数组的方式,虽然可以实现效果,但是由于会频繁操作数组,因此会牺牲一些性能,而且实现起来也没有Map方便。除了使用数组+对象,其实我们还可以使用双向链表的方式实现LRU。

使用双向链表DoublyLinkedList实现LRU

双向链表,顾名思义就是两个方向的链表,这有别于单向链表。直接看图可能更直观一点:

双向链表结构图.png

单向链表只有next,没有pre。双向链表在移动元素时,会比单向链表复杂一点,例如我们想把B和C节点交换一下位置,其过程如下:

双向链表移动元素图.png

这对应到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'));

实现结果:

image-20220627214821092.png

可以发现,使用双向链表实现也是可以的。双向链表使用起来可能不是那么好理解,画两个图方便大家理解:

双向链表移动元素详细过程

双向链表移动元素详细过程.png

双向链表删除元素详细过程

双向链表结构图.png

结合这两个图,再来看上面的代码,是不是会更清晰一点呢。

总结

本文总结了三种实现LRU缓存的方法:Map、数组+对象、双向链表,其中使用Map是最佳的方式。使用其他两种方式,虽然可以实现效果,但是从效率、可读性上来看,还是Map更胜一筹。那么这三种方式你都学会了吗?