手把手教你实现LRU缓存(JavaScript描述)

474 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

一、缓存策略

缓存这个概念相信大家都不陌生,缓存的存在主要是为了提高数据的访问效率,比较常用的数据放在缓存里面,相当于缩小了检索范围。不管是CPU还是浏览器,都需要用到缓存。

缓存的容量是有限的,一旦缓存满了就需要踢除旧的数据,那么应该踢除哪些数据呢?这就涉及到了缓存策略。常见的缓存策略主要有两种,LRU(least recently used)最少最近使用和LFU(least frequently used)最不经常使用。LRU看时间,淘汰最旧的数据,即使最旧的数据之前被使用过很多次,也会因为它是最旧的而被淘汰。LFU则看频次,淘汰使用最少的数据,如果旧的数据之前被使用过多次就不会被优先淘汰。

今天要带大家实现的就是最常用的缓存策略:LRU缓存。

二、LRU缓存策略图示

image.png

下面我们举个例子来看LRU是怎样缓存的。如上图所示,这是一个容量为5的缓存,我们依次放进去ABCDE五个值,准备放F的时候发现缓存已经满了,这个时候淘汰掉最旧的数据A,之后我们访问C这个数据,缓存的顺序因为访问做了调整,C因为是最新访问的数据所以被提前,变成CFEDB,准备放入G的时候发现缓存已满,淘汰最旧的数据B,然后放入G,此时缓存变为GCFEDB。

三、选择数据结构

可以观察到,存放缓存的数据结构是一个线性表,数据按照时间顺序从新到旧按顺序排开。

我们来分析一下这个线性表需要做的事情:

  1. 每次来新数据,我们需要在头部插入数据 ---- 头部插入数据
  2. 如果满了我们需要删除最旧的 ---- 尾部删除数据
  3. 我们需要访问缓存中的数据,比如上例中的C,我们需要判断它在不在,如果在我们需要找到它的位置 ---- 查找数据
  4. 像上例C那样的数据我们访问它之后需要把它从中间删除然后插入到头部 ---- 从中间删除数据

头部插入、尾部删除和从中间删除数据这三个链表都可以以O(1)的时间复杂度实现,但是链表的查找比较比较慢,需要O(n)。对于查找,我们可以借助哈希Map来实现数据的查找操作,只要用key来把哈希Map和链表做好关联就可以了。

根据以上分析,我们确定:

1.数据结构选用哈希表 + 双向链表

  • 双向链表用于按时间顺序保存数据
  • 哈希表用于把key映射到链表结点

2.用哈希表 + 双向链表实现如下操作

  • O(1)访问:直接检查哈希表
  • O(1)更新:通过哈希表找到链表结点,删除链表结点,并插入到链表头部
  • O(1)删除:总是淘汰链表最末尾结点,同时在哈希表中删除

四、代码实现

第一步:构造链表

这一步注意要为链表结点设置key属性来和哈希表产生关联

function LinkNode(val) {
    this.val = val
    this.next = null
    this.prev = null
    this.key = null
}

第二步:链表操作函数

插入结点

让p和p的后继结点都跟node产生双向连接从而达到插入node结点的目的。

function insert(p, node) {
    p.next.prev = node
    node.next = p.next
    node.prev = p
    p.next = node
}

删除结点

node的前驱结点直接指向node的后继结点,后继结点直接指向前驱结点,跳过node达到删除的目的。

function remove(node) {
    node.next.prev = node.prev
    node.prev.next = node.next
}

第三步:构造LRUCache函数

