前端也来点算法(TS版) | 1 - LRU Cache

2,410 阅读7分钟

这是 前端也来点算法 系列的第一篇文章,项目中的代码打算全部用 TS 编写。

这篇文章会涉及到的技术有:

  • 双向链表
  • 哈希表(JS 中叫对象)

What:

缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的 CPU 缓存、数据库缓存、浏览器缓存等等。

缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。

最近最少,越是最近使用就越是不会被清除,而最远使用的将会逐渐被推到丢弃端,如果一直不被使用,数据不断存入时将会丢弃它们。

Question:

运用你所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制。它应该支持以下操作: 获取数据 get 和 写入数据 put 。 获取数据 get(key) - 如果密钥 (key) 存在于缓存中,则获取密钥的值(总是正数),否则返回 -1。 写入数据 put(key, value) - 如果密钥不存在,则写入其数据值。当缓存容量达到上限时,它应该在写入新数据之前删除最近最少使用的数据值,从而为新的数据值留出空间。

着重使用双向链表实现

How:

  • LRU 存在容量限制(capacity)
  • get(k) 取值 (get)
    • 获取不到返回 -1
    • 获取到则返回,同时把该 (k, v) 对放到最近使用端(removeNodeaddNode)
  • put(k, v) 存值(put)
    • 如果存在该 k,则删除再在最近使用端添加 (k, v),如果不存在则在最近使用端添加 (k, v)
    • 如果添加之后总长度大于容量,则删除最远使用的 (k, v)

流程图

初始情况

双向链表包含节点,节点如下

class LinkedListNode {
  key: number
  value: number
  prev: LinkedListNode | null
  next: LinkedListNode | null
  constructor(key: number = 0, value: number = 0) {
    this.key = key
    this.value = value
    this.prev = null // 当前节点指向的前一个节点
    this.next = null // 当前节点指向的下一个节点
  }
}

最开始只有头结点和尾结点,头结点 next 直接指向尾结点,尾结点 prev 指向头结点。

后面的 getput 操作都是基于节点的加入,节点的删除实现。假设链表容量(capacity) 为4,往 LRU 中依次 put (4, 4)、(3, 3)、(2, 2)、(1, 1),链表就会变成这样。

get(4),key 为4的节点就自动往头部靠近,链表重新建立指向

put(2, 22),key 为 2 的节点先被链表删除,然后重新赋值 value 为 22,再添加到头部

put 流程

put 的时候先要直接删除节点(removeNode),然后再把该节点添加到 head 后面(addNode)。双向链表的好处是只要传入了节点就能自动找到节点的前驱和后继节点,并且给指针重新赋值。

删除一个节点

  private removeNode(node: LinkedListNode) {
    // 把当前节点的 prev、next 都存起来
    const prev = node.prev as LinkedListNode
    const next = node.next as LinkedListNode

    // 将前一个节点指向 node 的后一个节点
    node.prev!.next = next
    // 把后一个节点的 prev 指向 node 的前一个节点
    next!.prev = prev
  }

新增一个节点

  private addNode(node: LinkedListNode) {
    // 先把 node 的 prev 、next 建立好
    node.prev = this.head
    node.next = this.head.next

    //把原来的第一个节点的前一个节点指向 node (这里要在 head.next 更改指向之前先让原来的第一个节点指向 node)
    this.head.next!.prev = node
    // 最后把 head 的 next 重新指向 node
    this.head.next = node
  }

如果已经达到了链表的容量,则需要删除尾部的节点

  private popTail(): LinkedListNode {
    const res = this.tail.prev as LinkedListNode
    // 删除节点
    this.removeNode(res)
    return res
  }

put 代码

  put(k: number, v: number) {
    const node = this.cache[k]
    if (!node) {
      // 没找到节点,则要新插入一个节点
      const newNode = new LinkedListNode(k, v)
      this.cache[k] = newNode
      this.addNode(newNode)
      this.size += 1
      // 判断 size 是否查过了 capacity ,超过了则删除尾部节点
      if (this.size > this.capacity) {
        const popNode = this.popTail()
        delete this.cache[popNode.key]
        this.size -= 1
      }
    } else {
      // 找到了,则在链表里面先删除这个节点再把这个节点添加到 head
      this.removeNode(node)
      node.value = v
      this.addNode(node)
    }
  }

get 流程

get 时,链表里面没有值,则返回 -1,有值则返回节点的 value 同时将节点移动到头部。

移动到头部包含 put 里面讲到的两格流程,先删除再添加。

  private moveToHead(node: LinkedListNode) {
    this.removeNode(node)
    this.addNode(node)
  }

get 的代码

  get(k: number): number {
    const node = this.cache[k]
    if (node) {
      // 如果有这个节点,则把这个节点移动到 head
      this.moveToHead(node)
      return node.value
    }
    return -1
  }

