news 2026/9/7 23:10:24

LFU 缓存——从 LRU 到 LFU,设计题的“频率革命“

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LFU 缓存——从 LRU 到 LFU,设计题的“频率革命“

摘要:LRU 问"你多久没被用",LFU 问"你被用了几次"。缓存满了,LFU 淘汰最不常被访问的项。这篇从 LRU 的局限讲起,手写 LeetCode 460(Hard)双哈希表 + 频率桶的 O(1) 实现,用 895 最大频率栈展示"频率索引"的对称思想,附三语言、手算推演与对比表。

关键词:LFU 缓存、LeetCode 460、LeetCode 895、频率桶、双向链表、缓存淘汰策略、LRU

引子:缓存满了,谁该被请出去?

8 月的 LRU 篇(DSA 第20篇)讲了一个答案:最久没被用的滚蛋。但假设你的系统里有一个"每周五 10 点被大批量访问"的热点数据——它平时很冷,只在固定时刻爆热。LRU 可能刚把它请出去,下一秒访问就来了。

LFU(Least Frequently Used)给出另一个答案:用得最少的滚蛋。它不问"你多久没来",而问"你来了几次"。这篇从 LRU 的局限讲起,手写 LeetCode 460(Hard)的双哈希表 + 频率桶实现,再用 895 最大频率栈展示"频率索引"思想的对称面。


一、为什么需要 LFU?

1.1 LRU 的局限:时间 ≠ 重要

LRU 的核心假设是时间局部性:刚被访问过的,很快还会被访问。但真实场景里:

  • 周期性热点(每周五 10 点的报表)平时很冷,LRU 会提前淘汰它

  • 批量扫描任务会"污染"缓存:一次性访问大量数据,把真正的高频热点挤出去

LRU 只记得"最后一次",记不住"总共几次"——这就是 LFU 的动机[3]。

1.2 LFU 的直觉:次数才是真爱

LFU 给每个 key 记一个访问频率,容量满时淘汰频率最低的。它更适合"访问模式稳定"的场景(CDN 热点、数据库查询缓存)——高频项长期驻留,低频项快速清退[3]。


二、LFU 淘汰规则

2.1 主淘汰 + 兜底

容量满时: ① 先找频率最低的桶(min_freq) ② 桶内按"最近使用时间"排序 → 淘汰最久未用的 → LFU 主淘汰 + LRU 兜底 = 双保险[1]

为什么需要 LRU 兜底?因为同频率的 key 可能有多个,必须有个规则决定谁先走——"最久未使用"是最自然的仲裁者[1]。


三、460 三件套实现

3.1 三个映射,全是 O(1)

要做到 get/put 均摊 O(1),需要三个结构互相配合[1][4]:

  • 哈希表①key → (value, freq):get 查值 O(1)

  • 哈希表②freq → 双向链表:同频率项按访问时间排序,尾部最旧

  • 节点存 key:哈希表①能定位节点,从而 O(1) 从链表删除(不存 key 就得 O(n) 找)

3.2 Python 实现(核心逻辑)

class Node: __slots__ = ('key', 'value', 'freq', 'prev', 'next') def __init__(self, key=0, value=0): self.key, self.value, self.freq = key, value, 1 self.prev = self.next = None class DLinked: """频率桶:双向链表,头 = 最新,尾 = 最旧""" def __init__(self): self.head, self.tail = Node(), Node() self.head.next, self.tail.prev = self.tail, self.head self.size = 0 def push_front(self, node): nxt = self.head.next self.head.next, node.prev = node, self.head node.next, nxt.prev = nxt, node self.size += 1 def remove(self, node): node.prev.next, node.next.prev = node.next, node.prev self.size -= 1 def pop_back(self): node = self.tail.prev self.remove(node) return node def empty(self): return self.size == 0 class LFUCache: def __init__(self, capacity: int): self.cap = capacity self.min_freq = 0 self.kv = {} # key -> Node self.freqs = {} # freq -> DLinked def _touch(self, node): """访问节点:频率 +1,移入下一桶""" f = node.freq self.freqs[f].remove(node) if self.freqs[f].empty() and f == self.min_freq: self.min_freq += 1 # 桶空且是最小频率 → 最小频率上移 node.freq += 1 self.freqs.setdefault(node.freq, DLinked()).push_front(node) def get(self, key: int) -> int: if key not in self.kv or self.cap == 0: return -1 node = self.kv[key] self._touch(node) return node.value def put(self, key: int, value: int) -> None: if self.cap == 0: return if key in self.kv: node = self.kv[key] node.value = value self._touch(node) return if len(self.kv) >= self.cap: victim = self.freqs[self.min_freq].pop_back() # 最冷桶的最旧节点 del self.kv[victim.key] node = Node(key, value) self.kv[key] = node self.freqs.setdefault(1, DLinked()).push_front(node) self.min_freq = 1

3.3 C++ 要点

