从0到1实现LFU缓存:思路拆解+代码落地

从0到1实现LFU缓存:思路拆解+代码落地

在缓存淘汰策略的面试考点中,LFU(Least Frequently Used)是比LRU更进阶的高频题------它不仅要求掌握"哈希表+集合"的组合使用,还需要理解"频率维度的动态维护"。很多初学者看到"最少使用"的淘汰规则就陷入误区,要么用排序导致O(n log n)的时间复杂度,要么维护最小频率时频繁遍历,最终写出的代码既不满足性能要求,也难以维护。

本文将延续"需求分析→思路拆解→数据结构选型→逐步实现→易错点总结"的节奏,带你从零构建全O(1)时间复杂度的LFU缓存,最终写出和工业界标准一致的最优版本(双哈希表+Set)。

一、先搞懂:LFU到底是什么?

LFU 全称 Least Frequently Used(最少使用),核心逻辑和LRU有本质区别:

  • LRU:淘汰"最近最少被使用"的缓存项(看「使用时间」);

  • LFU:淘汰"使用次数最少"的缓存项(看「使用频率」)。

举个生活例子帮你理解:

你有一个只能放2本书的书架(缓存容量=2):

  1. 先放《算法》→ 书架:[算法](使用次数=1)

  2. 再放《Java》→ 书架:[算法(1), Java(1)](已满,两者频率相同)

  3. 访问《算法》→ 频率+1 → 书架:[算法(2), Java(1)]

  4. 新增《Python》→ 容量满,淘汰使用次数最少的《Java》→ 书架:[算法(2), Python(1)]

  5. 访问《Python》两次 → 频率变为3 → 书架:[算法(2), Python(3)]

  6. 新增《JS》→ 淘汰使用次数最少的《算法》→ 书架:[Python(3), JS(1)]

这个例子的核心是:频率是第一优先级,同频率下再按"插入顺序"淘汰最久未用的------这也是LFU实现的关键细节。

二、明确需求:LFU需要实现哪些功能?

面试中LFU的核心需求和性能要求是固定的,这是我们选择数据结构的核心依据:

操作 功能描述 性能要求
get(key) 根据key查询缓存值;查不到返回-1;查到则将该key的使用频率+1 O(1)
put(key, val) 1. 若key存在:更新缓存值,频率+1;2. 若key不存在:新增缓存;若容量满,先淘汰使用次数最少的项(同频率淘汰最久未用),再新增 O(1)

三、思路拆解:如何满足O(1)性能?

我们逐个分析核心操作,拆解需要解决的问题和对应的技术方案:

1. 先看get(key):如何实现O(1)查询+频率更新?

get操作的第一步是"快速找到key对应的缓存项",这和LRU一致------哈希表(Map) 是唯一选择,因为Map的get(key)操作天生是O(1),能直接通过key定位到缓存项。

但关键问题来了:找到缓存项后,如何"频率+1"且保证O(1)?

  • 频率+1本身是简单的数值操作,但需要把该key从"旧频率分组"移到"新频率分组";

  • 如果只用一个Map存储key→(val, freq),分组移动时需要遍历所有key找同频率项(O(n)),无法满足要求。

因此,我们需要第二个哈希表,专门维护"频率→同频率key集合"的映射。

2. 再看put(key, val):如何实现O(1)新增/更新+淘汰?

put操作的核心难点有两个:

  • 难点1:淘汰"使用次数最少"的项 → 必须快速找到当前最小频率;

  • 难点2:同频率下淘汰"最久未用"的项 → 同频率的key需要按插入顺序存储。

针对这两个难点,我们需要:

  1. 维护一个minFreq变量,实时记录当前最小频率(避免遍历所有频率找最小值);

  2. 同频率的key用Set存储(JS的Set按插入顺序存储,能O(1)获取最久未用的key)。 这里我偷懒了,其实之前的LRU的双链表也完全可以,也是常规写法。

3. 数据结构选型:双哈希表(最优组合)

结合以上分析,LFU的最优数据结构组合是:

  • 哈希表1(keyToNode):key → Node(节点存储key、val、freq),负责O(1)查询节点;

  • 哈希表2(freqToKeySet):freq → Set(key),负责O(1)按频率分组管理key;

  • minFreq变量:实时维护当前最小频率,避免O(n)遍历。

补充两个关键细节(新手必踩坑):

  • 为什么用Set存储同频率的key?→ Set的add/delete操作是O(1),且按插入顺序存储,能直接取第一个元素作为"同频率最久未用"的key;

  • 节点必须存储key吗?→ 必须!淘汰节点时,需要通过节点的key删除keyToNode中的映射,避免内存泄漏。

