从 LinkedHashMap 源码到手撕 LRU 缓存

news2025/3/3 5:43:00

大家好,我是 方圆。最近在刷 LeetCode 上LRU缓存的题目,发现答案中有 LinkedHashMap 和自己定义双向链表的两种解法,但是我对 LinkedHashMap 相关源码并不清楚,所以准备学习和记录一下。如果大家想要找刷题路线的话,可以参考 Github: LeetCode。

LRU(Least Recently Used),即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。

1. LinkedHashMap 源码

LinkedHashMap 继承了 HashMap,并使用双向链表对所有的 entry 进行管理,使得这些节点能够按照 插入顺序访问(access)顺序 来排列,并且节点的添加和移除 时间复杂度为 O(1)

顺序的模式通过字段 accessOrder 来定义,为 false 时表示插入顺序,否则为访问顺序。LinkedHashMap 中能够定义顺序模式的构造方法如下:

public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) {
    super(initialCapacity, loadFactor);
    this.accessOrder = accessOrder;
}

需要注意的是,按照插入顺序排列的 LinkedHashMap,如果 将其中已有的 key 再重新插入到 map 中,则它的节点顺序不会受到影响,我们来具体看一下源码:

LinkedHashMap 调用 put 方法时会执行 HashMap 中的 putVal 方法,关键的代码部分如下:

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        // ...
        else {
            // ...

            // map 中已经存在了这个 key
            if (e != null) { 
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                // 重点关注这里
                afterNodeAccess(e);
                return oldValue;
            }
        }

        // ...
    }

当 map 中已有该 key 时,会执行上述逻辑,注意其中的 afterNodeAccess 方法,它是定义在 HashMap 中的钩子方法,LinkedHashMap 对该方法做了实现,如下:

    // 将 节点 移动到末尾
    void afterNodeAccess(Node<K,V> e) {
        LinkedHashMap.Entry<K,V> last;
        // 需要满足是访问顺序排列和当前节点不是尾节点的条件
        if (accessOrder && (last = tail) != e) {
            // p 为当前节点,b 为 p 的前驱节点,a 为 p 的后继节点
            LinkedHashMap.Entry<K,V> p =
                (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
            // p 作为新的尾节点,after 指针为 null
            p.after = null;
            // 处理 p 的前驱节点 b,为空的话后继节点为新的头节点
            if (b == null)
                head = a;
            else
                // 否则 b 的 after 指针指向 p 的后继节点 a
                b.after = a;
            // 处理 p 的后继节点 a,不为空的话 a 的前驱节点为 b
            if (a != null)
                a.before = b;
            else
                // 这个 else 条件与当前节点 p 不是尾节点的条件相悖,理论上 a 节点不为空
                last = b;
            // 空链表会进入到这里,将第一插入的 p 节点作为头节点
            if (last == null)
                head = p;
            else {
                // p 节点作为新的尾节点,那么它的前驱节点是原尾节点 last
                p.before = last;
                // 原尾节点 last 的后继节点为 p
                last.after = p;
            }
            // tail 尾节点指针指向 p
            tail = p;
            ++modCount;
        }
    }

我们可以发现在判断条件 if (accessOrder && (last = tail) != e) 中,插入顺序 accessOrder 为 false,不会执行任何逻辑,所以重新插入已有的 key 不改变节点的顺序。当 accessOrder 为 true 时,即为访问顺序时,会将该节点移动到尾节点处。

LRU 算法需要通过访问顺序来实现,所以我们需要指定 accessOrder 为 True。如果需要指定 LRU 缓存的容量(超过容量将最老的节点移除),我们需要关注 afterNodeInsertion 方法,它也是定义在 HashMap 中的钩子方法,调用时机在第一次插入节点时,关键代码如下,它在 HashMap 的 putVal 方法中:

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        // ...

        // 新节点第一次插入
        afterNodeInsertion(evict);
        return null;
    }

我们来关注下 LinkedHashMap 中对此方法的实现:

    // 头节点是最旧的,将头节点进行移除
    void afterNodeInsertion(boolean evict) { 
        LinkedHashMap.Entry<K,V> first;
        // evict 为 true,且头节点不为空,removeEldestEntry 为 true 时将节点进行移除
        if (evict && (first = head) != null && removeEldestEntry(first)) {
            K key = first.key;
            removeNode(hash(key), key, null, false, true);
        }
    }