class LFUCache { struct Node { int key, val, freq; list<Node>::iterator it; }; unordered_map<int, Node> kv; unordered_map<int, list<Node>> freqList; // freq -> 双向链表 int cap, minFreq; public: // get/put 与 Python 逻辑一致;链表用 std::list 天然 O(1) 删除 };

3.4 手算推演

capacity=2 put(1,1): 1号桶=[1(f1)] min_freq=1 put(2,2): 1号桶=[1(f1),2(f1)] min_freq=1 get(1): 1→f2:1号桶=[2] 2号桶=[1] min_freq=1 put(3,3): 满 → 淘汰 1 号桶尾部 2(f1) → 3(f1)入 1号桶 → [3, 1号桶], 2号桶=[1] get(1): 1→f3 get(3): 3→f2 get(4)= -1(不存在)

每一步验证:"最小频率桶的最旧节点"就是牺牲品——2只被访问过一次,最先出局。


四、895 最大频率栈:对称的另一极

4.1 问题与直觉

LeetCode 895:实现一个栈,pop()返回频率最高且最接近栈顶的元素[2]。

注意对称性:

  • LFU(460):淘汰min_freq(最冷)

  • 895:弹出max_freq(最热)

实现同样简单:元素→频率+频率→栈(同频率用栈天然满足"后进先出")[2]。

4.2 Python 实现

from collections import defaultdict class FreqStack: def __init__(self): self.freq = defaultdict(int) # 元素 -> 频率 self.stacks = defaultdict(list) # 频率 -> 栈 self.max_freq = 0 def push(self, val: int) -> None: self.freq[val] += 1 f = self.freq[val] self.stacks[f].append(val) self.max_freq = max(self.max_freq, f) def pop(self) -> int: val = self.stacks[self.max_freq].pop() self.freq[val] -= 1 if not self.stacks[self.max_freq]: self.max_freq -= 1 # 栈空则最高频率下移 return val

4.3 为什么值得一起学

460 的桶是双向链表(要淘汰最旧),895 的桶是(要弹出最新)——同一个"频率→容器"索引,容器按需求换形态。这就是数据结构迁移:理解了频率索引思想,两题一起拿下[2]。


五、LRU vs LFU + 我的心得

5.1 对比表

维度

LRU(146)

LFU(460)

淘汰依据

最久未使用

使用频率最低

核心假设

时间局部性

频率稳定性

数据结构

哈希+双向链表

双哈希+频率桶

复杂度

O(1)

O(1)(均摊)

适合场景

通用缓存

热点稳定(CDN/DB 缓存)

变体

Redis volatile-lfu

5.2 三条心得

心得一:缓存淘汰没有银弹。LRU 怕"突发热点被误杀",LFU 怕"历史热点僵死"。Redis 默认组合策略说明工程上从不迷信单一算法[3]。

心得二:O(1) 的实现代价是"索引套索引"。460 用三个映射互相定位——这是设计题的核心考点:能用 O(1) 的前提是每一步都有索引

心得三:频率思想可以反向用。895 证明"频率"不仅能淘汰最冷,还能弹出最热——一个概念,两个出口

LFU 是 LRU 的"频率革命":把缓存从"时间记忆"升级到"频率记忆"。当你再遇到"淘汰谁"的设计题,就拥有了两套完整的武器库。


参考资料

信源编号对应02-情报.md信源清单。

  • [1] LeetCode 460 官方题解(一级)

  • [2] LeetCode 895 官方题解(一级)

  • [3] 系统设计教材:缓存淘汰策略(二级)

  • [4] 力扣讨论区:460 复杂度证明(三级)

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 23:08:06

CLI与JSON-RPC:AI系统集成的高效接口方案

1. CLI 作为 AI 连接世界的终极接口&#xff1a;为什么 OpenClaw 作者认为应该忘掉 MCP最近在 AI 开发工具领域&#xff0c;OpenClaw 作者提出的"CLI 才是 AI 连接世界的终极接口"这一观点引发了广泛讨论。作为一名长期工作在 AI 工程化一线的开发者&#xff0c;我深…

作者头像 李华
网站建设 2026/9/7 23:07:45

Spring Data Sort转QueryDSL OrderSpecifier通用工具类实现

做后端的朋友应该都有这种经历&#xff1a;接口明明收的是Pageable/Sort&#xff0c;走 JPA Repository 的时候一切正常&#xff0c;但是一旦切到 QueryDSL 自定义查询&#xff0c;Sort就使不上劲了。Spring Data 的Sort和 QueryDSL 的OrderSpecifier是两套排序模型&#xff0c…

作者头像 李华
网站建设 2026/9/7 23:03:16

移动端适配基石:彻底搞懂 Viewport 与视口单位

做移动端页面调试时&#xff0c;你一定遇到过这样的场景&#xff1a;PC 上用 DevTools 模拟手机一切正常&#xff0c;真机一打开&#xff0c;字小到要双指放大才能看清&#xff0c;页面横向还多出一截&#xff0c;底部按钮被地址栏遮得严严实实。为了这些移动浏览问题&#xff…

作者头像 李华
网站建设 2026/9/7 23:02:39

AI Agent安全沙箱:容器与微虚拟机隔离技术解析

1. AI Agent代码执行沙箱的核心挑战 在AI Agent开发领域&#xff0c;代码执行沙箱是确保系统安全的关键组件。我经历过多次由于沙箱隔离不足导致的安全事故&#xff0c;最严重的一次是恶意代码通过AI Agent逃逸到宿主系统&#xff0c;删除了整个数据库。这种惨痛教训让我深刻认…

作者头像 李华