四、核心思路梳理:串联操作流程

前面我们拆解了单个操作的实现思路和数据结构选型,这里我们串联所有核心操作,形成完整的LFU执行流程,帮你在编码前理清逻辑,避免写代码时混乱。

LFU的所有操作,本质都是"哈希表+Set+minFreq"的协同工作,核心流程可总结为3类场景,覆盖get、put的所有情况:

1. 场景一:get(key) 操作(查询+频率更新)

  1. 通过keyToNode哈希表查询key,若不存在,直接返回-1;

  2. 若存在,获取对应节点,执行频率更新(旧频率→新频率);

  3. 将key从旧频率的Set中移除,加入新频率的Set;

  4. 若旧频率的Set为空,删除该频率映射,若旧频率等于minFreq,则minFreq自增;

  5. 返回节点的val值。

2. 场景二:put(key, val) 操作(key已存在,更新)

  1. 通过keyToNode哈希表判断key是否存在,若存在,调用refresh方法;

  2. refresh方法中,先更新节点的val值(可选),再执行频率更新、Set迁移和minFreq维护;

  3. 更新完成后直接返回,无需后续操作。

3. 场景三:put(key, val) 操作(key不存在,新增)

  1. 判断缓存容量是否已满(keyToNode的size等于capacity),若已满,调用evict方法淘汰节点;

  2. evict方法中,获取minFreq对应的Set,删除Set中第一个key(同频率最久未用),同步删除keyToNode中的映射;

  3. 调用add方法,新建节点,将节点加入keyToNode,将key加入freq=1的Set;

  4. 重置minFreq=1(新增节点频率为1,必然是当前最小频率)。

梳理完这个流程,我们能发现:所有操作都围绕"双哈希表+minFreq"展开,且每一步都是O(1)操作,完全满足性能要求。接下来,我们就按照这个流程,逐步实现每个方法,把思路落地为代码。

五、逐步实现:从基础到完整代码

我们分三步实现,先搭建节点类,再实现核心方法,最后组合成完整的LFUCache类,每一步都标注思路和注意点。

第一步:实现LFU缓存节点(Node)

节点需要存储key、val和freq(使用频率),其中freq初始为1,每次get/put(更新)操作都会+1。

JavaScript 复制代码
/**
 * LFU缓存节点类 - 存储缓存的键、值、访问频率
 * @class Node
 * @param {any} key - 缓存键(淘汰时需通过key删除哈希表映射)
 * @param {any} val - 缓存值
 * @property {number} freq - 访问频率(初始值为1,每次访问+1)
 */
class Node {
  constructor(key, val) {
    this.key = key; // 必须存储key,淘汰时删除Map映射
    this.val = val; // 缓存值
    this.freq = 1;  // 访问频率,初始为1
  }
}

第二步:实现LFUCache核心类

LFUCache类组合双哈希表和minFreq变量,封装核心方法,同时提取语义化方法(refresh、evict、add),让代码更清晰、可维护。

JavaScript 复制代码
/**
 * LFU缓存实现类(Least Frequently Used - 最少使用淘汰策略)
 * 核心设计:双哈希表 + O(1) 维护最小频率
 *  - keyToNode: key → Node 映射(O(1) 查找节点)
 *  - freqToKeySet: freq → Set(key) 映射(O(1) 按频率分组管理key)
 * @class LFUCache
 * @param {number} capacity - 缓存最大容量(必须>0)
 */
class LFUCache {
  /**
   * 初始化LFU缓存
   * @param {number} capacity - 缓存最大容量
   */
  constructor(capacity) {
    this.capacity = capacity;          // 缓存最大容量
    this.keyToNode = new Map();        // key → Node(O(1) 查找节点)
    this.freqToKeySet = new Map();     // freq → Set(key)(O(1) 分组管理)
    this.minFreq = 1;                  // 最小访问频率,初始为1
  }

