环境 Deno2.6.8 TypeScript

概述

LRU 缓存淘汰算法就是一种常用的策略,全称为 Least Recently Used,也就是我们认为最近使用过的数据应该是 “有用的”,很久没有用过的数据应该就是无用的,内存满了就应该先删除那些很久没用过的数据

常用于有限容量的缓存优化

LRU算法设计

cache 这个数据结构必备的条件如下:

  1. 显然 cache 中的元素必须有序,从而可以区分最近使用和久未使用的数据,当容量满了之后要删除最久未使用的那个元素来腾出位置。

  2. 要在 cache 中快速找到某个 key 是否存在并得到对应的 val

  3. 每次访问 cache 中的某个 key,需要将这个元素变为最近使用的,也就是说 cache 要支持在任意位置的快速插入和删除元素。

因此采用哈希链表数据结构实现该算法:

结合了哈希表查找快速和双向链表插入删除高效

  1. 如果每次默认从链表尾部添加元素,那么显然越靠近尾部的元素就是最近使用的,越靠近头部的元素就是越久未使用的。

  2. 对于某一个 key,可以通过哈希表快速定位到链表中的节点,从而取得对应的 val

  3. 链表显然是支持在任意位置快速插入和删除的,改改指针就可以了。只不过传统的链表无法按照索引快速访问某一个位置的元素,而这里借助哈希表,可以通过 key 快速映射到任意一个链表节点,然后进行插入和删除

双向链表节点

/**
 * LRU存储节点
 */
export class Node<K, V> {
  /**
   * LRU 存储节点构造函数
   * @param key 节点键
   * @param value 节点值
   * @param prev
   * @param next
   */
  constructor(
    public key?: K,
    public value?: V,
    public prev: Node<K, V> | null = null,
    public next: Node<K, V> | null = null
  ) {}
}

LRU Cache实现类

export class LRUCache<K, V> {
  /**
   * LRU容量
   */
  private capacity: number = 0;
  /**
   * 哈希LRU节点哈希表
   */
  private map: Map<K, Node<K, V>>;

  /**
   * 虚拟节点: next
   */
  private head: Node<K, V>;

  /**
   * 虚拟尾节点: prev
   */
  private tail: Node<K, V>;

  constructor(capacity: number) {
    if (!Number.isInteger(capacity) || capacity <= 0) {
      throw new Error('缓存容量必须是一个正整数');
    }
    this.capacity = capacity;
    this.map = new Map<K, Node<K, V>>();

    // 初始化虚拟头尾节点
    this.head = new Node<K, V>();
    this.tail = new Node<K, V>();

    // LRUCache为空时头尾虚拟节点合并
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }

  // 工具方法

  /**
   * 在末尾添加一个节点
   * @param node
   */
  private addToTail(node: Node<K, V>) {
    node.prev = this.tail.prev; // 新节点指向末尾节点
    this.tail.prev!.next = node; // 末尾节点指向新节点
    this.tail.prev = node; // 虚拟节点指向到新节点
    node.next = this.tail;
  }

  /**
   * 删除某个节点
   * @param node
   */
  private removeNode(node: Node<K, V>) {
    const prevNode = node.prev; // 前驱节点
    const nextNode = node.next; // 后继节点

    // 避开目标节点
    prevNode!.next = nextNode;
    nextNode!.prev = prevNode;

    // 目标节点脱离链表
    node.prev = null;
    node.next = null;
  }

  /**
   * 将节点移动到末尾
   * @param node
   */
  private moveToTail(node: Node<K, V>) {
    this.removeNode(node);
    this.addToTail(node);
  }

  /**
   * 取出LRU缓存值,返回 -1表示不存在
   * @param key
   * @returns
   */
  public get(key: K): V | undefined {
    if (!this.map.has(key)) return undefined;
    const node = this.map.get(key);

    this.moveToTail(node!);
    return node!.value as V;
  }

  public put(key: K, value: V) {
    if (this.map.has(key)) {
      // 从哈希表获取并更新节点
      const node = this.map.get(key);
      node!.value = value;
      this.moveToTail(node!);
    } else {
      // 新增节点
      if (this.capacity === this.map.size) {
        const firstNode = this.head.next!;

        // 先删除头节点(已过期)
        this.removeNode(firstNode); // 链表
        this.map.delete(firstNode.key!); // 哈希表
      }
      const newNode = new Node<K, V>(key, value);
      this.addToTail(newNode);
      this.map.set(key, newNode);
    }
  }
}

测试

import { LRUCache } from './LRU.ts';