双向链表实现

使用 双向链表 + 对象 实现,双向链表存在链表头和链表尾结点,方便增加删除节点


// 列表节点
class LinkedListNode {
  key: number
  value: number
  prev: LinkedListNode | null
  next: LinkedListNode | null
  constructor(key: number = 0, value: number = 0) {
    this.key = key
    this.value = value
    this.prev = null // 当前节点指向的前一个节点
    this.next = null // 当前节点指向的下一个节点
  }
}

type NumObj = {
  [k: number]: LinkedListNode
}

class LRUCache {
  // 头部节点
  head: LinkedListNode
  // 尾部节点
  tail: LinkedListNode
  // 可以理解为缓存存放的地方
  cache: NumObj
  // LRU 的容量
  capacity: number
  // LRU 当前的大小
  size: number

  constructor(capacity: number) {
    this.cache = {}
    this.capacity = capacity
    this.size = 0
    this.head = new LinkedListNode() // 初始化头结点
    this.tail = new LinkedListNode() // 初始化尾结点

    // 初始情况下只有头结点和尾结点
    this.head.next = this.tail // 头结点的 next 指向尾结点
    this.tail.prev = this.head // 尾结点的 prev 指向头结点
  }

  // 把节点易懂到双向链表的头部(最近使用)
  private moveToHead(node: LinkedListNode) {
    this.removeNode(node)
    this.addNode(node)
  }

  /**
   * 添加一个节点,默认添加到头结点后面,表示最近使用
   * @param node {LinkedListNode}
   */
  private addNode(node: LinkedListNode) {
    // 先把 node 的 prev 、next 建立好
    node.prev = this.head
    node.next = this.head.next

    //把原来的第一个节点的前一个节点指向 node (这里要在 head.next 更改指向之前先让原来的第一个节点指向 node)
    this.head.next!.prev = node
    // 最后把 head 的 next 重新指向 node
    this.head.next = node
  }

  /**
   * 删除一个节点
   * @param node {LinkedListNode}
   */
  private removeNode(node: LinkedListNode) {
    // 把当前节点的 prev、next 都存起来
    const prev = node.prev as LinkedListNode
    const next = node.next as LinkedListNode

    // 将前一个节点指向 node 的后一个节点
    node.prev!.next = next
    // 把后一个节点的 prev 指向 node 的前一个节点
    next!.prev = prev
  }

  /**
   * 删除尾部节点
   */
  private popTail(): LinkedListNode {
    const res = this.tail.prev as LinkedListNode
    // 删除节点
    this.removeNode(res)
    return res
  }

  /**
   * 获取一个值
   * @param k {number}
   */
  get(k: number): number {
    const node = this.cache[k]
    if (node) {
      // 如果有这个节点,则把这个节点移动到 head
      this.moveToHead(node)
      return node.value
    }
    return -1
  }

  // 插入一个值
  put(k: number, v: number) {
    const node = this.cache[k]
    if (!node) {
      // 没找到节点,则要新插入一个节点
      const newNode = new LinkedListNode(k, v)
      this.cache[k] = newNode
      this.addNode(newNode)
      this.size += 1
      // 判断 size 是否查过了 capacity ,超过了则删除尾部节点
      if (this.size > this.capacity) {
        const popNode = this.popTail()
        delete this.cache[popNode.key]
        this.size -= 1
      }
    } else {
      // 找到了,则在链表里面先删除这个节点再把这个节点添加到 head
      this.removeNode(node)
      node.value = v
      this.addNode(node)
    }
  }
}

get 从哈希表存取值,时间复杂度为 O(1),put 时间复杂度也是 O(1)。

运行测试

测试通过

编译出来的 JS 执行效率

Map 实现

Map 是哈希表,同时Map存进去的数据自动会按照先后次序自动排序。

class LRUCache {
  cache: Map<number, number>
  constructor(public capacity: number) {
    this.cache = new Map()
  }
  // 用 key 获取一个值
  get(key: number): number {
    const v = this.cache.get(key)
    if (v === undefined) {
      return -1
    } else {
      this.moveToEnd(key, v)
      return v
    }
  }

  moveToEnd(k: number, v: number) {
    this.cache.delete(k)
    this.cache.set(k, v)
  }

  // 插入一个值
  put(key: number, value: number) {
    if (this.cache.has(key)) {
      this.cache.delete(key)
    } else if (this.cache.size >= this.capacity) {
      this.cache.delete(this.cache.keys().next().value)
    }
    this.cache.set(key, value)
  }
}

编译成 JS 代码之后的执行效率

get 直接从哈希表取值,时间复杂度是 O(1),put 是直接存值到哈希表,时间复杂度也是 O(1)。

文章代码链接:


欢迎关注我的公众号 云影sky

定期深度解析 React 源码