removeEldestEntry 方法我们需要点进去看看:

    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return false;
    }

我们可以发现,该方法默认情况下为 False,所以插入节点是不会对节点进行移除的。而LRU算法需要将缓存维持在固定大小,那么我们需要对该方法进行重写,比如要保持容量大小始终在100:

private static final int MAX_ENTRIES = 100;
 
protected boolean removeEldestEntry(Map.Entry eldest) {
    return size() > MAX_ENTRIES;
}

总结一下,使用 LinkedHashMap 实现 LRU 缓存需要做两件事:

  1. 调用特定的构造方法指定 accessOrder 为 true,使得每次被访问的节点都改变节点顺序

  2. 如果需要指定缓存容量的话,需重写 removeEldestEntry 方法来保证不超过指定的最大容量

2. 手撕 LRU 缓存

146. LRU 缓存 中等 是 LeetCode 要求手撕 LRU 缓存的题目,大家可以点进去看一下原题,这里我们分别做出两种解法:一种是针对上文所述的 LinkedHashMap 来实现,另一种是借助 HashMap 和我们自己使用双向链表管理 entry 来实现。

LinkedHashMap 法

该方法详细内容在上文中已有具体解释,所以这里不再赘述,直接看代码即可

class LRUCache extends LinkedHashMap<Integer, Integer> {

    // 指定缓存的最大容量
    private final int capacity;

    public LRUCache(int capacity) {
        super(capacity, 0.75F, true);
        this.capacity = capacity;
    }

    public int get(int key) {
        return super.getOrDefault(key, -1);
    }

    public void put(int key, int value) {
        super.put(key, value);
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
        return size() > capacity;
    }
}

HashMap 和 双向链表

先上代码,注意其中的注释

class LRUCache {

    static class ListNode {

        ListNode left;

        ListNode right;

        int key, value;

        public ListNode(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }

    private final HashMap<Integer, ListNode> map;

    private final ListNode sentinel;

    private final int capacity;

    /**
     * 定义访问过的节点移动到尾节点
     */
    public LRUCache(int capacity) {
        this.map = new HashMap<>(capacity);
        this.capacity = capacity;
        // 定义单个哨兵节点形成双向循环链表来简化边界条件的判断
        ListNode sentinel = new ListNode(-1, -1);
        this.sentinel = sentinel;
        sentinel.right = sentinel;
        sentinel.left = sentinel;
    }

    public int get(int key) {
        if (map.containsKey(key)) {
            ListNode node = map.get(key);
            // 将该节点移动到尾节点
            refresh(node);

            return node.value;
        } else {
            return -1;
        }
    }

    public void put(int key, int value) {
        if (map.containsKey(key)) {
            ListNode node = map.get(key);
            node.value = value;
            // 如果已经有这个节点则需要将其移动到尾节点
            refresh(node);
        } else {
            ListNode node = new ListNode(key, value);
            // 没有的话先判断容量
            if (map.size() == capacity) {
                // 先移除头节点
                ListNode head = sentinel.right;
                map.remove(head.key);
                sentinel.right = head.right;
                head.right.left = sentinel;
            }
            // 插入到尾节点
            insert(node);
            // 管理到 map 中
            map.put(key, node);
        }
    }

    /**
     * 移动该节点到尾节点
     */
    private void refresh(ListNode node) {
        ListNode pre = node.left, next = node.right;
        // 处理前驱节点 pre
        pre.right = next;
        // 处理后继节点 next
        next.left = pre;
        
        ListNode tail = sentinel.left;
        // 将当前节点移动到尾节点
        tail.right = node;
        node.left = tail;
        // 构建双向循环链表
        node.right = sentinel;
        sentinel.left = node;
    }

    /**
     * 添加到尾节点
     */
    private void insert(ListNode node) {
        ListNode tail = sentinel.left;
        // 添加到尾节点
        tail.right = node;
        node.left = tail;
        // 双向循环链表
        node.right = sentinel;
        sentinel.left = node;
    }
}

我们定义了一个 sentinel 哨兵节点,并让它形成一个循环的双向链表,我们可以根据该节点轻易获取到头节点(sentinel.right)和尾节点(sentinel.left)。这样做的好处是 简化了边界条件的处理,我们不需要在删除和移动链表节点的时候进行判空

链表图示如下,一个空的链表只由一个哨兵节点构成:

双向链表.png

需要注意的是,每次插入新的节点都需要注意维护循环双向链表


巨人的肩膀

