理解和实现 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;
    }

}
相关推荐
Buleall4 分钟前
期末考学C
java·开发语言
重生之绝世牛码6 分钟前
Java设计模式 —— 【结构型模式】外观模式详解
java·大数据·开发语言·设计模式·设计原则·外观模式
小蜗牛慢慢爬行13 分钟前
有关异步场景的 10 大 Spring Boot 面试问题
java·开发语言·网络·spring boot·后端·spring·面试
荒古前19 分钟前
龟兔赛跑 PTA
c语言·算法
Colinnian22 分钟前
Codeforces Round 994 (Div. 2)-D题
算法·动态规划
用户00993831430128 分钟前
代码随想录算法训练营第十三天 | 二叉树part01
数据结构·算法
shinelord明32 分钟前
【再谈设计模式】享元模式~对象共享的优化妙手
开发语言·数据结构·算法·设计模式·软件工程
新手小袁_J37 分钟前
JDK11下载安装和配置超详细过程
java·spring cloud·jdk·maven·mybatis·jdk11
呆呆小雅38 分钟前
C#关键字volatile
java·redis·c#