常见前端面试题 - LRU

1,287 阅读4分钟

LRU

写在前面

近期在面试过程中,常会问到LRU这一算法。然而往往得到的答案并不如意,特此总结。

LRU(Least Recently Used) 是一种常见的 缓存淘汰算法,其基本思想是,如果数据最近被访问过,那么将来被访问的几率也比较高。可见,LRU有三个特点:

  1. 缓存空间有限
  2. 根据访问时间来决策是否从缓存中剔除某项
  3. 缓存中的每一项需根据时间进行排序

在日常开发中较为常用。举个例子,vue内置的 keep-alive 组件就是 LRU 的实际应用。

开始实现

分析

既然是缓存,那么主要实现的无非是三个内容(对于前端的简单应用来说):

  1. 缓存的数据结构:需要保证有序
  2. 缓存的 getter
  3. 缓存的 setter(add/put)

数据结构

什么样的数据结构,是有顺序的呢?第一个蹦出来的大概会是 array

但是仅仅用array就行了吗?再分析分析:

对于JavaScript来说,array可以保证顺序,确实是我们需要的。然而对于一个缓存算法来说,取值、更新值的效率也很重要。当根据数组下标去获取 arrayItem 的时候,时间复杂度为 O(1),但实际情况往往是根据某个 idkey 去取值,此时array的时间复杂度即为 O(n)。

所以,要么需要更换一个数据结构,要么需要设置一些辅助变量,来帮助array提高 getter 的效率。

这里最容易想的,是同时用 arrayobject 来存储数据。array保证顺序,object保证 getter 效率。

当然还有更简单的方式:利用 es6 的 Map 结构。

The Map object holds key-value pairs and remembers the original insertion order of the keys.

想一下具体的逻辑步骤

  1. 初始化一个 Map,越新访问的值,越靠近 map 的尾部
  2. 在 getter 中,根据传入的key,查找是否存在对应的数据;如果有,则将其移动到 map 的尾部;如果没有,则返回 undefined
  3. 在 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];
  }
}