手写 LRU cache

593 阅读4分钟

这个算法非常经典,手动记录一下。

LRU是Least Recently Used的缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。 该算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间t,当须淘汰一个页面时,选择现有页面中其t 值最大的,即最近最少使用的页面予以淘汰。---wiki

实现 LRUCache 类:

LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存

int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。

void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该 逐出 最久未使用的关键字。

函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。---leetcode 146

经典双向链表

class LRUCache {
  constructor(capacity){
    this.capacity = capacity
    this.cacheCount = 0
    
    
    // 【先】用对象来使 get 操作时间复杂度为O(1)
    
    this.cache = {}
    
    
    // *关键点
    // 这里使用两个对象做指针 头指针永远指向第一个节点 尾指针指向最后一个节点
    // 如果有新加入的节点则从头指针入手改变引用关系
    // 如果超过缓存空间则从尾巴指针获取最后一个节点从cache中删除并且改变引用关系
    // 即用双向链接结构来解决普通对象无序的问题
    
    
    this.dummy = {
      prev: null,
      val: undefined,
      next: null
    }
    
    this.tail = {
      prev: this.dummy,
      val: undefined,
      next:null
    }
    this.dummy.next = this.tail  // 初始化时cache中没有任何数据因此这两个指针互相指向对方
  }
  
  get(key) {
    const node = this.cache[key]
    if(!node) return -1
    this.appendToHead(key) // 当对某一个节点进行get操作时将它移动到链的头部
    return node.val
  }
  
  put(key,value) {
    
    // 判断当前put的key是否已经在缓存中 如果是则直接更新并将它移动到链表头部
    
    if(this.cache[key]) {
      this.cache[key].val = value
      this.appendToHead(key)
      return
    }
    // 判断容量是否已满如果已经满了那就先把最后一个节点删掉
    if(this.cacheCount === this.capacity) this.deleteLast()
    
    // 获取原本最靠前的node
    
    const initialHead = this.dummy.next
    const newNode = {
      val: value,
      next: initialHead,
      prev: this.dummy,
      key   // node中记录key来方便我们在cache中获取
    }
    this.dummy.next = newNode
    initialHead.prev = newNode
    this.cache[key] = newNode
    this.cacheCount++
  }
  
  deleteLast() {
    const node = this.tail.prev // tail指针永远指向最后一个node
    const initialPrev = node.prev // 获取将要删除的节点的上一个节点
    this.tail.prev = initialPrev
    initialPrev.next = this.tail
    delete this.cache[node.key] // 根据key从cache中清除缓存节点
    this.cacheCount--
    
  }
  
  appendToHead(key) {
    const node = this.cache[key]
    // 如果前一个节点是dummy节点说明已经是第一个节点了 就不用动了
    // 这在处理只有一个节点的时候比较有用
    if(node.prev.val === undefined) return
    // 在dummy指针和原节点之间插入这个node
    // 这里需要注意的一点是除了维护新的关系,原节点自身上下节点的关系也需要维护
    
    let initialNext = this.dummy.next // 记录原本的头节点 
    this.dummy.next = node // 改写头节点的指向

    const nodePrev = node.prev // 记录该节点原本的前一个节点
    const nodeNext = node.next // 记录该节点原本的下一个节点


    // 维护该节点和dummy以及原本dummy指向的节点之间的关系
    node.prev = this.dummy

    initialNext.prev = node

    node.next = initialNext


    // 维护该节点原始前一个节点和后一个节点的关系
    nodeNext.prev = nodePrev

    nodePrev.next = nodeNext

  }
}

使用Map数据结构

在上面部分我们留下了一个伏笔:

image.png

是不是只有有这么一种数据结构既可以做到 普通对象O(1)的读写也可以记录更新顺序呢?

答案是有的,那就是ES6Map 【MDN】

其主要原理就是依靠了 Map数据结构能够记录键值对被添加的顺序

class LRUCache {
  constructor(props) {
    const { capacity = 1 }  = props
    this.capacity = capacity
    this.cache = new Map()
  }
  get(key) {
    let val = this.cache.get(key)
    if(val === undefined) return -1
    this.cache.delete(key)
    this.cache.set(key, val) // 重新set 就能使对应的key的顺序更新
    return val
  }
  put(key,val) {
    if(this.cache.has(key)) this.cache.delete(key)
    this.cache.set(key,value)
    // 通过map对象的size静态属性可以获取当前map的容量
    if(this.cache.size > this.capacity){
      // map.keys方法返回一个可迭代的对象
      let keyIterator = this.cache.keys(); 
      // 获取最后一个键名
      this.cache.delete(keyIterator.next().value);
    }
  }
}

相信通过这个示例可以加深不少对于 Map的理解