LFU算法的详细介绍与实现

news2024/11/23 12:14:12

LRU 算法的淘汰策略是 Least Recently Used,也就是每次淘汰那些最久没被使用的数据;而 LFU 算法的淘汰策略是 Least Frequently Used,也就是每次淘汰那些使用次数最少的数据。

LRU 算法的核心数据结构是使用哈希链表 LinkedHashMap(HashMap + LinkedList),首先借助链表的有序性使得链表元素维持插入顺序,同时借助哈希映射的快速访问能力使得我们可以在 O(1) 时间访问链表的任意元素。

从实现难度上来说,LFU 算法的难度大于 LRU 算法,因为 LRU 算法相当于把数据按照时间排序,这个需求借助链表很自然就能实现,你一直从链表头部加入元素的话,越靠近头部的元素就是新的数据,越靠近尾部的元素就是旧的数据,我们进行缓存淘汰的时候只要简单地将尾部的元素淘汰掉就行了。

而 LFU 算法相当于是把数据按照访问频次进行排序,这个需求恐怕没有那么简单,而且还有一种情况,如果多个数据拥有相同的访问频次,我们就得删除最早插入的那个数据。也就是说 LFU 算法是淘汰访问频次最低的数据,如果访问频次最低的数据有多条,需要淘汰最旧的数据。

所以说 LFU 算法是要复杂很多的,而且经常出现在面试中,因为 LFU 缓存淘汰算法在工程实践中经常使用,也有可能是应该 LRU 算法太简单了。不过话说回来,这种著名的算法的套路都是固定的,关键是由于逻辑较复杂,不容易写出漂亮且没有 bug 的代码。

那么本文就带你拆解 LFU 算法,自顶向下,逐步求精,就是解决复杂问题的不二法门。

一、算法描述

要求你写一个类,接受一个 capacity 参数,实现 get 和 put 方法:

class LFUCache {
    // 构造容量为 capacity 的缓存
    public LFUCache(int capacity) {}
    // 在缓存中查询 key
    public int get(int key) {}
    // 将 key 和 val 存入缓存
    public void put(int key, int val) {}
}

get(key) 方法会去缓存中查询键 key,如果 key 存在,则返回 key 对应的 val,否则返回 -1。

put(key, value) 方法插入或修改缓存。如果 key 已存在,则将它对应的值改为 val;如果 key 不存在,则插入键值对 (key, val)。

当缓存达到容量 capacity 时,则应该在插入新的键值对之前,删除使用频次(后文用 freq 表示)最低的键值对。如果 freq 最低的键值对有多个,则删除其中最旧的那个。

// 构造一个容量为 2 的 LFU 缓存
LFUCache cache = new LFUCache(2);

// 插入两对 (key, val),对应的 freq 为 1
cache.put(1, 10);
cache.put(2, 20);

// 查询 key 为 1 对应的 val
// 返回 10,同时键 1 对应的 freq 变为 2
cache.get(1);

// 容量已满,淘汰 freq 最小的键 2
// 插入键值对 (3, 30),对应的 freq 为 1
cache.put(3, 30);   

// 键 2 已经被淘汰删除,返回 -1
cache.get(2);     

二、思路分析

一定先从最简单的开始,根据 LFU 算法的逻辑,我们先列举出算法执行过程中的几个显而易见的事实:

  1. 调用 get(key) 方法时,要返回该 key 对应的 val。
  2. 只要用 get 或者 put 方法访问一次某个 key,该 key 的 freq 就要加一。
  3. 如果在容量满了的时候进行插入,则需要将 freq 最小的 key 删除,如果最小的 freq 对应多个
    key,则删除其中最旧的那一个。

