理解和实现 LFU 缓存置换算法

引言

在计算机科学中,缓存是一种重要的技术,用于提高数据访问速度和系统性能。然而,由于缓存空间有限,当缓存满了之后,就需要一种智能的策略来决定哪些数据应该保留,哪些应该被淘汰。LFU(Least Frequently Used,最少使用)算法就是一种常见的缓存淘汰策略,它基于数据项的访问频率来进行优化管理。

LFU算法简介

LFU算法的核心思想是优先淘汰那些访问频率最低的数据项。在缓存达到容量限制时,LFU算法会移除那些被访问次数最少的缓存条目。如果多个条目的访问次数相同,则根据它们最早被访问的时间进行决策,优先删除最早被访问的条目。

LFU算法的工作原理

LFU算法通常需要两种数据结构来实现:

哈希表 :提供O(1)时间复杂度的数据访问和插入。此时哈希表需要两个,一个记录key和当前节点的映射,一个记录频率和节点的映射
双向链表:维护数据项的使用顺序,最近使用的在头部,最久未使用的在尾部。

数据访问和插入的流程如下:

获取数据(Get):从缓存中获取数据,如果数据存在(缓存命中),则更新数据使用的频率,也就是频率+1,同时要删除当前节点之前对应的频率映射;如果数据不存在(缓存未命中),返回 -1。

插入数据(Put):将数据放入缓存,如果数据已经存在,则更新数据值并更新数据使用的频率;如果数据不存在,则将数据插入放入缓存中,并将最低频率设置为1。如果缓存已满,首先需要移除缓存中的元素,然后再插入。

LFU算法的实现

java 复制代码
import java.util.HashMap;

public class LFUCache {

    static class Node{
        int key, value, freq = 1;
        Node prev, next;

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


    // 键到节点的映射
    private HashMap<Integer, Node> keyToNode = new HashMap<>();
    // 频率到虚拟头节点的映射
    private HashMap<Integer, Node> freqToDummy = new HashMap<>();
    // 最小访问频率
    private int minFreq;
    // 容量
    private int capacity;

    public LFUCache(int capacity) {
        this.capacity = capacity;
    }

    private Node newList() {
        Node dummy = new Node(0, 0);
        dummy.prev = dummy;
        dummy.next = dummy;
        return dummy;
    }

    /**
     * 根据键获取值。
     * 该方法首先尝试从keyToNode映射中获取给定键对应的节点。如果节点不存在,说明该键不存在于数据结构中,方法将返回-1。
     * 如果节点存在,则该方法会执行删除操作(del)和频率更改操作(changeFreq),然后再返回节点的值。
     * 这种设计可能是为了在获取值的同时,根据获取情况动态调整数据结构,例如实现一种基于频率的缓存淘汰策略。
     *
     * @param key 需要获取值的键。
     * @return 键对应的值,如果键不存在,则返回-1。
     */
    public int get(int key) {
        // 尝试从映射中获取给定键对应的节点。
        Node node = keyToNode.get(key);
        // 如果节点不存在,说明键不存在于数据结构中,返回-1。
        if(node == null) return -1;
        // 修改节点的频率信息,
        changeFreq(node);
        // 返回节点的值。
        return node.value;
    }


    /**
     * 插入一个键值对到缓存中。
     * 如果键已经存在,则更新其值,并根据更新后的频率进行调整。
     * 如果缓存已满,则移除最低频率的键值对,并插入新的键值对。
     *
     * @param key 插入或更新的键。
     * @param value 插入或更新的值。
     */
    public void put(int key, int value) {
        // 尝试从映射中获取现有的节点
        Node node = keyToNode.get(key);
        if(node != null){
            // 如果节点存在,更新其值,并准备进行频率更新操作
            node.value = value;
            changeFreq(node);
            return;
        }

        // 如果缓存已满,需要移除最低频率的节点以腾出空间
        if(keyToNode.size() == capacity){
            Node cur = freqToDummy.get(minFreq);
            Node delNode = cur.prev;
            keyToNode.remove(delNode.key);
            del(delNode);
            if(cur.prev == cur) freqToDummy.remove(minFreq);
        }
        // 创建新节点,并插入到映射和频率链表中
        node = new Node(key, value);
        keyToNode.put(key, node);
        insert(1, node);
        minFreq = 1;
    }


