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

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

  • 设计复杂缓存系统的能力
  • 面试中讲清楚"为什么这样设计"的底气
  • 把算法真正落地成工程代码的经验
相关推荐
程芯带你刷C语言简单算法题2 小时前
Day39~实现一个算法确定将一个二进制整数翻转为另一个二进制整数,需要翻转的位数
c语言·开发语言·学习·算法·c
zcbdandan2 小时前
JNA内存对齐导致的结构体数组传输错误
数据结构·算法
dundunmm2 小时前
【每天一个知识点】YOLO算法
算法·yolo·目标检测
lihihi2 小时前
P5182 棋盘覆盖
算法·图论
白云千载尽2 小时前
LLaMA-Factory 入门(一):Ubuntu20 下大模型微调与部署
人工智能·算法·大模型·微调·llama
yugi9878383 小时前
基于Takens嵌入定理和多种优化算法的混沌序列相空间重构MATLAB实现
算法·matlab·重构
Yuer20253 小时前
为什么要用rust做算子执行引擎
人工智能·算法·数据挖掘·rust
持梦远方3 小时前
持梦行文本编辑器(cmyfEdit):架构设计与十大核心功能实现详解
开发语言·数据结构·c++·算法·microsoft·visual studio
im_AMBER3 小时前
Leetcode 90 最佳观光组合
数据结构·c++·笔记·学习·算法·leetcode