好的,我们希望能够在 O(1) 的时间内解决这些需求,可以使用基本数据结构来逐个击破:

  1. 使用一个 HashMap 存储 key 到 val 的映射,就可以快速计算 get(key)。
    HashMap<Integer, Integer> keyToVal;
  2. 使用一个 HashMap 存储 key 到 freq 的映射,就可以快速操作 key 对应的 freq。
    HashMap<Integer, Integer> keyToFreq;
  3. 这个需求应该是 LFU 算法的核心,所以我们分开说。
    3.1. 首先,肯定是需要 freq 到 key 的映射,用来找到 freq 最小的 key。
    3.2. 将 freq 最小的 key 删除,那你就得快速得到当前所有 key 最小的 freq 是多少。想要时间复杂度 O(1) 的话,肯定不能遍历一遍去找,那就用一个变量 minFreq 来记录当前最小的 freq 吧。
    3.3.可能有多个 key 拥有相同的 freq,所以 freq 对 key 是一对多的关系,即一个 freq 对应一个 key 的列表。
    3.4. 希望 freq 对应的 key 的列表是存在时序的,便于快速查找并删除最旧的 key。
    3.5. 希望能够快速删除 key 列表中的任何一个 key,因为如果频次为 freq 的某个 key 被访问,那么它的频次就会变成 freq+1,就应该从 freq 对应的 key 列表中删除,加到 freq+1 对应的 key 的列表中。

HashMap<Integer, LinkedHashSet> freqToKeys;
int minFreq = 0;

介绍一下这个 LinkedHashSet,它满足我们 3.3,3.4,3.5 这几个要求。你会发现普通的链表 LinkedList 能够满足 3.3,3.4 这两个要求,但是由于普通链表不能快速访问链表中的某一个节点,所以无法满足 3.5 的要求。

LinkedHashSet 顾名思义,是链表和哈希集合的结合体。链表不能快速访问链表节点,但是插入元素具有时序;哈希集合中的元素无序,但是可以对元素进行快速的访问和删除。

那么,它俩结合起来就兼具了哈希集合和链表的特性,既可以在 O(1) 时间内访问或删除其中的元素,又可以保持插入的时序,高效实现 3.5 这个需求。

综上,我们可以写出 LFU 算法的基本数据结构:

class LFUCache {
    // key 到 val 的映射,我们后文称为 KV 表
    HashMap<Integer, Integer> keyToVal;
    // key 到 freq 的映射,我们后文称为 KF 表
    HashMap<Integer, Integer> keyToFreq;
    // freq 到 key 列表的映射,我们后文称为 FK 表
    HashMap<Integer, LinkedHashSet<Integer>> freqToKeys;
    // 记录最小的频次
    int minFreq;
    // 记录 LFU 缓存的最大容量
    int cap;

    public LFUCache(int capacity) {
        keyToVal = new HashMap<>();
        keyToFreq = new HashMap<>();
        freqToKeys = new HashMap<>();
        this.cap = capacity;
        this.minFreq = 0;
    }

    public int get(int key) {}

    public void put(int key, int val) {}

}

三、代码框架

LFU 的逻辑不难理解,但是写代码实现并不容易,因为你看我们要维护 KV 表,KF 表,FK 表三个映射,特别容易出错。对于这种情况,labuladong 教你三个技巧:

  1. 不要企图上来就实现算法的所有细节,而应该自顶向下,逐步求精,先写清楚主函数的逻辑框架,然后再一步步实现细节。

  2. 搞清楚映射关系,如果我们更新了某个 key 对应的 freq,那么就要同步修改 KF 表和 FK 表,这样才不会出问题。

  3. 画图,画图,画图,重要的话说三遍,把逻辑比较复杂的部分用流程图画出来,然后根据图来写代码,可以极大减少出错的概率。

下面我们先来实现 get(key) 方法,逻辑很简单,返回 key 对应的 val,然后增加 key 对应的 freq:

public int get(int key) {
    if (!keyToVal.containsKey(key)) {
        return -1;
    }
    // 增加 key 对应的 freq
    increaseFreq(key);
    return keyToVal.get(key);
}

增加 key 对应的 freq 是 LFU 算法的核心,所以我们干脆直接抽象成一个函数 increaseFreq,这样 get 方法看起来就简洁清晰了对吧。

下面来实现 put(key, val) 方法,逻辑略微复杂,我们直接画个图来看:
在这里插入图片描述

这图就是随手画的,不是什么正规的程序流程图,但是算法逻辑一目了然,看图可以直接写出 put 方法的逻辑:

public void put(int key, int val) {
    if (this.cap <= 0) return;

    /* 若 key 已存在,修改对应的 val 即可 */
    if (keyToVal.containsKey(key)) {
        keyToVal.put(key, val);
        // key 对应的 freq 加一
        increaseFreq(key);
        return;
    }

    /* key 不存在,需要插入 */
    /* 容量已满的话需要淘汰一个 freq 最小的 key */
    if (this.cap <= keyToVal.size()) {
        removeMinFreqKey();
    }

    /* 插入 key 和 val,对应的 freq 为 1 */
    // 插入 KV 表
    keyToVal.put(key, val);
    // 插入 KF 表
    keyToFreq.put(key, 1);
    // 插入 FK 表
    freqToKeys.putIfAbsent(1, new LinkedHashSet<>());
    freqToKeys.get(1).add(key);
    // 插入新 key 后最小的 freq 肯定是 1
    this.minFreq = 1;
}

increaseFreq 和 removeMinFreqKey 方法是 LFU 算法的核心,我们下面来看看怎么借助 KV 表,KF 表,FK 表这三个映射巧妙完成这两个函数。

四、LFU 核心逻辑

首先来实现 removeMinFreqKey 函数:

private void removeMinFreqKey() {
    // freq 最小的 key 列表
    LinkedHashSet<Integer> keyList = freqToKeys.get(this.minFreq);
    // 其中最先被插入的那个 key 就是该被淘汰的 key
    int deletedKey = keyList.iterator().next();
    /* 更新 FK 表 */
    keyList.remove(deletedKey);
    if (keyList.isEmpty()) {
        freqToKeys.remove(this.minFreq);
        // 问:这里需要更新 minFreq 的值吗?
    }
    /* 更新 KV 表 */
    keyToVal.remove(deletedKey);
    /* 更新 KF 表 */
    keyToFreq.remove(deletedKey);
}

删除某个键 key 肯定是要同时修改三个映射表的,借助 minFreq 参数可以从 FK 表中找到 freq 最小的 keyList,根据时序,其中第一个元素就是要被淘汰的 deletedKey,操作三个映射表删除这个 key 即可。

但是有个细节问题,如果 keyList 中只有一个元素,那么删除之后 minFreq 对应的 key 列表就为空了,也就是 minFreq 变量需要被更新。如何计算当前的 minFreq 是多少呢?

实际上没办法快速计算 minFreq,只能线性遍历 FK 表或者 KF 表来计算,这样肯定不能保证 O(1) 的时间复杂度。

但是,其实这里没必要更新 minFreq 变量,因为你想想 removeMinFreqKey 这个函数是在什么时候调用?在 put 方法中插入新 key 时可能调用。而你回头看 put 的代码,插入新 key 时一定会把 minFreq 更新成 1,所以说即便这里 minFreq 变了,我们也不需要管它。

下面来实现 increaseFreq 函数:

private void increaseFreq(int key) {
    int freq = keyToFreq.get(key);
    /* 更新 KF 表 */
    keyToFreq.put(key, freq + 1);
    /* 更新 FK 表 */
    // 将 key 从 freq 对应的列表中删除
    freqToKeys.get(freq).remove(key);
    // 将 key 加入 freq + 1 对应的列表中
    freqToKeys.putIfAbsent(freq + 1, new LinkedHashSet<>());
    freqToKeys.get(freq + 1).add(key);
    // 如果 freq 对应的列表空了,移除这个 freq
    if (freqToKeys.get(freq).isEmpty()) {
        freqToKeys.remove(freq);
        // 如果这个 freq 恰好是 minFreq,更新 minFreq
        if (freq == this.minFreq) {
            this.minFreq++;
        }
    }
}

更新某个 key 的 freq 肯定会涉及 FK 表和 KF 表,所以我们分别更新这两个表就行了。

和之前类似,当 FK 表中 freq 对应的列表被删空后,需要删除 FK 表中 freq 这个映射。如果这个 freq 恰好是 minFreq,说明 minFreq 变量需要更新。

