LeetCode 460 - LFU 缓存


文章目录

摘要

LFU 缓存是缓存算法里的"进阶关卡"。

LRU 大家都很熟,但 LFU 往往是很多人刷 LeetCode 时第一次真正感受到:
"原来 O(1) 的设计不是写得快,而是数据结构选得对。"

这道题不只是让你写一个缓存,而是在逼你回答三个工程级问题:

  1. 怎么在 O(1) 内找到最少使用的 key?
  2. 使用次数相同时,怎么再按 LRU 淘汰?
  3. get / put 都要是 O(1),不能靠排序、遍历硬撑。

描述

题目要求你实现一个 LFUCache,支持:

  • get(key)

    • 存在:返回 value,并且 使用次数 +1
    • 不存在:返回 -1
  • put(key, value)

    • key 已存在:更新 value,并且 使用次数 +1

    • key 不存在:

      • 如果缓存没满,直接插

      • 如果缓存满了:

        • 先淘汰 使用次数最少
        • 如果次数相同,淘汰 最久没用的

并且有一个非常重要的硬指标:

getput 的平均时间复杂度必须是 O(1)

这直接排除了所有"排序 / 每次扫描一遍"的方案。

题解答案

真正能跑通并满足 O(1) 的解法,核心是三层结构:

  1. key → Node 映射
  2. freq → 双向链表 映射
  3. 一个全局的 minFreq

一句话总结:

用 HashMap 快速定位节点,用「频次分桶 + LRU 链表」来控制淘汰顺序。

题解代码分析

节点需要存什么信息?

每一个缓存项,至少要知道:

  • key
  • value
  • 当前使用频率 freq
  • 在链表里的前后指针(为了 O(1) 删除)
swift 复制代码
class Node {
    let key: Int
    var value: Int
    var freq: Int = 1
    var prev: Node?
    var next: Node?

    init(_ key: Int, _ value: Int) {
        self.key = key
        self.value = value
    }
}

为什么要用「频次 → 双向链表」?

因为 LFU 有一个隐藏条件:

同一个频次下,要按 LRU 淘汰

所以每个频次桶,本质上是一个 LRU 链表

swift 复制代码
class DoublyLinkedList {
    let head = Node(0, 0)
    let tail = Node(0, 0)
    var size = 0

    init() {
        head.next = tail
        tail.prev = head
    }

    func addToHead(_ node: Node) {
        node.next = head.next
        node.prev = head
        head.next?.prev = node
        head.next = node
        size += 1
    }

    func remove(_ node: Node) {
        node.prev?.next = node.next
        node.next?.prev = node.prev
        size -= 1
    }

    func removeLast() -> Node? {
        guard size > 0, let last = tail.prev, last !== head else {
            return nil
        }
        remove(last)
        return last
    }
}

LFUCache 的核心结构

swift 复制代码
class LFUCache {
    private let capacity: Int
    private var minFreq = 0
    private var keyToNode = [Int: Node]()
    private var freqToList = [Int: DoublyLinkedList]()
  • keyToNode:O(1) 找节点
  • freqToList:O(1) 找某个频次的 LRU 链表
  • minFreq:O(1) 知道该淘汰谁

get 操作怎么做?

核心逻辑只有三步:

  1. 查 key 是否存在
  2. 从旧 freq 链表中移除
  3. freq +1,放进新链表
swift 复制代码
func get(_ key: Int) -> Int {
    guard let node = keyToNode[key] else {
        return -1
    }
    updateFreq(node)
    return node.value
}

put 操作的关键点

  • capacity 为 0,直接返回

  • key 已存在:更新 value + 更新 freq

  • key 不存在:

    • 如果满了:

      • minFreq 对应的链表里,删 最久未使用
    • 插入新节点,freq = 1

swift 复制代码
func put(_ key: Int, _ value: Int) {
    if capacity == 0 { return }

    if let node = keyToNode[key] {
        node.value = value
        updateFreq(node)
        return
    }

    if keyToNode.count == capacity {
        if let list = freqToList[minFreq],
           let removed = list.removeLast() {
            keyToNode.removeValue(forKey: removed.key)
        }
    }

    let newNode = Node(key, value)
    keyToNode[key] = newNode
    let list = freqToList[1] ?? DoublyLinkedList()
    list.addToHead(newNode)
    freqToList[1] = list
    minFreq = 1
}

更新频次是整个设计的核心

swift 复制代码
private func updateFreq(_ node: Node) {
    let freq = node.freq
    if let list = freqToList[freq] {
        list.remove(node)
        if freq == minFreq && list.size == 0 {
            minFreq += 1
        }
    }

    node.freq += 1
    let newList = freqToList[node.freq] ?? DoublyLinkedList()
    newList.addToHead(node)
    freqToList[node.freq] = newList
}

这里做了三件事:

  • 从旧频次链表移除
  • 如果刚好是 minFreq 且链表空了,更新 minFreq
  • 插入新频次链表头部

示例测试及结果

swift 复制代码
let lfu = LFUCache(2)

lfu.put(1, 1)
lfu.put(2, 2)
print(lfu.get(1)) // 1

lfu.put(3, 3)
print(lfu.get(2)) // -1
print(lfu.get(3)) // 3

lfu.put(4, 4)
print(lfu.get(1)) // -1
print(lfu.get(3)) // 3
print(lfu.get(4)) // 4

输出结果:

复制代码
1
-1
3
-1
3
4

和题目示例完全一致。

与实际场景结合

LFU 在真实项目里比你想象中常见,比如:

  • CDN 本地缓存
  • App 内图片 / 数据缓存
  • 后端热点数据保护
  • 推荐系统中的候选集缓存

相比 LRU,LFU 更适合:

"长期高频访问的数据,不能因为短期冷却就被干掉"

比如用户主页、配置数据、热门商品列表。

时间复杂度

  • get:O(1)
  • put:O(1)

这是通过 HashMap + 双向链表 + minFreq 联合保证的。

空间复杂度

复制代码
O(capacity)

所有节点、链表总数都和缓存容量线性相关。

总结

LFU 这道题,真正难的不是代码多,而是:

  • 你能不能拆清楚"频次"和"时间"这两个维度
  • 你能不能在 O(1) 下把这两个维度同时维护住

如果你能完整写出这道题,其实已经具备了:

  • 设计复杂缓存系统的能力
  • 面试中讲清楚"为什么这样设计"的底气
  • 把算法真正落地成工程代码的经验
相关推荐
kebidaixu3 分钟前
两轮BMS 防打火策略详解
算法
wabs66635 分钟前
关于动态规划【力扣1035.不相交的线和53.最大子数组和的思考】
算法·leetcode·动态规划
退休倒计时35 分钟前
【每日一题】LeetCode 199. 二叉树的右视图 TypeScript
算法·leetcode·typescript
可编程芯片开发39 分钟前
通过MATLAB实现PID控制器,积分分离控制器以及滑模控制器
算法
kebidaixu1 小时前
两轮BMS 短路保护策略详解
算法
zwenqiyu1 小时前
非线性字符串数据结构串讲
数据结构·c++·学习·算法
z小猫不吃鱼1 小时前
03 Optimal Brain Surgeon 详解:Hessian 剪枝为什么有效?
算法·机器学习·剪枝
硕风和炜2 小时前
【LeetCode: 1301. 最大得分的路径数目 + DP】
java·算法·leetcode·动态规划·dp·记忆化搜索
XLYcmy2 小时前
核内调度问题的分层优化:缓存管理与性能均衡策略 模型评价 模型缺点与改进方向
缓存·ai·启发式算法·图神经网络·遗传算法·数模·模拟退火
用户99045017780092 小时前
做了一个AI诊断,参考倪海厦中医理论,科学养生
算法