算法练习-LRU、LFU缓存机制

1,075 阅读6分钟

摘要

这两个缓存机制算法,要解决共同难点:在缓存满的时候,如何快速删除恰当的数据。

区别在于:

  • LRU 要删除的是「最不常用的数据」
  • LFU 则比较复杂,要删除的是「使用频率最低,且最不常用的数据」

所以,虽然都是借助「哈希表 + 双向链表」来解决,但又有小小的差别。

LRU (Least Recently Used)

题目来源:146. LRU 缓存机制 - 力扣(LeetCode)

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

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

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

我们直接关注,如何在 O(1) 时间复杂度内完成呢?

get 比较容易,一般都能想到使用哈希表作为数据结构。

关键点在于,当缓存满的时候,如何在 O(1) 时间内,找到「最不常用的数据」并删除。

答案是「双向链表」。

题解

数据结构:双向链表 List + 哈希表 [Int:Node]

关键点:需要保证 List 的头结点是「最近」使用的。

算法:

get 时,取值后,将该结点移动到 List 的头部。

put 时:

  1. 若已存在,则更新 value,并将其移动到 List 的头部
  2. 若缓存已满,则删除 List 尾结点(最不常用)
  3. 否则直接插入到 List 头部

结合下图理解:

picture 2

文字描述不是很具体,可直接看代码:

class LRUCache {
    // 双向链表
    class Node {
        var key = 0, value = 0
        var pre: Node?
        var nx: Node?
    }

    class List {
        var head: Node?
        var tail: Node?

        func appendToHead(_ node: Node) {
            if let h = head {
                node.nx = h
                h.pre = node
                head = node
                return
            }

            // 无 head
            head = node
            tail = node
            node.pre = nil
            node.nx = nil
        }

        func moveToHead(_ node: Node) {
            guard let _ = head else {
                // 链表中没有结点的情况
                appendToHead(node)
                return
            }
            // 删除
            delete(node)

            // 移到头部
            appendToHead(node)
        }

        /// 删除某个结点
        func delete(_ node: Node) {
            guard let _ = head, let t = tail else {
                // 无结点
                return
            }

            if node === t { // 尾结点,需要更新 tail
                tail = t.pre
                tail?.nx = nil
            }

            // 借助哨兵结点,简化代码
            // 情况 1 只有一个结点
            // 情况 2 多于一个结点

            let dummy = Node()
            dummy.nx = head
            head?.pre = dummy

            node.pre?.nx = node.nx
            node.nx?.pre = node.pre

            head = dummy.nx
            head?.pre = nil
        }

        /// 清理
        func removeAll() {
            head = nil
            tail = nil
        }
    }

    private var capacity = 0
    /// [key : node]
    private var kvT = [Int:Node]()

    private var list = List()

    // 初始化时设置最大值
    init(_ capacity: Int) {
        self.capacity = capacity
    }

    func get(_ key: Int) -> Int {
        // 若需要保证线程安全,可加锁

        // read
        let v = self._get(key)

        return v
    }

    private func _get(_ key: Int) -> Int {
        guard capacity > 0, let n = kvT[key] else {
            return -1
        }

        _handleGetNode(n)

        return n.value
    }


    /// get 时,取值后,将该结点移动到 List 的头部
    private func _handleGetNode(_ n: Node) {
        list.moveToHead(n)
    }


    func put(_ key: Int, _ value: Int) {
        // 若需要保证线程安全,可加锁

        // write
        self._put(key, value)
    }

    /// 1. 若已存在,则更新 value,并将其移动到 List 的头部
    /// 2. 若缓存已满,则删除 List 尾结点(最不常用)
    /// 3. 否则直接插入到 List 头部
    private func _put(_ key: Int, _ value: Int) {
        if let n = kvT[key] { // 已存在
            n.value = value
            _handleGetNode(n)
            return
        }
        // 缓存已满
        _handleFull()

        // 新 node
        let n = Node()
        n.key = key
        n.value = value

        // 插入到 kvT
        kvT[key] = n

        // 插入到 List 头部
        list.appendToHead(n)
    }

    /// 缓存满了,删除尾结点
    private func _handleFull() {
        guard kvT.count == capacity else {
            return
        }

        guard let n = list.tail else {
            return
        }
        list.delete(n) // 删除尾结点
        kvT.removeValue(forKey: n.key) // 移除 kvT 中的
    }
}

LFU (Least Frequently Used)

题目来源:460. LFU 缓存

请你为 最不经常使用(LFU)缓存算法设计并实现数据结构。

实现 LFUCache 类:

  • LFUCache(int capacity) - 用数据结构的容量 capacity 初始化对象
  • int get(int key) - 如果键存在于缓存中,则获取键的值,否则返回 -1。
  • void put(int key, int value) - 如果键已存在,则变更其值;如果键不存在,请插入键值对。当缓存达到其容量时,则应该在插入新项之前,使最不经常使用的项无效。在此问题中,当存在平局(即两个或更多个键具有相同使用频率)时,应该去除 最近最久未使用 的键。