这一步需要把head、tail、capacity、map都作为成员变量,使得原型链上面的方法可以直接访问它们。其中的head和tail作为哨兵,让链表结点可以更方便的被访问到。(如果不理解哨兵,可以看下这里的解释 链表中的哨兵是怎么一个作用?

const LRUCache = function(capacity) {
    this.head = new LinkNode(0)
    this.tail = new LinkNode(0)
    this.head.next = this.tail
    this.tail.prev = this.head
    this.capacity = capacity
    this.map = new Map()
};

第四步:写get、put方法

get

如果map里面找不到对应的key说明缓存里面没有返回-1,如果有那么把对应的node从原位置删除,再从头部插入。

LRUCache.prototype.get = function(key) {
    if(!this.map.has(key)) return -1
    let node = this.map.get(key)
    remove(node)
    insert(this.head, node)
    return node.val
};

put

如果map里面找不到对应的key说明缓存里面没有对应的结点,所以需要新建结点并插入。注意插入链表之前需要先判断一下map的长度是否超过了缓存容量上限,如果超过了需要删除链表的末尾结点和对应的map键值对。一定要先删除map键值对再删除链表结点,因为key值取的是链表的尾结点的key值。

如果map中已经存在对应的key值说明缓存中已有此数据,那么更新一下对应key的值(因为有可能key相同值不同),把结点从原位置删除,再从头部插入。

LRUCache.prototype.put = function(key, value) {
    if(!this.map.has(key)) {
        let node = new LinkNode(value)
        node.key = key
        this.map.set(key, node)
        if(this.map.size > this.capacity) {
            // 注意要先删除map中的key值,后删除链表中的结点
            this.map.delete(this.tail.prev.key)
            remove(this.tail.prev)
        }
        insert(this.head, node)
    } else {
        let node = this.map.get(key)
        // key相同值可能不同,所以这里要给node赋值
        node.val = value
        remove(node)
        insert(this.head, node)
    }
};

五、总结

我们从LRU缓存的实际应用场景出发分析了要完成这样一种缓存策略所需要做的操作:

  1. 访问数据,如果缓存中有则把该数据从原位置删除,再从头部插入,如果没有则直接返回-1
  2. 添加数据,插入之前判断一下是否超过了缓存的最大容量,如果超过了则从尾部删除最旧的数据再插入

为了能够快速的插入和删除数据,我们选择了链表结构,为了能够快速的访问数据,我们选择了哈希表,然后我们通过key来连接两种结构,使它们能够共同起作用。

过程中容易出错的点是完成put方法时判断当map的长度超出缓存的最大容量时,要先删除map的键值对,后删除链表的尾结点。以及当新加入的key哈希表中已经存在时,要记得更新node的值,因为有可能key相同但是值有所更新。

六、完整代码(附)

1.原型链写法

/**
 * @param {number} capacity
 */
const LRUCache = function(capacity) {
    this.capacity = capacity
    this.map = new Map()
    this.head = new LinkNode(0)
    this.tail = new LinkNode(0)
    this.head.next = this.tail
    this.tail.prev = this.head
};

/** 
 * @param {number} key
 * @return {number}
 */
LRUCache.prototype.get = function(key) {
    if(!this.map.has(key)) return -1
    let node = this.map.get(key)
    remove(node)
    insert(this.head, node)
    return node.val
};

/** 
 * @param {number} key 
 * @param {number} value
 * @return {void}
 */
LRUCache.prototype.put = function(key, value) {
    if(!this.map.has(key)) {
        let node = new LinkNode(value)
        node.key = key
        this.map.set(key, node)
        if(this.map.size > this.capacity) {
            this.map.delete(this.tail.prev.key)
            remove(this.tail.prev)
        }
        insert(this.head, node)
    } else {
        let node = this.map.get(key)
        node.val = value
        remove(node)
        insert(this.head, node)
    }
};

function LinkNode(val) {
    this.val = val
    this.next = null
    this.prev = null
    this.key = null
}

function remove(node) {
    node.next.prev = node.prev
    node.prev.next = node.next
}

function insert(p, node) {
    node.next = p.next
    p.next.prev = node
    p.next = node
    node.prev = p
}
/**
 * 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)
 */

2.类写法

/**
 * @param {number} capacity
 */
class LinkNode {
    constructor(val) {
        this.val = val
        this.next = null
        this.prev = null
        this.key = null
    }
    static remove(node) {
        node.next.prev = node.prev
        node.prev.next = node.next
    }
    static insert(p, node) {
        node.next = p.next
        p.next.prev = node
        p.next = node
        node.prev = p
    }
}
class LRUCache {
    constructor(capacity) {
        this.capacity = capacity
        this.cache = new Map()
        this.head = new LinkNode(0)
        this.tail = new LinkNode(0)
        this.head.next = this.tail
        this.tail.prev = this.head
    }   
    get(key) {
        if(!this.cache.has(key)) return -1
        let node = this.cache.get(key)
        LinkNode.remove(node)
        LinkNode.insert(this.head, node)
        return node.val
    }
    put(key, value) {
        if(!this.cache.has(key)) {
            let node = new LinkNode(value)
            node.key = key
            this.cache.set(key, node)
            if(this.cache.size > this.capacity) {
                this.cache.delete(this.tail.prev.key)
                LinkNode.remove(this.tail.prev)
            }
            LinkNode.insert(this.head, node)
        } else {
            let node = this.cache.get(key)
            node.val = value
            LinkNode.remove(node)
            LinkNode.insert(this.head, node)
        }
    }
}

/**
 * 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)
 */

六、参考资料

  1. Leecode146题:leetcode-cn.com/problems/lr…
  2. 极客时间算法训练营