  /**
   * 语义化核心方法:刷新指定key的缓存(可选更新值 + 频率+1)
   * @param {any} key - 缓存键
   * @param {any} [val] - 可选:要更新的缓存值(不传则仅更新频率)
   * @description 核心逻辑:更新值 → 频率+1 → 从旧频率Set移除 → 加入新频率Set → 维护minFreq
   */
  refresh(key, val) {
    // 1. 获取节点(调用前已确保key存在)
    const node = this.keyToNode.get(key);

    // 2. 可选更新缓存值
    if (val !== undefined) {
      node.val = val;
    }

    // 3. 频率更新:旧频率→新频率
    const oldFreq = node.freq;
    const newFreq = oldFreq + 1;
    node.freq = newFreq; // 核心:必须更新节点自身的freq

    // 4. 从旧频率的Set中移除当前key
    const keySet = this.freqToKeySet.get(oldFreq);
    keySet.delete(key); // Set.delete是O(1)

    // 5. 若旧频率的Set为空,删除该频率映射,并维护minFreq
    if (keySet.size === 0) {
      this.freqToKeySet.delete(oldFreq);
      // 关键:如果删除的是当前最小频率,直接+1(O(1)维护)
      if (this.minFreq === oldFreq) {
        this.minFreq++;
      }
    }

    // 6. 将key添加到新频率的Set中(不存在则新建)
    if (!this.freqToKeySet.has(newFreq)) {
      this.freqToKeySet.set(newFreq, new Set());
    }
    this.freqToKeySet.get(newFreq).add(key);
  }

  /**
   * 语义化核心方法:淘汰最少使用的节点(频率最低 → 同频率最先加入的key)
   * @description 核心逻辑:取minFreq的Set第一个key → 删除key → 同步删除哈希表映射
   */
  evict() {
    // 1. 获取最小频率对应的key集合
    const keySet = this.freqToKeySet.get(this.minFreq);

    // 2. 取Set中第一个key(同频率最久未用)
    const firstKey = keySet.values().next().value;

    // 3. 从Set中删除该key
    keySet.delete(firstKey);

    // 4. 若Set为空,删除该频率映射
    if (keySet.size === 0) {
      this.freqToKeySet.delete(this.minFreq);
    }

    // 5. 同步删除keyToNode中的映射
    this.keyToNode.delete(firstKey);
  }

  /**
   * 语义化核心方法:新增缓存节点(频率初始化为1)
   * @param {any} key - 缓存键
   * @param {any} val - 缓存值
   * @description 核心逻辑:新建节点 → 加入keyToNode → 加入freq=1的Set → 重置minFreq=1
   */
  add(key, val) {
    // 1. 新建节点(频率默认1)
    const newNode = new Node(key, val);

    // 2. 加入keyToNode哈希表
    this.keyToNode.set(key, newNode);

    // 3. 加入freq=1的Set(不存在则新建)
    if (!this.freqToKeySet.has(1)) {
      this.freqToKeySet.set(1, new Set());
    }

    // 4. 强制重置minFreq=1(新增节点频率为1,必然是最小)
    this.minFreq = 1;
    this.freqToKeySet.get(1).add(key);
  }

  /**
   * 对外暴露方法:根据key获取缓存值
   * @param {any} key - 缓存键
   * @returns {any} 存在返回值,不存在返回-1
   */
  get(key) {
    // 1. key不存在 → 返回-1
    if (!this.keyToNode.has(key)) {
      return -1;
    }

    // 2. 刷新节点频率(仅更新频率,不传val)
    this.refresh(key);

    // 3. 返回缓存值
    return this.keyToNode.get(key).val;
  }

  /**
   * 对外暴露方法:新增/更新缓存
   * @param {any} key - 缓存键
   * @param {any} val - 缓存值
   */
  put(key, val) {
    // 边界:容量为0时直接返回
    if (this.capacity === 0) return;

    // 1. key已存在 → 更新值并刷新频率
    const hasNode = this.keyToNode.has(key);
    if (hasNode) {
      this.refresh(key, val);
      return;
    }

    // 2. key不存在 → 先判断容量是否已满
    if (this.keyToNode.size === this.capacity) {
      this.evict(); // 容量满 → 淘汰最少使用节点
    }

    // 3. 新增节点
    this.add(key, val);
  }
}

第三步:测试验证

写好代码后,通过测试用例验证核心逻辑,覆盖"新增、访问、淘汰、频率更新"等场景:

JavaScript 复制代码
// 测试用例(验证所有核心逻辑)
function testLFU() {
  const lfu = new LFUCache(2);
  // 1. 新增2个节点(freq=1,minFreq=1)
  lfu.put('a', 1);
  lfu.put('b', 2);
  console.log('get(a):', lfu.get('a')); // 1 → a.freq=2,minFreq=1
  // 2. 新增c → 淘汰b(minFreq=1),add重置minFreq=1
  lfu.put('c', 3);
  console.log('get(b):', lfu.get('b')); // -1(已淘汰)
  console.log('get(c):', lfu.get('c')); // 3 → c.freq=2,minFreq=1
  // 3. 新增d → 淘汰a(minFreq=1),add重置minFreq=1
  lfu.put('d', 4);
  console.log('get(a):', lfu.get('a')); // -1(已淘汰)
  console.log('get(d):', lfu.get('d')); // 4 → d.freq=2,minFreq=1
  // 4. 极端场景:多次访问d,验证minFreq精准更新
  lfu.get('d');
  console.log('多次访问d后minFreq:', lfu.minFreq); // 2 → 旧freq=2的Set为空,minFreq++
}
testLFU();

