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 不存在:

      • 如果缓存没满,直接插

      • 如果缓存满了:

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

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

get 和 put 的平均时间复杂度必须是 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) 下把这两个维度同时维护住

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

  • 设计复杂缓存系统的能力
  • 面试中讲清楚"为什么这样设计"的底气
  • 把算法真正落地成工程代码的经验
相关推荐
罗西的思考9 小时前
【Agentic RL】Dressage Staleness 控制深度分析
人工智能·算法·机器学习
quaer10 小时前
弗洛伊德算法--判圈法寻找环的入口O(n) O(1)
数据结构·算法
文丑颜不良啊11 小时前
LeetCode51~75题目解析
leetcode
高晶11 小时前
无乘法器实现LC二阶滤波器:基于移位运算的快速算法与四种精度推导
算法·设计模式·程序员
ysbfsy11 小时前
大数计算思路
算法
高晶11 小时前
主从自动分配算法:平均值乘以设备数一半的选举基准值解析
算法·设计模式·程序员
学电子她就能回来吗12 小时前
LeetCode Hot 100|普通数组专题学习笔记(零基础版)
笔记·算法·leetcode
用户老李12 小时前
反向传播计算梯度举例
算法
niyongsheng12 小时前
多智能体连续时间内路径规划:AA-SIPP(m) 的 Rust 实现与算法
算法·rust·机器人
302wanger13 小时前
DeepSeek-V4.1-Flash-1017 免费用到1024,速来
算法