跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客GitHub 精选镜像工具UI配色美学隐私政策关于联系
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
Javajava算法

深入理解 LRU 与 LFU 缓存算法实现

LRU 和 LFU 是面试中常见的缓存淘汰策略。LRU 基于最近访问时间,使用哈希表加双向链表实现 O(1) 操作。LFU 基于访问频率,可通过平衡树或双哈希表优化。两者在系统设计中各有适用场景,掌握其底层原理对应对高并发架构挑战至关重要。

KernelLab发布于 2026/3/30更新于 2026/6/518 浏览
深入理解 LRU 与 LFU 缓存算法实现

深入理解 LRU 与 LFU 缓存算法实现

在系统设计和面试中,缓存淘汰策略是高频考点。LRU(最近最少使用)和 LFU(最不经常使用)代表了两种不同的淘汰逻辑,掌握它们的底层实现原理,对于应对高并发架构挑战至关重要。

一、LRU 缓存算法

1. 哈希表 + 双向链表

以 LeetCode 的 LRU Cache 题目为例,核心需求是在 O(1) 时间复杂度内完成 get 和 put 操作。

LRU 题目描述

设计思路

单纯用哈希表无法维护访问顺序,单纯用链表查找效率太低。因此我们采用组合方案:

  • 双向链表:维护节点的访问顺序。最近使用的节点放在头部,最少使用的节点放在尾部。淘汰时直接删除尾部节点。
  • 哈希表:存储 key 到链表节点的映射,实现 O(1) 快速查找。
  • 哑头/哑尾节点:简化边界处理,无需判断链表是否为空或节点是否为头尾。
代码实现
class LRUCache {
    // 1. 双向链表节点类:封装缓存的 key、value,以及前后指针
    class DLinkedNode {
        int key;
        int value;
        DLinkedNode prev;
        DLinkedNode next;

        // 无参构造:用于创建哑头/哑尾节点
        public DLinkedNode() {}

        // 有参构造:用于创建真实的缓存节点
        public DLinkedNode(int _key, int _value) {
            this.key = _key;
            this.value = _value;
        }
    }

    // 2. LRUCache 核心成员变量
    private Map<Integer, DLinkedNode> cache = new HashMap<>();
    private int size;       // 当前缓存中的节点数量
    private int capacity;   // 缓存的最大容量
    private DLinkedNode head; // 链表哑头节点
    private DLinkedNode tail; // 链表哑尾节点

    // 3. 构造方法:初始化 LRU 缓存
    public LRUCache(int capacity) {
        this.size = 0;
        this.capacity = capacity;
        head = new DLinkedNode();
        tail = new DLinkedNode();
        head.next = tail;
        tail.prev = head;
    }

    // 4. get 操作:根据 key 获取缓存值
    public int get(int key) {
        DLinkedNode node = cache.get(key);
        if (node == null) {
            return -1;
        }
        // 存在则移到链表头部,标记为'最近使用'
        moveToHead(node);
        return node.value;
    }

    // 5. put 操作:添加/更新缓存
    public void put(int key, int value) {
        DLinkedNode node = cache.get(key);

        if (node == null) {
            // 情况 1:key 不存在,新增节点
            DLinkedNode newNode = new DLinkedNode(key, value);
            cache.put(key, newNode);
            addToHead(newNode);
            size++;

            // 若超过容量,淘汰尾部节点
            if (size > capacity) {
                DLinkedNode tailNode = removeTail();
                cache.remove(tailNode.key);
                size--;
            }
        } else {
            // 情况 2:key 已存在,更新缓存值
            node.value = value;
            moveToHead(node);
        }
    }

    // 6. 辅助方法:删除链表中指定的节点(O(1) 时间)
    private void removeNode(DLinkedNode node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    // 7. 辅助方法:将节点添加到链表头部(O(1) 时间)
    private void addToHead(DLinkedNode node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }

    // 8. 辅助方法:将节点移到链表头部(先删除,再头插)
    private void moveToHead(DLinkedNode node) {
        removeNode(node);
        addToHead(node);
    }

    // 9. 辅助方法:移除链表尾部的真实节点,并返回该节点
    private DLinkedNode removeTail() {
        DLinkedNode res = tail.prev;
        removeNode(res);
        return res;
    }
}
关键点解析
  • 哑节点的作用:有了哑头哑尾,插入和删除逻辑就不需要单独处理'链表为空'或'操作头尾节点'的特殊情况,代码更简洁。
  • Key 的必要性:节点中必须存储 key。因为当需要从哈希表中删除映射时,我们只有链表节点,如果没有 key,就无法反向定位哈希表中的 entry。
  • 复杂度:所有操作均为 O(1),这是 LRU 缓存的最优实现。

二、LFU 缓存算法

LFU(Least Frequently Used)优先淘汰访问频率最低的元素。如果频率相同,则淘汰最早访问的元素。

1. 哈希表 + 平衡二叉树

这种方案借助 TreeSet 来维护节点的有序性。

LFU 题目描述

核心逻辑
  • HashMap:实现 O(1) 查找节点。
  • TreeSet:按「频率 + 时间戳」排序。第一个元素即为待淘汰节点。
  • 排序规则:先按频率升序,频率相同则按时间戳升序。
代码实现
class LFUCache {
    // 缓存节点类:实现 Comparable 用于 TreeSet 排序
    class Node implements Comparable<Node> {
        int cnt;      // 访问频率
        int time;     // 时间戳
        int key;
        int value;

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