Deno.test('测试LRU算法', () => {
  const cache = new LRUCache<number, number>(2); // 缓存容量为2

  cache.put(1, 1);
  // cache = [(1, 1)]

  cache.put(2, 2);
  // cache = [(2, 2), (1, 1)]

  console.log(cache.get(1)); // 返回 1
  // cache = [(1, 1), (2, 2)]
  // 因为最近访问了键 1,所以提至队头
  // 返回键 1 对应的值 1

  cache.put(3, 3);
  // cache = [(3, 3), (1, 1)]
  // 缓存容量已满,需要删除内容空出位置
  // 优先删除久未使用的数据,也就是队尾的数据
  // 然后把新的数据插入到队头

  console.log(cache.get(2)); // 返回-1(未找到)
  // cache = [(3, 3), (1, 1)]
  // cache 中已经不存在键为 2 的数据

  cache.put(1, 4);
  // cache = [(1, 4), (3, 3)]
  // 键1已经存在,更新对应的值,并将其提前到队头
  console.log(cache.get(1)); // 返回 4
});

LRU缓存 + TTL

/**
 * @param {number} capacity
 */
const LRUCache = function (capacity) {
  this.head = new ListNode();
  this.tail = new ListNode();
  this.cap = capacity;
  this.hashMap = new Map();

  // 形成闭环,防止边界冗余操作
  this.head.next = this.tail;
  this.tail.pre = this.head;
};
/**
 *
 * @param {ListNode} node
 */
LRUCache.prototype.moveToEnd = function (node) {
  this.removeNodeFromLinkList(node);
  this.addToEnd(node);
};

/**
 *
 * @param {ListNode} node
 */
LRUCache.prototype.removeNodeFromLinkList = function (node) {
  // 因为LRU缓存链表是闭环的,两个节点必然存在
  const preNode = node.pre;
  const nextNode = node.next;
  // 引用绕过 node, 不做除了LinkList以外操作
  preNode.next = nextNode;
  nextNode.pre = preNode;
};

/**
 *
 * @param {ListNode} node
 */
LRUCache.prototype.addToEnd = function (node) {
  // 尾节点指向 node
  this.tail.pre.next = node;
  node.pre = this.tail.pre;
  // 更新尾虚拟节点指向
  this.tail.pre = node;
  node.next = this.tail;
};

/**
 * @param {number} key
 * @return {number}
 */
LRUCache.prototype.get = function (key) {
  const node = this.hashMap.get(key);
  if (!node) {
    return -1;
  }
  // 判断是否过期
  if (node.expire <= Date.now()) {
    this.removeNodeFromLinkList(node);
    this.hashMap.delete(node.key);
    return -1;
  }

  this.moveToEnd(node);
  return node.val;
};

/**
 * @param {number} key
 * @param {number} value
 * @param {number} TTL
 * @return {void}
 */
LRUCache.prototype.put = function (key, value, TTL) {
  const node = this.hashMap.get(key);
  if (node) {
    node.val = value;
    // 更新时间戳
    node.expire = Date.now() + TTL;
    this.moveToEnd(node);
  } else {
    const curr = new ListNode(key, value, Date.now() + TTL);

    // 缓存已满,先删除旧数据
    if (this.cap === this.hashMap.size) {
      // ❌:先把头节点从链表删除了,再删除哈希表中原头节点的下一个节点删除,导致映射依赖混乱
      //   this.removeNodeFromLinkList(this.head.next);
      //   this.hashMap.delete(this.head.next.key);
      const oldNode = this.head.next;
      this.removeNodeFromLinkList(oldNode);
      this.hashMap.delete(oldNode.key);
    }
    this.addToEnd(curr);
    this.hashMap.set(key, curr);
  }
};

/**
 * 遍历所有节点,删除过期节点
 */
LRUCache.prototype.clean = function () {
  let curr = this.head.next;
  while (curr !== this.tail) {
    const next = curr.next; // 暂存,防止引用失效
    if (curr.expire <= Date.now()) {
      this.removeNodeFromLinkList(curr);
      this.hashMap.delete(curr.key);
    }
    curr = next;
  }
};

/**
 * @param {number | undefined} key
 * @param {number | undefined} val
 * @param {ListNode | null} pre
 * @param {ListNode | null} next
 */
function ListNode(key, val, expire, pre = null, next = null) {
  this.key = key;
  this.val = val;
  this.pre = pre;
  this.next = next;
  this.expire = expire;
}

/**
 * 注意事项:边界处理、虚拟节点、辅助函数解耦、权责分明
 * Your LRUCache object will be instantiated and called as such:
 * var obj = new LRUCache(capacity)
 * var param_1 = obj.get(key)
 * obj.put(key,value)
 */