运行结果符合预期,说明代码逻辑正确。

六、新手高频易错点(面试避坑)

结合实现经验和面试考点,总结7个关键易错点,帮你避开90%的坑:

1. 节点频率更新遗漏

  • 错误:refresh中只计算newFreq,但未执行node.freq = newFreq

  • 后果:节点频率永远为1,minFreq无法正确维护,淘汰逻辑完全失效。

2. minFreq维护方式错误

  • 错误:evict后遍历所有频率找新minFreq(O(n));

  • 优化:利用"evict仅在put新节点时触发"的特性,add直接重置minFreq=1;refresh中旧频率为空时minFreq++。

3. Set操作语法错误

  • 错误:使用remove方法(如this.keyToNode.remove(key));

  • 正确:JS中Map/Set删除用delete方法(this.keyToNode.delete(key))。

4. 可选参数判断错误

  • 错误:用if (val)判断是否传值(val为0/false时误判);

  • 正确:用val !== undefined判断(覆盖所有值类型)。

5. 初始值设置错误

  • 错误:minFreq初始化为Infinity;

  • 正确:初始化为1(新增节点频率必为1,语义更合理)。

6. 新增节点时Set逻辑错误

  • 错误:直接this.freqToKeySet.set(1, new Set([key]))(覆盖原有Set);

  • 正确:先判断Set是否存在,存在则add,不存在则新建。

7. 边界场景遗漏

  • 错误:未处理capacity=0的情况(put操作会无意义执行evict);

  • 正确:put方法开头加if (this.capacity === 0) return

七、LFU vs LRU:核心区别总结

很多初学者会混淆LFU和LRU,这里用表格清晰对比:

维度 LFU(最少使用) LRU(最近最少使用)
核心依据 使用频率(次数) 使用时间(最近)
淘汰规则 先淘汰频率最低的,同频率淘汰最久未用 淘汰最久未使用的
核心数据结构 双哈希表(key→Node + freq→Set) 哈希表+双向链表
频率维护 实时更新频率,维护minFreq 无需频率,维护使用顺序
适用场景 访问频率分布不均(如热点数据) 访问时间分布不均(如最近访问优先)

八、总结

LFU缓存的核心,是"用双哈希表实现O(1)查询+频率分组,用minFreq变量实现O(1)找到最小频率"。整个实现过程,最关键的不是代码本身,而是"从需求出发,拆解问题、选择合适数据结构"的思路。

本文的代码是工业界/面试中的最优版本,语义化封装清晰、边界处理完善、性能拉满(全O(1))。建议你自己动手敲一遍代码,结合测试用例调试,重点理解"双哈希表的配合逻辑"和"minFreq的动态维护",这样面试时无论遇到什么变体,都能轻松应对。

最后记住:LFU的核心不是"Set"(双链表也完全可以),而是"最少使用"的淘汰逻辑------只要能满足O(1)性能,数据结构的选择可以灵活调整,但双哈希表+Set是常规、高效的实现方式。

相关推荐
炫饭第一名2 小时前
速通Canvas指北🦮——变形、渐变与阴影篇
前端·javascript·程序员
Neptune12 小时前
让我带你迅速吃透React组件通信:从入门到精通(上篇)
前端·javascript
小星哥哥2 小时前
JavaScript 动态导入 (Dynamic Imports)
javascript
武子康2 小时前
大数据-241 离线数仓 - 实战:电商核心交易数据模型与 MySQL 源表设计(订单/商品/品类/店铺/支付)
大数据·后端·mysql
流水白开2 小时前
前端设计模式
javascript·面试
SimonKing2 小时前
JetBrains 用户狂喜!这个 AI 插件让 IDE 原地进化成「智能编码助手」
java·后端·程序员
茶杯梦轩2 小时前
从零起步学习RabbitMQ || 第三章:RabbitMQ的生产者、Broker、消费者如何保证消息不丢失(可靠性)详解
分布式·后端·面试
小码哥_常2 小时前
别再乱加exclusion了!Maven依赖冲突有妙解
后端
狂奔小菜鸡2 小时前
Day39 | Java中更灵活的锁ReentrantLock
java·后端·java ee