深入理解缓存淘汰策略:LRU 与 LFU 算法详解及 Java 实现

一、LRU (Least Recently Used - 最近最少使用)

LRU 策略的核心思想是:当缓存空间不足时,优先淘汰最近最长时间未被访问的数据。它基于"时间局部性"原理,即最近被访问的数据,在未来被访问的概率也更高。

LeetCode 146. LRU 缓存机制

这道题要求我们设计并实现一个满足 LRU 约束的数据结构。

核心思路:哈希表 + 双向链表

为了同时满足快速查找(get​ 操作)和快速增删(维护访问顺序)的需求,我们采用"哈希表 + 双向链表"的组合:

  1. ​HashMap<Integer, Node>​: 哈希表用于存储键(Key)到链表节点(Node)的映射。这使得我们能够以 O(1) 的平均时间复杂度通过 Key 快速定位到链表中的节点。

  2. 双向链表 (DoubleList​): 双向链表按照节点的访问顺序来组织。

    • 最近使用的节点放在链表尾部。
    • 最久未使用的节点放在链表头部。
    • 当缓存容量满时,淘汰链表头部的节点。
    • 当访问(get 或 put 更新)一个节点时,将其移动到链表尾部。
    • 添加新节点时,也将其添加到链表尾部。

为什么是这个组合?

  • 哈希表保证了 get 操作查找节点的时间复杂度为 O(1)。
  • 双向链表保证了在链表头部删除(淘汰)、在链表尾部添加(新访问/新添加)、以及将任意节点移动到尾部(更新访问)的操作时间复杂度都为 O(1)。(普通链表无法 O(1) 删除任意指定节点)。
  • HashMap 帮助我们快速找到链表中的节点,然后双向链表快速完成节点的移动或删除。

图示理解:

(此处可想象或引用你提供的图示 image-20240714103929-cde7wui.png​ 和 image-20240714103937-nq23qq9.png​ 来展示数据结构)

Java 实现 (LRUCache​)

复制代码
import java.util.HashMap;

class LRUCache {
    // Key -> Node(key, val)
    private HashMap<Integer, Node> map;
    // 双向链表,存储 Node
    private DoubleList cache;
    // 最大容量
    private int cap;

    public LRUCache(int capacity) {
        this.cap = capacity;
        map = new HashMap<>();
        cache = new DoubleList();
    }

    /* 将某个 key 提升为最近使用的 */
    private void makeRecent(Node node) {
        // 先从链表中删除
        cache.remove(node);
        // 再添加到链表尾部
        cache.addLast(node);
    }

    /* 添加最近使用的元素 */
    private void addRecent(Node node) {
        // 添加到链表尾部
        cache.addLast(node);
        // 同时添加到 map 中
        map.put(node.key, node);
    }

    /* 删除某一个 key 对应的 Node */
    private void removeNode(Node node) {
        // 从链表中删除
        cache.remove(node);
        // 从 map 中删除
        map.remove(node.key);
    }

    /* 删除最久未使用的元素 (链表头部第一个节点) */
    private void removeLeastRecent() {
        // 从链表头部删除节点
        Node deletedNode = cache.removeFirst();
        // 如果链表不为空,则从 map 中也删除
        if (deletedNode != null) {
            map.remove(deletedNode.key);
        }
    }

    public int get(int key) {
        if (!map.containsKey(key)) {
            return -1; // 键不存在
        }
        // 键存在,将其变为最近使用
        Node node = map.get(key);
        makeRecent(node);
        return node.val;
    }

    public void put(int key, int value) {
        if (map.containsKey(key)) {
            // 如果 key 已存在,更新值并将节点移到末尾
            // 1. 从 map 中删除旧节点 (因为要更新节点,虽然key相同) - 或者直接更新node的val
            // Node oldNode = map.get(key);
            // removeNode(oldNode); // 更简单的做法是下面这样

            // 1. 更新节点值
            Node node = map.get(key);
            node.val = value;
            // 2. 将节点移到末尾
            makeRecent(node);

            // 注意:不能简单地 removeNow + addRecent,因为这会创建一个新 Node 对象
            // removeNow(map.get(key));
            // addrecent(new Node(key,value)); // 这样做逻辑上是更新,但效率低且可能引入问题

            return;
        }

        // 如果 key 不存在,需要添加新节点
        // 检查容量是否已满
        if (cache.size() == cap) {
            // 删除最久未使用的元素
            removeLeastRecent();
        }
        // 添加新节点到末尾
        addRecent(new Node(key, value));
    }

    // --- 内部类定义 ---
    class Node {
        public int key, val;
        public Node next, pre;

        public Node(int k, int v) {
            this.key = k;
            this.val = v;
        }
    }

    class DoubleList {
        // 虚拟头尾节点,简化边界处理
        private Node head, tail;
        // 链表元素数
        private int size;

