前端刷题路-Day31:LRU 缓存机制(题号146)

354 阅读6分钟

LRU 缓存机制(题号146)

题目

运用你所掌握的数据结构,设计和实现一个LRU (最近最少使用) 缓存机制 。 实现LRUCache类:

  • LRUCache(int capacity) 以正整数作为容量capacity初始化 LRU 缓存
  • int get(int key)如果关键字key存在于缓存中,则返回关键字的值,否则返回 -1 。
  • void put(int key, int value)如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字-值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。

进阶:你是否可以在O(1)时间复杂度内完成这两种操作?

示例:

输入
["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]
输出
[null, null, null, 1, null, -1, null, -1, 3, 4]

解释
LRUCache lRUCache = new LRUCache(2);
lRUCache.put(1, 1); // 缓存是 {1=1}
lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
lRUCache.get(1);    // 返回 1
lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3}
lRUCache.get(2);    // 返回 -1 (未找到)
lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
lRUCache.get(1);    // 返回 -1 (未找到)
lRUCache.get(3);    // 返回 3
lRUCache.get(4);    // 返回 4

提示:

  • 1 <= capacity <= 3000
  • 0 <= key <= 3000
  • 0 <= value <= 104
  • 最多调用3 * 104getput

链接

leetcode-cn.com/problems/lr…

解释

这题啊,这题是没想到。

和之前的N题一样,笔者走进了逻辑的死胡同。

这题重要的就是维护LRUCache的顺序,而且需要注意题目中的最久未使用数据。判断这个需要先了解什么叫使用数据。

这里的使用指的并不只是读取了某个数据,更改也算,新建也算。

那么这就以为这我们在进行查找、修改、新建的时候,都需要把当前数据放到队列的最前面,如果需要删除某条数据,从队列的末尾开始删除即可。

也就是维护一个先出的队列,同时每次进行操作的时候都需要更新队列的数据。

再写这题的时候,笔者总想着利用数组和对象一起操作,这样的想法没错,重点是顺序弄错了。

为了维护一个共同的数据,笔者采用权重分配的方法去对数组进行排序,问题也出现在了权重上,权重的分配总是不均匀的,所以排序总有问题,后来看了别的解答后才发现,原来根本就不需要权重,只需要自己手动维护一个数组即可,也不需要进行排序,每次只需要进行删除和添加到头部的操作即可。

下面的答案都是笔者看完打完后自己手动写出来的,可能会与原答案有些冲突。

自己的答案

更好的方法(对象+数组)

上面说了,数组加对象完全可以解决这个问题。数组用来维护顺序,对象用来存储值。

只是这里发现一个奇怪的现象,使用ES5语法时,最后一个用例会超时失败,但换上ES6的class就成功了,虽然性能时最后的5%。

先看看ES5版本的代码👇:

var LRUCache = function(capacity) {
  this.capacity = capacity
  this.obj = {}
  this.arr = []
};

LRUCache.prototype.get = function(key) {
  if (this.arr.findIndex(item => item === key) > -1) {
    this.arr = this.arr.filter(item => item !== key)
    this.arr.push(key)
    return this.obj[key]
  } else {
    return -1
  }
};

LRUCache.prototype.put = function(key, value) {
  if (this.arr.findIndex(item => item === key) > -1) {
    this.arr = this.arr.filter(item => item !== key)
    this.arr.push(key)
    this.obj[key] = value
  } else {
    this.obj[key] = value
    this.arr.push(key)
    if (this.arr.length > this.capacity) this.arr.shift()
  }
};

代码还是比较简单的,初始化时创建一个数组、一个对象,还有就是需要记住数组的最大长度,也就是capacity

get方法的内容也是很简单,首先看看数组中有没有当前元素,如果有就先把它干掉,然后插入到数组末尾中去,最后利用obj返回对应的值即可;如果没有当前元素,直接返回-1

put方法稍微有点复杂,也是区分当前key存在和不存在两种情况:

  • 当前key存在

    也是老规矩,先改变这个key在数组中的位置,把它放到最后。之后改变obj中的值即可

  • 当前key不存在

    不存在的话就是添加了,直接在数组末尾添加key即可,同时也需要修改obj中的值。

    添加完成后需要判断下数组的长度是否超出限制,如果数组长度超出限制还需要去掉数组第一个元素,直接使用shift方法即可。

整体逻辑还是比较简单的,就是性能太差导致最后一个用例没过,现在看看ES6的写法👇:

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity
    this.arr = new Array(capacity)
    this.obj = {}
  }
  _hasKey(key) {
    return this.arr.findIndex(item => item === key) > -1
  }
  _deleteKey(key) {
    this.arr = this.arr.filter(item => item !== key)
  }
  get(key) {
    if (this._hasKey(key)) {
      this._deleteKey(key)
      this.arr.push(key)
      return this.obj[key]
    } else {
      return - 1
    }
  }
  put(key, value) {
    if (this._hasKey(key)) {
      this._deleteKey(key)
      this.arr.push(key)
      this.obj[key] = value
    } else {
      this.arr.push(key)
      this.obj[key] = value
      if (this.arr.length > this.capacity) this.arr.shift()
    }
  }
}