能不能快速找到当前的 minFreq 呢?这里是可以的,因为我们刚才把 key 的 freq 加了 1 嘛,所以 minFreq 也加 1 就行了。

至此,经过层层拆解,LFU 算法就完成了。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/744499.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

spring全家桶(一):如何创建springboot项目

一.如何创建springboot项目 1.通过官网网站创建项目&#xff1a;https://start.spring.io/ 2.eclipse通过插件Spring Tool Suite(sts)创建项目 3.idea默认已经有spring插件 二.程序入口 SpringBootApplication public class HelloApplication {public static void main(Strin…

Linux--获取当前进程的父进程PID(即PPID)

方法一&#xff1a;编程法 #include <sys/types.h>pid_t ppidgetppid(); 方法二&#xff1a;指令法 ps axj | head -1 && ps axj | grep 当前进程PID 注&#xff1a;你会发现&#xff0c;每次查看当前进程PID时&#xff0c;PID都不相同&#xff0c;但是它的P…

设计模式——原型模式

原型模式比较简单&#xff0c;本质就是将一个设置好一部分公共属性的对象进行克隆&#xff0c;产生出大量的对象&#xff0c;再对每个对象进行相应的个性化处理需要注意的是&#xff1a;对象克隆时&#xff0c;如果其成员变量中存在引用类型&#xff08;数组、引用对象等&#…

《人工智能.一种现代方法》原版精读思维导图-第二章

目录 书籍 相关 2. Intelligent Agents 2.1 Agents and Environments 2.2 Good Behavior: The Concept of Rationality 2.3 The Nature of Environments 2.4 The Structure of Agents summary 书籍 人工智能.一种现代方法 Artificial Intelligence. The Modern Appro…

基于LLM大模型开发Web App生成器

随着越来越多的代码生成模型公开可用&#xff0c;现在可以以我们以前无法想象的方式进行文本到网络甚至文本到应用程序。 本教程介绍了一种通过流式传输和渲染内容来生成 AI Web 内容的直接方法。 推荐&#xff1a;用 NSDT设计器 快速搭建可编程3D场景。 1、在 Node 应用程序中…

13 个最佳免费 PDF 编辑器清单

您正在寻找一款真正免费的 PDF 编辑器&#xff0c;不仅可以编辑和添加文本&#xff0c;还可以更改图像、添加您自己的图形、签署您的名字、填写表格等等&#xff1f;您来对地方了&#xff1a;我研究了这些类型的应用程序&#xff0c;以得出您正在寻找的内容的列表。 其中一些是…

element 表格套输入框

实现效果&#xff1a; 编辑&#xff1a; 查看&#xff1a;点击平台补贴展示弹窗 <el-table:data"tableData"border:header-cell-style"{background:#D7D7D7,color:#000}"style"width: 100%"row-dblclick"dbclick":cell-class-name…

c++中的时间处理(3)与sleep相关的时间函数

1、Sleep()函数 头文件&#xff1a; Windows下为&#xff1a;windows.h Linux下为&#xff1a;unistd.h 注意&#xff1a; &#xff08;1&#xff09;Sleep是区分大小写的&#xff0c;有的编译器是大写&#xff0c;有的是小写。 &#xff08;2&#xff09;Sleep括号里的时间&…

ELK中grok插件、mutate插件、multiline插件、date插件的相关配置

目录 grok 正则捕获插件 自定义表达式调用 mutate 数据修改插件 示例&#xff1a; ●将字段old_field重命名为new_field ●添加字段 ●将字段删除 ●将filedName1字段数据类型转换成string类型&#xff0c;filedName2字段数据类型转换成float类型 ●将filedName字段中…

Nginx调优和探活配置

Nginx基本参数优化 1 . worker_processes 1; # 指定 Nginx 要开启的进程数&#xff0c;结尾的数字就是进程的个数&#xff0c;可以为 auto。 这个参数调整的是 Nginx 服务的 worker 进程数&#xff0c;Nginx 有 Master 进程和 worker 进程之分&#xff0c;Master 为管理进程、真…