    /**
     * 修改节点的频率。
     * 此方法用于更新给定节点的频率,并相应地调整频率列表和最小频率的值。
     * 如果更新后的频率导致原有的频率列表头部节点成为一个孤立节点(即它的前向和后向指针都指向自己),
     * 则该节点将从频率列表中移除,并且如果该频率原本是最低频率,则最小频率值将增加。
     * 最后,使用更新后的频率将节点插入到频率列表的适当位置。
     *
     * @param node 需要更新频率的节点。
     */
    void changeFreq(Node node){
    	 // 1、先删除节点
        del(node);
        // 根据当前节点的频率获取频率链表的头部节点
        Node cur = freqToDummy.get(node.freq);
        // 检查当前频率的链表是否为空(即头部节点的前向指针是否指向自己)
        if(cur.prev == cur){
            // 如果链表为空,则从映射中移除该频率,并检查是否需要调整最小频率值
            freqToDummy.remove(node.freq);
            if(minFreq == node.freq){
                minFreq++;
            }
        }
        // 3、插入到新位置
        insert(++node.freq, node);
    }


    /**
     * 将给定节点插入到特定频率链表中。
     * 此函数假设频率对应的链表已经存在,或者如果不存在,则会创建一个新的链表。
     * 插入操作在链表的头部进行,以保持节点按频率排序。
     *
     * @param key 节点的键值,用于确定节点应插入到哪个频率链表。
     * @param node 要插入的节点。
     */
    void insert(int key, Node node){
        // 根据频率获取或创建对应的频率链表的头节点。
        // 获取频率的头节点
        Node cur = freqToDummy.computeIfAbsent(key, k -> newList());
        node.prev = cur;
        node.next = cur.next;
        cur.next.prev = node;
        cur.next = node;
    }


    /**
     * 从双向链表中删除给定节点。
     * 此函数不返回任何值,因为它操作的是链表的内部结构。
     * 它接受一个参数 node,该参数是需要被删除的节点。
     *
     * @param node 需要被删除的节点。
     */
    void del(Node node){
        /* 将给定节点的前一个节点的next指针指向给定节点的后一个节点,从而在链表中向前断开给定节点。 */
        node.next.prev = node.prev;
        /* 将给定节点的后一个节点的prev指针指向给定节点的前一个节点,从而在链表中向后断开给定节点。 */
        node.prev.next = node.next;
    }

}
相关推荐
十一月十一」34 分钟前
WebDriver API
java·selenium
前端组件开发36 分钟前
基于uni-app与图鸟UI的移动应用模板构建研究
java·开发语言·前端·ui·小程序·前端框架·uni-app
weixin_8368695202 小时前
Java中的机器学习模型集成与训练
java·开发语言·机器学习
VX_DZbishe2 小时前
springboot旅游管理系统-计算机毕业设计源码16021
java·spring boot·python·servlet·django·flask·php
橙子味冰可乐2 小时前
isprintable()方法——判断字符是否为可打印字符
java·前端·javascript·数据库·python
yunpeng.zhou2 小时前
logging 模块简单使用记录
java·前端·数据库
小oo呆2 小时前
【机器学习300问】135、决策树算法ID3的局限性在哪儿?C4.5算法做出了怎样的改进?
算法·决策树·机器学习
嗨!陌生人3 小时前
SpringSecurity中文文档(Servlet Session Management)
java·hadoop·spring boot·后端·spring cloud·servlet
广西千灵通网络科技有限公司3 小时前
基于Java的微信记账小程序【附源码】
java·微信·小程序
情系明明3 小时前
使用c++设计一个计算器
数据结构·c++·算法