算法通过村第五关-队列和Hash黄金笔记|LRU的设计与实现

news2025/1/12 12:23:34

文章目录

  • 前言
  • 1. LRU的含义
  • 2. Hash+双向链表实现LRU
  • 总结


前言


提示:我曾如此渴望命运的波澜,到最后才发现:人生最曼妙的风景,竟是内心的淡定从容。 我们层如此盼望世界的认可,到最后才知道:世界是自己,与他人毫无关系。 --杨绛

LRU 是非常经典的问题,而且在常年的算法中也是热门,但是他是存在技巧的,我们这就来一起看看吧。

1. LRU的含义

缓存是应用软件的必备功能之一,在操作系统中,Java里面的Spring、mybatis,redis、mysql等软件中都有自己的内部缓存模块,然而缓存是如何实现的呢?在操作系统的教科书中,我们知道常见的有FIFO、LRU,LFU三种基本方法。FIFO也就是队列方式,但是它不能很好的利用程序局部性特征,缓存效果比较差,一般我们比较推荐使用LRU(最近最少使用)和LFU(最不经常使用的淘汰算法)。LRU是淘汰最长时间没有被使用的页面,LFU是淘汰一段时间内,使用次数最少的页面。

从实现上来说LRU相对更容易一些,而LFU比较麻烦,我们这里重点研究一下LRU的问题,当然这也是一道高频题目,让我们设计一个LRU缓存,该题可以排到算法前3是没有问题的。

参考题目介绍:146. LRU 缓存 - 力扣(LeetCode)

在这里插入图片描述在这里插入图片描述
在这里插入图片描述
百度百科:

LRU(最近最少使用)缓存:LRU_百度百科 (baidu.com)

首先关于什么是LRU,最简单的说法是当内存空间满了,不得不淘汰某些数据时(通常时容量已满),先择最久未呗使用的数据进行淘汰。

这里时做了简化的,题目是让我们实现一个容器固定LRUCache。如果插入数据时,发现容器已满时,则先按照LRU规则进行淘汰数据,再插入新的数据,这种【插入】和【查询】都算一次“使用”。

最近最少使用算法(LRU)是大部分操作系统为最大化页面命中率而广泛使用的一种页面置换算法。

该算法的思路是:

发生缺页中断时,选择未使用时间最长的页面置换出去。假设内存只能容纳3个页大小,按照7 0 1 2 0 3 0 4 的次序访问页面。假设内存按照栈的方式类描述访问时间,在上面时最近访问的,在下面的最远时间访问的,LRU就是这样工作的:
在这里插入图片描述
尝试了工作流程,那么我们需要怎么来实现它呢,有什么好的思路呢?定义一个数组,然后根据上面的写上一些规则吗?我估计这个要实现起来会非常难,即使写出来了,也非常复杂,超时等。那我们应该怎么做,这里告诉你标准的答案:Hash+双向链表。

2. Hash+双向链表实现LRU