        @Override
        public boolean equals(Object anObject) {
            if (this == anObject) return true;
            if (anObject instanceof Node) {
                Node rhs = (Node) anObject;
                return this.cnt == rhs.cnt && this.time == rhs.time;
            }
            return false;
        }

        @Override
        public int compareTo(Node rhs) {
            return cnt == rhs.cnt ? time - rhs.time : cnt - rhs.cnt;
        }

        @Override
        public int hashCode() {
            return cnt * 1000000007 + time;
        }
    }

    private int capacity;
    private int time;
    private Map<Integer, Node> key_table;
    private TreeSet<Node> S;

    public LFUCache(int capacity) {
        this.capacity = capacity;
        this.time = 0;
        key_table = new HashMap<>();
        S = new TreeSet<>();
    }

    public int get(int key) {
        if (capacity == 0 || !key_table.containsKey(key)) {
            return -1;
        }
        Node cache = key_table.get(key);
        S.remove(cache); // 移除旧节点,因为属性要更新
        cache.cnt++;
        cache.time = ++time;
        S.add(cache);    // 重新加入
        return cache.value;
    }

    public void put(int key, int value) {
        if (capacity == 0) return;

        if (!key_table.containsKey(key)) {
            if (key_table.size() == capacity) {
                // 淘汰频率最低、最早访问的节点
                Node toRemove = S.first();
                key_table.remove(toRemove.key);
                S.remove(toRemove);
            }
            Node cache = new Node(1, time++, key, value);
            key_table.put(key, cache);
            S.add(cache);
        } else {
            Node cache = key_table.get(key);
            S.remove(cache);
            cache.cnt++;
            cache.time = ++time;
            cache.value = value;
            S.add(cache);
        }
    }
}
分析

此方案实现简单,但 TreeSet 的增删操作是 O(log N)。每次更新节点都需要先从集合中移除,修改属性后再插入,以保证排序正确。

2. 双哈希表优化

为了达到纯 O(1) 的时间复杂度,我们可以引入「频率桶」的概念。

设计思路
  • keyTable:键 → 节点,用于快速查找。
  • freqTable:频率 → 双向链表,同一频率的节点挂在同一个链表中。
  • minfreq:记录当前最小访问频率,直接定位待淘汰的频率组。

淘汰时,只需找到 minfreq 对应链表的尾节点即可。频率更新时,节点从原频率链表移除,加入新频率链表。

代码实现
class LFUCache {
    private int minfreq;
    private int capacity;
    private Map<Integer, Node> keyTable;
    private Map<Integer, DoublyLinkedList> freqTable;

    public LFUCache(int capacity) {
        this.minfreq = 0;
        this.capacity = capacity;
        keyTable = new HashMap<>();
        freqTable = new HashMap<>();
    }

    public int get(int key) {
        if (capacity == 0 || !keyTable.containsKey(key)) {
            return -1;
        }
        Node node = keyTable.get(key);
        int val = node.val;
        int freq = node.freq;

        // 从原频率链表移除
        freqTable.get(freq).remove(node);

        // 如果原频率链表为空,删除该频率映射,并更新 minfreq
        if (freqTable.get(freq).size == 0) {
            freqTable.remove(freq);
            if (minfreq == freq) {
                minfreq += 1;
            }
        }

        // 加入新频率链表
        DoublyLinkedList list = freqTable.getOrDefault(freq + 1, new DoublyLinkedList());
        list.addFirst(new Node(key, val, freq + 1));
        freqTable.put(freq + 1, list);
        keyTable.put(key, freqTable.get(freq + 1).getHead());

        return val;
    }