        public DoubleList() {
            head = new Node(0, 0);
            tail = new Node(0, 0);
            head.next = tail;
            tail.pre = head;
            size = 0;
        }

        // 在链表尾部添加节点 x(表示最近使用)
        public void addLast(Node x) {
            x.pre = tail.pre;
            x.next = tail;
            tail.pre.next = x;
            tail.pre = x;
            size++;
        }

        // 删除链表中的 x 节点(x 一定存在)
        public void remove(Node x) {
            x.pre.next = x.next;
            x.next.pre = x.pre;
            size--;
        }

        // 删除链表中第一个节点(最久未使用),并返回该节点
        public Node removeFirst() {
            if (head.next == tail) { // 链表为空
                return null;
            }
            Node first = head.next;
            remove(first);
            return first;
        }

        // 返回链表长度
        public int size() {
            return size;
        }
    }
}

关键点总结 (LRU):

  • ​get​ 操作:通过 map​ 找到节点,调用 makeRecent​ 将其移到链表尾部。

  • ​put​ 操作:

    • 若 Key 存在:更新节点 value,调用 makeRecent 将其移到链表尾部。
    • 若 Key 不存在:检查容量,若满则调用 removeLeastRecent 淘汰链表头部节点(并从 map 移除);然后调用 addRecent 将新节点添加到链表尾部和 map 中。

二、LFU (Least Frequently Used - 最不经常使用)

LFU 策略的核心思想是:当缓存空间不足时,优先淘汰访问频次最低的数据。如果访问频次最低的数据有多条,则淘汰其中最旧(按访问时间算,即最早进入该最低频次)的数据。

LeetCode 460. LFU 缓存

这道题要求我们设计并实现一个满足 LFU 约束的数据结构,且 get​ 和 put​ 操作的时间复杂度都为 O(1)。

核心思路:哈希表组合 + LinkedHashSet​

LFU 的 O(1) 实现比 LRU 更复杂,需要巧妙地组合多个哈希表:

  1. ​HashMap<Integer, Integer> keyToVal​: 存储 Key 到 Value 的映射,用于 O(1) 获取值。

  2. ​HashMap<Integer, Integer> keyToFreq​: 存储 Key 到其访问频次(Frequency)的映射,用于 O(1) 获取和更新 Key 的频次。

  3. ​HashMap<Integer, LinkedHashSet<Integer>> freqToKeys​: 存储频次(Frequency)到拥有该频次的 Key 集合的映射。

    • 为什么是 LinkedHashSet​?

      • 我们需要一个集合来存储同一频次的所有 Key。
      • 当某个 Key 的频次增加时,需要能 O(1) 地从旧频次的集合中删除该 Key。HashSet 提供 O(1) 的平均删除时间。
      • 当频次最低的有多个 Key 时,需要淘汰最旧的 Key。LinkedHashSet 在保持 O(1) 增删查的同时,内部维护了元素的插入顺序。因此,当需要淘汰时,迭代 LinkedHashSet 的第一个元素即为该频次下最旧的 Key。
      • 普通的 LinkedList 无法 O(1) 删除任意指定 Key,而 HashSet 不保证顺序。LinkedHashSet 是最佳选择。
  4. ​int minFreq​: 一个变量,记录当前缓存中存在的最低访问频次。这使得在需要淘汰时,能 O(1) 定位到最低频次的 Key 集合。

图示理解:

(此处可想象或引用你提供的图示 image-20240714113101-7ery7zh.png​ 和 image-20240714113113-1fnp3y7.png​ 来展示数据结构关系)

Java 实现 (LFUCache​)

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

class LFUCache {
    // key -> value
    HashMap<Integer, Integer> keyToVal;
    // key -> frequency
    HashMap<Integer, Integer> keyToFreq;
    // frequency -> keys (保持插入顺序,即时间顺序)
    HashMap<Integer, LinkedHashSet<Integer>> freqToKeys;
    // 记录当前缓存中存在的最小频次
    int minFreq;
    // 缓存的最大容量
    int cap;

    public LFUCache(int capacity) {
        keyToVal = new HashMap<>();
        keyToFreq = new HashMap<>();
        freqToKeys = new HashMap<>();
        this.cap = capacity;
        this.minFreq = 0; // 初始最小频次为0
    }

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

    public void put(int key, int value) {
        if (this.cap <= 0) { // 处理容量为0或负数的边界情况
            return;
        }

        if (keyToVal.containsKey(key)) {
            // key 已存在,更新 value
            keyToVal.put(key, value);
            // 增加 key 对应的频次
            increaseFreq(key);
            // 注意:LFU 的更新操作仅涉及更新值和增加频率,不需要像 LRU 那样显式地"移动"
        } else {
            // key 不存在,需要插入新 key

            // 检查容量是否已满
            if (this.cap <= keyToVal.size()) {
                // 容量已满,需要淘汰一个 key
                removeMinFreqKey();
            }

            // 插入新的 key 和 value
            keyToVal.put(key, value);
            // 新 key 的初始频次为 1
            keyToFreq.put(key, 1);
            // 将新 key 加入频次为 1 的集合中
            freqToKeys.putIfAbsent(1, new LinkedHashSet<>());
            freqToKeys.get(1).add(key);
            // 插入新 key 后,最小频次一定是 1
            this.minFreq = 1;
        }
    }