  • 源于 LinkedHashMap源码

  • 【宫水三叶】设计数据结构:实现一个 LRUCache

  • 《算法导论》第 10.2 章

  • LRU_百度百科

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

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

相关文章

Windows如何体验使用Linux

一、背景 因早上刷抖音时&#xff0c;刷到一博主介绍WSL这个东东&#xff0c;因已很少在本地电脑操作Linux环境&#xff0c;咱们来看下这个和传统的vmware workstation 、virtualbox虚拟机有啥不同&#xff0c;WSL如何安装和使用&#xff1b;另提前声明&#xff0c;WSL不推荐用…

超实用!五种常用的多离散化小技巧

一、引言 「离散化」在数据分析中扮演着重要的角色。通过将连续型变量转化为离散型变量&#xff0c;我们可以更好地理解和分析数据&#xff0c;从而揭示出潜在的模式和关系。本文的目的是介绍五种常用的多离散化小技巧&#xff0c;它们可以帮助数据分析人员有效地处理连续变量。…

企业架构LNMP学习笔记34

LVS-DR模式&#xff1a; 老师分析&#xff1a; 1、首先用户用CIP请求VIP 2、根据上图可以看到&#xff0c;不管是Director Server还是Real Server上都需要配置VIP&#xff0c;那么当用户请求到达我们的集群网络的前端路由器的时候&#xff0c;请求数据包的源地址为CIP目标地址…

02. Kubeadm部署Kubernetes集群

目录 1、前言 2、Kubernetes部署方式 3、kubeadmin部署 3.1、关闭防火墙 3.2、配置阿里云Kubernetes源 3.3、安装kubeadm&#xff0c;kubelet&#xff0c;kubectl 3.4、初始化master节点 3.5、master节点配置kubectl命令行工具 3.6、master节点下载flannel网络配置文件…

Java 抽象类能不能实例化

短回答就是&#xff1a;不能 这里有 2 个概念&#xff0c;什么是抽象类和什么是实例化。 实例化 实例化简单来说就是为 Java 中使用的对象分配存储空间。 抽象类 从代码上来说&#xff0c;抽象类就是一个用 abstract 关键字来修饰的类。 这个类除了不能被实例化以外&#x…

第16章_瑞萨MCU零基础入门系列教程之CAN 协议

本教程基于韦东山百问网出的 DShanMCU-RA6M5开发板 进行编写&#xff0c;需要的同学可以在这里获取&#xff1a; https://item.taobao.com/item.htm?id728461040949 配套资料获取&#xff1a;https://renesas-docs.100ask.net 瑞萨MCU零基础入门系列教程汇总&#xff1a; ht…

喜报 | 实力亮相2023服贸会,擎创科技斩获领军人物奖创新案例奖

近日&#xff0c;由中华人民共和国商务部、北京市人民政府共同主办的中国&#xff08;北京&#xff09;国际服务贸易交易会&#xff08;简称服贸会)已圆满落幕。 本次会议中&#xff0c;发布了2023年度“数智影响力”征集活动获奖名单&#xff0c;擎创科技创始人兼CEO杨辰获企…

Jetsonnano B01 笔记5:IIC通信

今日继续我的Jetsonnano学习之路&#xff0c;今日学习的是IIC通信&#xff0c;并尝试使用Jetson读取MPU6050陀螺仪数据。文章提供源码。文章主要是搬运的官方PDF说明&#xff0c;这里结合自己实际操作作笔记。 目录 IIC通信&#xff1a; IIC硬件连线&#xff1a; 安装IIC库文…

【技能树笔记】网络篇——练习题解析(二)

目录 前言 一. 数据链路层的作用 1.1 数据链路层作用 1.2 数据链路层封装 1.3 数据链路层功能 1.4 数据帧格式 二. MAC地址及分类 2.1 MAC地址 2.2 MAC地址分类 三. 交换机的作用 3.1 交换机的作用 3.2 交换机作用 四.交换机的工作原理 4.1 交换机的工作原理 4.…

spring---第七篇

系列文章目录 文章目录 系列文章目录一、什么是bean的自动装配,有哪些方式?一、什么是bean的自动装配,有哪些方式? 开启自动装配,只需要在xml配置文件中定义“autowire”属性。 <bean id="cutomer" class="com.xxx.xxx.Customer" autowire="…

【侯捷C++面向对象 】(上)

1.C 编程简介 & 目标 培养代码正规编范class 分为 带pointer 和 不带pointer的 学习C &#xff1a; 语言 标准库 2.C vs C C语言 &#xff1a; &#xff08;type&#xff09;数据 函数 —create—》 数据sC &#xff1a; (class ) 数据 成员 —create—》 对象不带指…

AI伦理:科技发展中的人性之声

文章目录 AI伦理的关键问题1. 隐私问题2. 公平性问题3. 自主性问题4. 伦理教育问题 隐私问题的拓展分析数据收集和滥用隐私泄露和数据安全 公平性问题的拓展分析历史偏见和算法模型可解释性 自主性问题的拓展分析自主AI决策伦理框架 伦理教育的拓展分析伦理培训 结论 &#x1f…

【LeetCode-中等题】34. 在排序数组中查找元素的第一个和最后一个位置

文章目录 题目方法一&#xff1a;二分查找&#xff08;先找到mid&#xff0c;在根据mid确定左右区间&#xff09;方法二&#xff1a;分两次二分查找&#xff0c;一次用于找左区间&#xff0c;一次用于找右区间 题目 方法一&#xff1a;二分查找&#xff08;先找到mid&#xff0…

第六讲:如何构建类的事件(上)

【分享成果&#xff0c;随喜正能量】世界上凡是人聚集的地方&#xff0c;讨论的话题无外乎三个&#xff1a;拐弯抹角的炫耀自己、添油加醋的贬低别人、相互窥探的搬弄是非。人性的丑陋就是&#xff1a;在无权无势、善良的人身上挑毛病&#xff1b;在有权有势的人身上找优点。。…

【硬件设计】硬件学习笔记二--电源电路设计

硬件学习笔记二--电源电路设计 一、LDO设计1.1 LDO原理1.2 LDO参数1.3 应用 二、DC-DC设计2.1 DC-DC原理2.2 DC-DC参数介绍2.4 DC-DC设计要点2.5 DC-DC设计注意事项 写在前面&#xff1a;本篇笔记来自王工的硬件工程师培训课程&#xff0c;想要学硬件的同学可以去腾讯课堂直接搜…

【LeetCode-中等题】69. x 的平方根

文章目录 题目方法一&#xff1a;二分查找 题目 方法一&#xff1a;二分查找 假设求8的平方根&#xff0c;那就设置left 0 &#xff0c;right 8&#xff1b; 每次取最中间的元素的平方和8对比&#xff0c;如果大于8&#xff0c;则right mid-1&#xff0c;如果小于8 left mi…

第二节 极限 (一)

一、极限的定义(了解) 二、求极限的方法 (重点 大题8分 选择4分 填空4分) (1) 直接代入 (只要有意义) (2) 洛必达法则&#xff08;80%解题法&#xff09; (3) 无穷小和无穷大的性质 (4) 三种特例 (5) 两个重要极限 (6) 等价无穷小的替换 三、真题 方法一&#xff…

蓝桥杯官网填空题(振兴中华)

题目描述 本题为填空题&#xff0c;只需要算出结果后&#xff0c;在代码中使用输出语句将所填结果输出即可。 小明参加了学校的趣味运动会&#xff0c;其中的一个项目是&#xff1a;跳格子。 地上画着一些格子&#xff0c;每个格子里写一个字&#xff0c;如下所示&#xff1…

字符编码(idea)

File----------settings-------------Editor------------File Encodings

常见IO模型(非常详细)

背景知识 常⽤5中⽹络IO模型 阻塞IO&#xff08;Blocking IO&#xff09;⾮阻塞IO&#xff08;Non-Blocking IO&#xff09;多路复⽤IO&#xff08;IO Multiplexing&#xff09;信号驱动IO&#xff08;Signal Driven IO&#xff09;异步IO&#xff08;Asynchronous IO&#x…