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) 下把这两个维度同时维护住

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

  • 设计复杂缓存系统的能力
  • 面试中讲清楚"为什么这样设计"的底气
  • 把算法真正落地成工程代码的经验
相关推荐
Momo__zz21 小时前
零代码平台设计
算法·深度优先
cpp_250121 小时前
P2947 [USACO09MAR] Look Up S
数据结构·c++·算法·题解·单调栈·洛谷
水木流年追梦1 天前
大模型入门-大模型优化方法13- MTP 多 token 输出、DCA 双块注意力
人工智能·分布式·算法·正则表达式·prompt
数据皮皮侠1 天前
全国消协智慧 315 平台投诉信息数据库
大数据·人工智能·算法·百度·制造
8Qi81 天前
LeetCode 115 & 392:不同子序列 / 判断子序列
算法·leetcode·职场和发展·动态规划
小蒋学算法1 天前
算法-乘法表中第K小的数-二分
数据结构·算法
智者知已应修善业1 天前
【51单片机8个LED,已经使用了D1D2,怎么样在不动D1D2的前提下实现D6~D8的流水灯】2024-1-19
c++·经验分享·笔记·算法·51单片机
Evand J1 天前
【MATLAB例程】自适应渐消扩展卡尔曼滤波(AFEKF)三维雷达目标跟踪|效果已调优,附下载链接和运行结果,代码直接运行即可
开发语言·算法·matlab·目标跟踪·卡尔曼滤波·自适应滤波·代码定制
圣保罗的大教堂1 天前
leetcode 2161. 根据给定数字划分数组 中等
leetcode
插件开发1 天前
矢量路径运算如何选GPU技术?——适用算法对比及OpenGL/Direct3D/CUDA选型指南
算法·3d