    /* 增加 key 对应的频次 */
    private void increaseFreq(int key) {
        int freq = keyToFreq.get(key); // 获取当前频次
        // 更新 key 的频次
        keyToFreq.put(key, freq + 1);

        // 从旧频次的 key 集合中移除 key
        LinkedHashSet<Integer> oldFreqKeys = freqToKeys.get(freq);
        oldFreqKeys.remove(key);

        // 将 key 加入新频次的 key 集合中
        freqToKeys.putIfAbsent(freq + 1, new LinkedHashSet<>());
        freqToKeys.get(freq + 1).add(key);

        // 检查旧频次的 key 集合是否为空
        if (oldFreqKeys.isEmpty()) {
            // 如果为空,则从 freqToKeys 中移除该频次条目
            freqToKeys.remove(freq);
            // 如果移除的这个频次恰好是 minFreq,则需要更新 minFreq
            if (freq == this.minFreq) {
                // 新的 minFreq 变成了 freq + 1
                this.minFreq++;
            }
        }
    }

    /* 淘汰一个最小频次且最旧的 key */
    private void removeMinFreqKey() {
        // 获取最小频次对应的 key 集合 (按插入顺序)
        LinkedHashSet<Integer> keyList = freqToKeys.get(this.minFreq);
        // 第一个元素就是最旧的 key
        int deletedKey = keyList.iterator().next();

        // 从 key 集合中移除
        keyList.remove(deletedKey);
        // 检查移除后集合是否为空
        if (keyList.isEmpty()) {
            // 如果为空,则从 freqToKeys 中移除该频次条目
            freqToKeys.remove(this.minFreq);
            // 注意:这里不需要更新 minFreq
            // 因为 removeMinFreqKey() 只在 put 新元素且容量满时调用
            // 而 put 新元素后 minFreq 会被强制设为 1,所以旧的 minFreq 是否有效已不重要
        }

        // 从 keyToVal 和 keyToFreq 中移除该 key
        keyToVal.remove(deletedKey);
        keyToFreq.remove(deletedKey);
    }
}

关键点总结 (LFU):

  • ​get​ 操作:通过 keyToVal​ 获取值,然后调用 increaseFreq​ 更新频率相关信息。

  • ​put​ 操作:

    • 若 Key 存在:更新 keyToVal 中的值,调用 increaseFreq。
    • 若 Key 不存在:检查容量,若满则调用 removeMinFreqKey 淘汰;然后,在 keyToVal, keyToFreq (设为1), freqToKeys (添加到freq=1的集合) 中添加新 Key,并将 minFreq 更新为 1。
  • ​increaseFreq​ 核心逻辑:更新 keyToFreq​,从旧频次的 LinkedHashSet​ 中移除 Key,添加到新频次的 LinkedHashSet​ (如果需要则创建)。如果旧频次集合变空且它曾是 minFreq​,则递增 minFreq​。

  • ​removeMinFreqKey​ 核心逻辑:从 freqToKeys​ 中获取 minFreq​ 对应的 LinkedHashSet​,移除其第一个元素(最旧的),并同步更新所有相关的 Map​。如果集合变空,则移除该频次条目。

三、LRU vs LFU

  • LRU: 关注最近访问时间,实现相对简单(LinkedHashMap 或 HashMap+DLL)。适合访问模式有较强时间局部性的场景。
  • LFU: 关注访问频率,并结合时间作为次要淘汰标准(频率相同时淘汰最旧的)。实现更复杂,需要维护频率信息和访问时序。适合需要保留高频访问数据,即使它不是最近访问的场景。
相关推荐
xin007hoyo2 小时前
算法笔记.染色法判断二分图
数据结构·笔记·算法
Doker 多克2 小时前
Django 缓存框架
python·缓存·django
rainFFrain3 小时前
(MySQL)库的操作
数据库·mysql
此木|西贝3 小时前
【设计模式】享元模式
java·设计模式·享元模式
এ᭄画画的北北4 小时前
力扣-234.回文链表
算法·leetcode·链表
李少兄4 小时前
解决Spring Boot多模块自动配置失效问题
java·spring boot·后端
bxlj_jcj5 小时前
JVM性能优化之年轻代参数设置
java·性能优化
不当菜虚困5 小时前
JAVA设计模式——(八)单例模式
java·单例模式·设计模式