Web常见请求参数接收的总结

首先本文所展示的参数接收的总结&#xff0c;都是基于Spring Boot框架而言的&#xff0c;不是一般传统方式使用request对象来完成参数的接收 简单参数的接收 对于简单参数的接收&#xff0c;在Spring Boot框架中&#xff0c;在Controller类中设置对应的处理方式时&#xff0c;…

SpringMVC 中的数据验证如何使用 @Valid 注解

SpringMVC 中的数据验证如何使用 Valid 注解 在 Web 开发中&#xff0c;数据验证是一个非常重要的环节。它可以确保数据的合法性和正确性&#xff0c;保护系统不受到恶意攻击或用户误操作的影响。在 SpringMVC 中&#xff0c;我们可以使用 Valid 注解来实现数据验证。 Valid 注…

排序算法第二辑——选择排序

一&#xff0c;选择排序 选择排序算是简单排序中的渣渣&#xff0c;这种算法基本上是没有什么用处的。但是作为一个初学者&#xff0c;我又必须要会写这种算法。这种算法的实现实现思想和它的名字一样&#xff0c;就是在一个范围内选择最大或者最小的数据然后再交换数据实现排序…

山西电力市场日前价格预测【2023-07-13】

日前价格预测 预测明日&#xff08;2023-07-13&#xff09;山西电力市场全天平均日前电价为342.42元/MWh。其中&#xff0c;最高日前电价为403.93元/MWh&#xff0c;预计出现在00: 15。最低日前电价为282.08元/MWh&#xff0c;预计出现在24: 00。 价差方向预测 1&#xff1a;实…

为什么大部分游戏公司仍在坚持使用SVN?

游戏开发是一个复杂的过程&#xff0c;涉及多个开发人员的协作和大量的代码、艺术资源以及其他项目文件。版本控制系统在游戏开发中起着至关重要的作用。它提供了对项目代码和文件的管理、跟踪和协作能力&#xff0c;对于保持项目的稳定性、团队协作的顺畅性以及追踪项目历史和…

《微服务架构设计模式》第七章 在微服务架构中实现查询

内容总结自《微服务架构设计模式》 在微服务架构中实现查询 一、使用API组合模式查询1、简介2、设计形式3、弊端 二、使用CQRS进行查询1、简介2、利弊 三、CQRS架构1、设计2、存储3、数据访问模块 四、总结 一、使用API组合模式查询 1、简介 这是最简单的方法&#xff0c;应尽…

WebDAV之π-Disk派盘 + PDF Expert

PDF Expert 支持WebDAV方式连接π-Disk派盘。 PDF Expert是一款macOS上的办公软件,它具有专业的PDF编辑功能,可以快速从邮件、网页支持PDF打开,支持用户进行阅读、批注等功能,用户可以直接在PDF上进行编辑文字图片,表单文档、创建笔记、添加书单等自定义使用,大大提高工…

手写JAVA线程池

前言 手写一个简单的java线程池&#xff1a;重点关注&#xff0c;如何确保一直有运行的线程&#xff1f;如何确保线程消费提交的任务信息&#xff1f;。一直保存有运行的线程底层使用的是死循环。使用消息队列确保信息的提交和消费。消息队列使用先进先出原则。 步骤 线程池…

漏洞复现 || OpenSNS远程命令执行漏洞

免责声明 技术文章仅供参考&#xff0c;任何个人和组织使用网络应当遵守宪法法律&#xff0c;遵守公共秩序&#xff0c;尊重社会公德&#xff0c;不得危害网络安全&#xff0c;不得利用网络从事危害国家安全、荣誉和利益&#xff0c;未经授权请勿利用文章中的技术资料对任何计…

力扣 | 二分查找模板

力扣&#xff1a;二分查找 文章目录 &#x1f4da;二分查找&#x1f4da;模板I&#x1f449;x 的平方根&#x1f449;猜数字大小&#x1f449;搜索旋转排序数组 &#x1f4da;模板II&#x1f449;第一个错误的版本&#x1f449;寻找峰值 &#x1f4da;模板III&#x1f449;在排…