    public void put(int key, int value) {
        if (capacity == 0) return;

        if (!keyTable.containsKey(key)) {
            if (keyTable.size() == capacity) {
                // 淘汰 minfreq 对应链表的尾节点
                Node node = freqTable.get(minfreq).getTail();
                keyTable.remove(node.key);
                freqTable.get(minfreq).remove(node);
                if (freqTable.get(minfreq).size == 0) {
                    freqTable.remove(minfreq);
                }
            }
            DoublyLinkedList list = freqTable.getOrDefault(1, new DoublyLinkedList());
            list.addFirst(new Node(key, value, 1));
            freqTable.put(1, list);
            keyTable.put(key, freqTable.get(1).getHead());
            minfreq = 1;
        } else {
            // 逻辑同 get,只是更新 value
            Node node = keyTable.get(key);
            int freq = node.freq;
            freqTable.get(freq).remove(node);
            if (freqTable.get(freq).size == 0) {
                freqTable.remove(freq);
                if (minfreq == freq) {
                    minfreq += 1;
                }
            }
            DoublyLinkedList list = freqTable.getOrDefault(freq + 1, new DoublyLinkedList());
            list.addFirst(new Node(key, value, freq + 1));
            freqTable.put(freq + 1, list);
            keyTable.put(key, freqTable.get(freq + 1).getHead());
        }
    }

    class Node {
        int key, val, freq;
        Node prev, next;
        public Node() { this(-1, -1, 0); }
        public Node(int key, int val, int freq) {
            this.key = key; this.val = val; this.freq = freq;
        }
    }

    class DoublyLinkedList {
        Node dummyHead, dummyTail;
        int size;
        public DoublyLinkedList() {
            dummyHead = new Node();
            dummyTail = new Node();
            dummyHead.next = dummyTail;
            dummyTail.prev = dummyHead;
            size = 0;
        }
        public void addFirst(Node node) {
            Node prevHead = dummyHead.next;
            node.prev = dummyHead;
            dummyHead.next = node;
            node.next = prevHead;
            prevHead.prev = node;
            size++;
        }
        public void remove(Node node) {
            node.prev.next = node.next;
            node.next.prev = node.prev;
            size--;
        }
        public Node getHead() { return dummyHead.next; }
        public Node getTail() { return dummyTail.prev; }
    }
}
对比总结

相比 TreeSet 方案,双哈希表 + 双向链表实现了真正的 O(1) 时间复杂度。虽然代码量稍多,但在对性能要求极高的场景下,这是 LFU 缓存的最优解。

核心在于利用 minfreq 快速定位淘汰目标,并通过分组链表避免全局遍历。实际开发中,可根据具体场景权衡实现复杂度与性能需求。

目录

  1. 深入理解 LRU 与 LFU 缓存算法实现
  2. 一、LRU 缓存算法
  3. 1. 哈希表 + 双向链表
  4. 设计思路
  5. 代码实现
  6. 关键点解析
  7. 二、LFU 缓存算法
  8. 1. 哈希表 + 平衡二叉树
  9. 核心逻辑
  10. 代码实现
  11. 分析
  12. 2. 双哈希表优化
  13. 设计思路
  14. 代码实现
  15. 对比总结
  • 💰 8折买阿里云服务器限时8折了解详情
  • Magick API 一键接入全球大模型注册送1000万token查看
  • 🤖 一键搭建Deepseek满血版了解详情
  • 一键打造专属AI 智能体了解详情
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • C++ 静态成员与非静态成员详解
  • WebView 并发初始化竞争风险分析
  • 斯大林排序算法:原理、特点与实现
  • Python 开发工具 uv 安装、配置与最佳实践
  • JVM 运行时数据区域详解
  • OpenClaw 的免费 AI 大模型及其配置方法
  • 链表经典算法题详解
  • 前端可访问性开发指南
  • 微信群智能管理:扣子机器人接入实战
  • Java 转 AI:经验分享与实战路线
  • 无人机视觉语言导航入门:概念、挑战与应用
  • Java AI 代码最佳实践优化器实测
  • 数据结构:双向链表详解与实现
  • Java 状态机详解:三种实现方式优雅消除 if-else 嵌套
  • Ground Slow, Move Fast: 一种通用可泛化的双系统视觉 - 语言导航基础模型
  • Docker 部署 OpenClaw 常见问题排查与自定义模型配置
  • C++11 核心特性详解:列表初始化、右值引用与移动语义
  • 基于 Docker 部署 AI 量化分析平台及波浪理论实战
  • Stable Diffusion XL 1.0 免配置方案:灵感画廊 Streamlit UI 定制实战
  • 高校电动车租赁系统设计与实现:SpringBoot+Vue+MySQL

相关免费在线工具

  • Keycode 信息

    查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online

  • Escape 与 Native 编解码

    JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online

  • JavaScript / HTML 格式化

    使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online

  • JavaScript 压缩与混淆

    Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online

  • 加密/解密文本

    使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online

  • Gemini 图片去水印

    基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online