整体逻辑还是和ES5的差不多,区别就是提取了一些公用的方法,比方说_hasKey_deleteKey,减少了一部分冗余的代码。

改动虽然不大,但也是通过了用例,虽然性能依旧是最后的5%。

更好的方法(Map)

为什么说Map是更好的方法呢?因为如果使用Map就不需要使用数组了。因为对象的key是无法自定义顺序的,所以需要数组来辅助保证顺序。到了Map这就不需要了,因为Mapkey是有序的,就是我们插入的顺序。

既然顺序得到了保证,那么只需要对Map进行相关的增删查操作即可,而且Map本身自带的setdeletehasget操作可以在很大程度上减少代码量,相比对象+数组也是更加方便。

话不多说,直接看代码👇:

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity
    this.map = new Map()
  }
  get(key) {
    const value = this.map.get(key)
    if (value === undefined) return -1
    this.map.delete(key)
    this.map.set(key, value)
    return value
  }
  put(key, value) {
    if (this.map.has(key)) this.map.delete(key)
    this.map.set(key, value)
    const keys = this.map.keys()
    if (this.map.size > this.capacity) this.map.delete(keys.next().value)
  }
}

可以看出来代码量可能只有之前的一半左右,但是功能可一个都不少。

逻辑什么的都差不多,这里不多做缀饰,只是需要注意这里的一个小坑。

get方法,该方法利用了Mapget方法,get本身是没有问题的,如果有key就返回值,没有就返回undefined。非常合理,主要是这里传入的值有问题,如果这个value0呢?

直接使用:

if (this.map.has(key)) {
	...
}

是不行的,因为0会被判断为false,笔者当时还困扰了好久,后来仔细思考之后才想出了原因。

所以这里的判断条件只能是value === undefined

除了这个别的也就没啥了,和数组+对象差不多。

更好的方法(双向链表)

这个方法就更强了,从逻辑层面上来说简直无敌,就是性能上不如Map,而且代码实现起来较为复杂。

先说说整体的逻辑,双向链表这个东西的概念就不多少了,之前的文章中有说过到。

这里需要首先给一个头元素和尾元素来保证整条链表的顺利链接,也是为了更加方便的获取头元素和尾元素,因为需要进行相应的插入和删除操作。

但仅仅是双向链表是不够的,因为没在的O(1)的复杂度上完成查找的工作,所以这里需要另外一个对象或者Map来直接获取到我们需要的节点,下面的代码中为了方便就使用的对象,使用Map性能会更好些。

有了对象和双向链表之后就简单了,跟上面的逻辑一样。查找的时候先删除掉当前节点,之后插入到链表的最前面;修改的话也是一样的逻辑,删除+添加;添加的时候就需要先在头节点后面插入新节点,之后去掉尾节点的前一个节点。

看看代码👇:

class DoubleLinkedNode {
  constructor(key, value) {
    this.key = key
    this.value = value
    this.prv = null
    this.next = null
  }
}

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity
    this.hashMap = {}
    this.headNode = new DoubleLinkedNode(null, null)
    this.tailNode = new DoubleLinkedNode(null, null)
    this.headNode.next = this.tailNode
    this.tailNode.prv = this.headNode
  }
  _isFull() {
    return Object.keys(this.hashMap).length >= this.capacity
  }
  _removeNode(node) {
    node.prv.next = node.next
    node.next.prv = node.prv
    delete this.hashMap[node.key]
    return node
  }
  _addHeadNode(node) {
    node.next = this.headNode.next
    node.prv = this.headNode
    this.headNode.next.prv = node
    this.headNode.next = node
    this.hashMap[node.key] = node
  }
  get(key) {
    const node = this.hashMap[key]
    if (node) {
      this._addHeadNode(this._removeNode(node))
      return node.value
    } else {
      return -1
    }
  }
  put(key, value) {
    const node = this.hashMap[key]
    if (node) {
      this._addHeadNode({
        ...this._removeNode(node),
        value
      })
    } else {
      if (this._isFull()) this._removeNode(this.tailNode.prv)
      this._addHeadNode(new DoubleLinkedNode(key, value))
    }
  }
}

代码量果然多了不少呢,先有一个DoubleLinkedNode类来帮助我们生成新的节点。

之后就是上面说的那些操作,为了避免冗余,提取了_isFull_removeNode_addHeadNode三个方法出来,供getput调用。

具体就不一点点细说了,整体逻辑就是这样,和Map的有点类似,区别就是使用数据格式不同。



PS:想查看往期文章和题目可以点击下面的链接:

这里是按照日期分类的👇

前端刷题路-目录(日期分类)

经过有些朋友的提醒,感觉也应该按照题型分类
这里是按照题型分类的👇

前端刷题路-目录(题型分类)

有兴趣的也可以看看我的个人主页👇

Here is RZ