注意「项的使用次数」就是自插入该项以来对其调用 get 和 put 函数的次数之和。使用次数会在对应项被移除后置为 0 。

为了确定最不常使用的键,可以为缓存中的每个键维护一个 使用计数器 。使用计数最小的键是最久未使用的键。

当一个键首次插入到缓存中时,它的使用计数器被设置为 1 (由于 put 操作)。对缓存中的键执行 get 或 put 操作,使用计数器的值将会递增。

相比 LRU,在删除的时候,多了一个需要考量的条件:使用频率。

题解

数据结构:双向链表 + 2 个哈希表

kvT: [Int:Node]freqT: [Int:List]

有以下注意点:

  1. 额外用一个 minFreq 保存「最小频次」
  2. 往 List 中添加元素时,只能添加到头部。

算法:

get 时,取值后,更新该结点的频率,及其在 freqT 中的位置。

put 时:

  1. 若已存在,则更新 value,其使用频率 + 1,并更新其在 freqT 中的位置
  2. 若缓存已满,则删除 List 尾结点。
  3. 否则直接插入到频率 1 对应的 List 头部。

结合图片

picture 3

文字描述不是很具体,直接看以下代码:

class LFUCache {
    class Node {
        var key = 0, freq = 0, value = 0
        var pre: Node?
        var nx: Node?
    }

    class List {
        var head: Node?
        var tail: Node?

        func appendToHead(_ node: Node) {
            if let h = head {
                node.nx = h
                h.pre = node
                head = node
                return
            }

            // 无 head
            head = node
            tail = node
            node.pre = nil
            node.nx = nil
        }

        func delete(_ node: Node) {
            guard let _ = head, let t = tail else {
                // 无结点
                return
            }

            if node === t { // 尾结点,需要更新 tail
                tail = t.pre
                tail?.nx = nil
            }

            // 借助哨兵结点,简化代码
            // 情况 1 只有一个结点
            // 情况 2 多于一个结点

            let dummy = Node()
            dummy.nx = head
            head?.pre = dummy

            node.pre?.nx = node.nx
            node.nx?.pre = node.pre

            head = dummy.nx
            head?.pre = nil
        }
    }

    private var capacity = 0
    /// [key : node]
    private var kvT = [Int:Node]()
    /// [freq : List]
    private var freqT = [Int:List]()
    /// 最小频率,便于删除
    private var minFreq = 0

    init(_ capacity: Int) {
        self.capacity = capacity
    }

    func get(_ key: Int) -> Int {
        guard capacity > 0, let n = kvT[key] else {
            return -1
        }

        _handleGetNode(n)

        return n.value
    }

    private func _handleGetNode(_ n: Node) {
        // 暂存 new freq
        let nf = n.freq + 1

        // 从 freq 的链表中删除
        freqT[n.freq]?.delete(n)

        // 添加到 freq+1 的链表头部
        let l = freqT[nf] ?? List()
        l.appendToHead(n)
        freqT[nf] = l

        // 更新 minFreq,后一个条件与 List 结构有关
        if minFreq == n.freq, freqT[minFreq]?.head == nil {
            minFreq = nf
        }

        // 更新 freq
        n.freq = nf
    }

    func put(_ key: Int, _ value: Int) {
        if let n = kvT[key] { // 已存在
            n.value = value
            _handleGetNode(n)
            return
        }
        // 缓存已满
        _handleFull()

        // 插入
        let n = Node()
        n.key = key
        n.value = value
        n.freq = 1

        // 插入到 kvT
        kvT[key] = n

        // 插入到 freqT
        let list1 = freqT[1] ?? List()
        list1.appendToHead(n)
        freqT[1] = list1

        minFreq = 1
    }

    private func _handleFull() {
        guard kvT.count == capacity else {
            return
        }

        guard let n = freqT[minFreq]?.tail else {
            return
        }
        freqT[minFreq]?.delete(n) // 删除尾结点
        kvT.removeValue(forKey: n.key) // 移除 kvT 中的
    }
}

小结

这两个算法核心差异,以及解决方法的区别:

缓存机制缓存满时,需要删除解决方法 - 数据结构
LRU最不常用的数据双向链表 + 哈希表
LFU使用频率最低,且最不常用的数据双向链表 + 2 个哈希表

笔者觉得,这 2 道题目其实很好证明了双向链表的强大之处。

它的优势在于,插入、删除操作时间复杂度 O(1)。

再配合哈希表,就可以做到查找时间复杂度 O(1)。

题外话

笔者做这 2 道题时,看了下 iOS 平台 NSCache 的源码,发现底层实现大同小异。

作为实际应用例子,阅读起来,应该会比干巴巴的算法题好理解。

有兴趣的读者可以看看 源码浅析 - iOS 缓存 NSCache