目前公认最合理的方法,Hash+双向链表。没想到吧?那我们想一下为什么这么设计,我们要怎么实现。(展示💕

  • Hash的作用是:采用O(1)访问元素,哈希表就是普通的哈希映射(HashMap),通过缓存数据的键映射到其双向链表的位置。Hash里的数据结构是key-value,value就是我们自己封装好的node,key就是键值,也就是在Hash的地址
  • 双向链表的作用是:根据访问情况对元素进行排序。双向链表按照被使用的顺序存储这些键值对,靠近头部的键值对是最近使用的,而靠近尾部的键值对是最久未使用的。

这样以来,我们要确定元素的位置就可以直接访问哈希表就可以,找出缓存项的双向链表中的位置,随后将其移动到双向链表的头部,即可以实现O(1)的时间内完成get操作或者put操作。具体的方法放在下面💡:

  • 对于get操作,首先判断key是否存在:
    • 如果key不存在,则返回-1
    • 如果key存在,则key对应的节点是最近被使用的节点。通过哈希表定位到该节点在双向链表中的位置,并将其移动到双向链表的头部,最后返回该节点的值
  • 对于put操作,首先要判断key是否存在:
    • 如果key不存在,使用key和value创建一个新的节点,在双向链表的头部添加该节点,并将key和该节点添加进哈希表中。然后判断双向链表的节点书是否超出容量,如果超出容量,则删除双向链表的尾节点,并删除哈希表中的对应项
    • 如果key存在,则于get操作类似,先通过哈希定位,再将对应的节点更新为value,并将该节点移到双向链表的头部。

上述的各项操作中,访问哈希表的时间复杂度为O(1),在双向链表的头部添加节点、在双向链表的尾部删除节点的复杂度也是O(1)。而将一个节点移到双向链表的头部,可以分成【删除该节点】和【在双向链表的头部添加元素】两部操作,都可以在O(1)的时间内完成。

同时为了方便操作,在双向链表的实现中,使用伪头部(dummy head)和伪尾部(dummy tail)标记界限,这样在添加节点和删除节点的时候就不需要检查相邻的节点是否存在。

说了这么多,还是看图更方便一些:

双向链表的实现中,使用伪头部(dummy head)和伪尾部(dummy tail)标记界限,这样在添加节点和删除节点的时候就不需要检查相邻的节点是否存在。

我们先看这个容量为3 的例子,首先缓存了1,此时结构如图所示,之后缓存2,3结构如b所示:

在这里插入图片描述
之后4再进入,此时容量已经不够了,只能再最远未使用的元素1删掉,然后将4插入到链表头部,如此就变成了图c的样子。

如果接下类又访问了一次2,会怎么样呢?我们会将2移动到链表的首位,也就是下面d的样子。
在这里插入图片描述
之后存储5呢?此时将tail指向的3删除,然后将5插入到链表的头部,也就是如上图的e的样子。

上面的方案图示很容易实现,但是这里我们强调几个点哈🥰:

  1. 假设容量没有满,可以直接将元素插入到链表头部就可以
  2. 如果容量满了,就要采取策略,新的元素到来,则tail指向的表尾元素删除就行
  3. 假设要访问的元素已经存在,则将此元素先从链表中删除,再插入到表头就行了。

我们再看一些Hash的操作:

  1. Hash没有容量的限制,凡是被访问的元素都会再Hash中有标记,key就是我们要查询的条件,而value就是链表的节点的引用,可以不访问链表直接定位到某个元素节点,然后执行我们上面提到的方法来删除对应的元素节点。
  2. 这里的双向链表的删除好理解,那HashMap中的删除要如何处理呢?其实就是将node变为null。这样get(key)的时候就会返回null,也就实现了删除的功能。

总结:

上述的各项操作中,访问哈希表的时间复杂度为O(1),在双向链表的头部添加节点、在双向链表的尾部删除节点的复杂度也是O(1)。而将一个节点移到双向链表的头部,可以分成【删除该节点】和【在双向链表的头部添加元素】两部操作,都可以在O(1)的时间内完成。

代码展示看效果
难度:⭐⭐⭐⭐⭐

import java.util.HashMap;
import java.util.Map;

public class LRUCache {

    // 写一个双向链表的结构
    class DLinkedNode {
        int key;
        int value;
        // 前后引用
        DLinkedNode prev;
        DLinkedNode next;

        public DLinkedNode() {

        }

        public DLinkedNode(int _key, int _value) {
            this.key = _key;
            this.value = _value;
        }
    }

    // 需要一个HashMap
    private Map<Integer, DLinkedNode> cache = new HashMap<Integer, DLinkedNode>();
    // 需要一个size
    private int size;
    // 需要一个容量
    private int capacity;
    // 需要头尾伪节点
    private DLinkedNode head, tail;

    public LRUCache(int capacity) {
        this.size = 0;
        this.capacity = capacity;
        // 使用伪 头节点和尾节点
        head = new DLinkedNode();
        tail = new DLinkedNode();
        // 相互联系
        head.next = tail;
        tail.prev = head;
    }

    public int get(int key) {
        DLinkedNode node = cache.get(key);
        if (node == null) {
            return -1;
        }
        // 这里如果key存在,先通过哈希表定位在移动到头部
        moveToHead(node);
        return node.value;
    }

    public void put(int key, int value) {
        DLinkedNode node = cache.get(key);
        if (node == null) {
            // key 不存在就创建一个新的节点
            DLinkedNode newNode = new DLinkedNode(key, value);
            // 添加到哈希表中
            cache.put(key, newNode);
            // 添加到双向链表的头部
            addToHead(newNode);
            ++size;
            if (size > capacity) {
                // 超出容量,对应策略 删除双向链表的尾部节点
                DLinkedNode tail = removeTail();
                // 删除对应哈希表中的项
                cache.remove(tail.key);
                --size;
            }
        } else {
            // 如果key存在  先通过hash定位,再修改value,并且将它移动到头部
            node.value = value;
            moveToHead(node);
        }
    }

    private void addToHead(DLinkedNode node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }

    private void removeNode(DLinkedNode node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    private void moveToHead(DLinkedNode node) {
        removeNode(node);
        addToHead(node);
    }

    private DLinkedNode removeTail() {
        DLinkedNode res = tail.prev;
        removeNode(res);
        return res;
    }


    public static void main(String[] args) {
        LRUCache lRUCache = new LRUCache(2);
        lRUCache.put(1, 1); // 缓存是 {1=1}
        lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
        System.out.println(lRUCache.get(1));    // 返回 1
        lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3}
        System.out.println(lRUCache.get(2));    // 返回 -1 (未找到)
        lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
        System.out.println(lRUCache.get(1));    // 返回 -1 (未找到)
        System.out.println(lRUCache.get(3));    // 返回 3
        System.out.println(lRUCache.get(4));    // 返回 4
    }
}

总结

提示:理解双向链表;了解LRU缓存机制;Hash+双向链表的思考

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

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

相关文章

01-Kafaka

1、Kafka 2 的安装与配置 1、上传kafka_2.12-1.0.2.tgz到服务器并解压&#xff1a; tar -zxf kafka_2.12-1.0.2.tgz -C /opt 2、配置环境变量并更新&#xff1a; 编辑profile配置文件&#xff1a; vim /etc/profile #设置kafka的环境变量export KAFKA_HOME/opt/kafka_2.1…

【ESP32--FreeRTOS 任务间的同步与通信】

本文主要记录【ESP32–FreeRTOS 任务间的同步与通信】的学习记录&#xff0c;邮件&#xff0c;信号量&#xff0c;事件组的使用和优缺点以及应用场景 &#x1f4cb; 个人简介 &#x1f496; 作者简介&#xff1a;大家好&#xff0c;我是喜欢记录零碎知识点的小菜鸟。&#x1f6…

软件测试之随机测试

目录 一、作随机测试之前的一些前提条件 二、随机测试功能点的选取 三、功能点的随机测试 在软件测试中除了根据测试用例和测试说明书进行功能测试外&#xff0c;还需要进行随机测试&#xff08;Ad-hoc testing&#xff09;&#xff0c;随机测试是没有书面测试用例、记录期望…

【算法】插入排序

插入排序 插入排序代码实现代码优化 排序&#xff1a; 排序&#xff0c;就是使一串记录&#xff0c;按照其中的某个或某些关键字的大小&#xff0c;递增或递减的排列起来的操作。 稳定性&#xff1a; 假定在待排序的记录序列中&#xff0c;存在多个具有相同的关键字的记录&…

【算法】归并排序 详解

归并排序 详解 归并排序代码实现1. 递归版本2. 非递归版本 排序&#xff1a; 排序&#xff0c;就是使一串记录&#xff0c;按照其中的某个或某些关键字的大小&#xff0c;递增或递减的排列起来的操作。 稳定性&#xff1a; 假定在待排序的记录序列中&#xff0c;存在多个具有相…

dpkt 处理linux cooked capture

这里写自定义目录标题 欢迎使用Markdown编辑器新的改变功能快捷键合理的创建标题&#xff0c;有助于目录的生成如何改变文本的样式插入链接与图片如何插入一段漂亮的代码片生成一个适合你的列表创建一个表格设定内容居中、居左、居右SmartyPants 创建一个自定义列表如何创建一个…

CUDA小白 - NPP(4) 图像处理 Data Exchange and Initialization(1)

cuda小白 原始API链接 NPP GPU架构近些年也有不少的变化&#xff0c;具体的可以参考别的博主的介绍&#xff0c;都比较详细。还有一些cuda中的专有名词的含义&#xff0c;可以参考《详解CUDA的Context、Stream、Warp、SM、SP、Kernel、Block、Grid》 常见的NppStatus&#xf…

QT 初识多线程

1.QThread线程基础 QThread是Qt线程中有一个公共的抽象类&#xff0c;所有的线程类都是从QThread抽象类中派生的&#xff0c;需要实现QThread中的虚函数run(),通过start()函数来调用run函数。 void run&#xff08;&#xff09;函数是线程体函数&#xff0c;用于定义线程的功能…

借助CIFAR10模型结构理解卷积神经网络及Sequential的使用

CIFAR10模型搭建 CIFAR10模型结构 0. input : 332x32&#xff0c;3通道32x32的图片 --> 特征图(Feature maps) : 3232x32即经过32个35x5的卷积层&#xff0c;输出尺寸没有变化&#xff08;有x个特征图即有x个卷积核。卷积核的通道数与输入的通道数相等&#xff0c;即35x5&am…

01-从JDK源码级别剖析JVM类加载机制

1. 类加载运行全过程 当我们用java命令运行某个类的main函数启动程序时&#xff0c;首先需要通过类加载器把主类加载到JVM。 public class Math {public static final int initData 666;public static User user new User();public int compute() { //一个方法对应一块栈帧…

整理mongodb文档:事务(一)

个人博客 整理mongodb文档:事务(一) 原文链接&#xff0c;个人博客 求关注&#xff0c;本文主要讲下怎么在mongose下使用事务&#xff0c;建议电脑端看 文章概叙 本文的开发环境为Nodejs&#xff0c;在‘单机模式’讲解最基本的事务概念。并没有涉及分片以及集群&#xff0…

《向量数据库指南》——AI原生向量数据库Milvus Cloud 2.3新功能

New Feature Upsert 功能 支持用户通过 upsert 接口更新或插入数据。已知限制,自增 id 不支持 upsert;upsert 是内部实现是 delete + insert所以性能上会有一定损耗,如果明确知道是写入数据的场景请继续使用 insert。 Range Search 功能 支持用户通过输入参数指定 search 的…

TortoiseGit设置作者信息和用户名、密码存储

前言 Git 客户端每次与服务器交互&#xff0c;都需要输入密码&#xff0c;但是我们可以配置保存密码&#xff0c;只需要输入一次&#xff0c;就不再需要输入密码。 操作说明 在任意文件夹下&#xff0c;空白处&#xff0c;鼠标右键点击 在弹出菜单中按照下图点击 依次点击下…

LLVM 与代码混淆技术

项目源码 什么是 LLVM LLVM 计划启动于2000年&#xff0c;开始由美国 UIUC 大学的 Chris Lattner 博士主持开展&#xff0c;后来 Apple 也加入其中。最初的目的是开发一套提供中间代码和编译基础设施的虚拟系统。 LLVM 命名最早源自于底层虚拟机&#xff08;Low Level Virtu…

LEARN GIT

概念 基础概念 本地电脑 代码区&#xff1a;工作区间&#xff0c;放代码的地方 暂存区&#xff1a;git所管理的暂存区域 本地仓库&#xff1a;git所管理的本机的硬盘区域 远程电脑 远程仓库&#xff1a;github、gitee 代码提交管理的过程 代码区------->暂存区-------&…

关于 RK3568的linux系统killed用户应用进程(用户现象为崩溃) 的解决方法

若该文为原创文章&#xff0c;转载请注明原文出处 本文章博客地址&#xff1a;https://hpzwl.blog.csdn.net/article/details/132710642 红胖子网络科技博文大全&#xff1a;开发技术集合&#xff08;包含Qt实用技术、树莓派、三维、OpenCV、OpenGL、ffmpeg、OSG、单片机、软硬…

模拟Proactor模式实现 I/O 处理单元

编写main.cpp 1.socket通信 服务器应用程序可以通过读取和写入 Socket 对象 来监听来自客户端的请求并向客户端返回响应 #define MAX_FD 65536 // 最大的文件描述符个数 #define MAX_EVENT_NUMBER 10000 // 监听的最大的事件数量 // 添加信号捕捉 void addsig(int sig, …

【MySQL】索引 详解

索引 详解 一. 概念二. 作用三. 使用场景四. 操作五. 索引背后的数据结构B-树B树聚簇索引与非聚簇索引 一. 概念 索引是一种特殊的文件&#xff0c;包含着对数据表里所有记录的引用指针。可以对表中的一列或多列创建索引&#xff0c;并指定索引的类型&#xff0c;各类索引有各…

机器学习的特征工程

字典特征提取 def dict_demo():"""字典特征提取:return:"""data [{city: 北京, temperature: 100}, {city: 上海, temperature: 60}, {city: 深圳, temperature: 30}]# data [{city:[北京,上海,深圳]},{temperature:["100","6…

《机器人学一(Robotics(1))》_台大林沛群 第 5 周【机械手臂 轨迹规划】 Quiz 5

我又行了&#xff01;&#x1f923; 求解的 位置 可能会有 变动&#xff0c;根据求得的A填写相应值即可。注意看题目。 coursera链接 文章目录 第1题 Cartesian space求解 题1-3 的 Python 代码 第2题第3题第4题 Joint space求解 题4-6 的 Python 代码 第5题第6题其它可参考代…