LRU实现

使用 Java 实现 LRU(Least Recently Used,最近最少使用)缓存是一个非常经典的面试题,它考验了候选人对数据结构、面向对象设计以及 Java 集合框架的掌握程度。


LRU 缓存的核心思想

LRU 缓存的核心思想是"如果数据最近被访问过,那么它在将来被访问的概率也更高"。当缓存容量已满,需要插入新数据时,它会淘汰最久未被使用的数据。

为了实现这个逻辑,我们需要满足两个基本操作:

  • 快速查找 :当访问一个数据时,需要能够快速判断它是否在缓存中,并且要能快速地更新它的"最近使用"状态。HashMap 的平均 O(1) 时间复杂度的查找非常适合。
  • 快速排序/维护顺序:我们需要一种结构来记录所有数据的"最近使用"顺序,并且当数据被访问或更新时,能快速地将其移动到"最新"的位置,当需要淘汰时,能快速地找到"最旧"的数据。双向链表非常适合这个场景,因为它可以在 O(1) 时间内完成节点的插入和删除。

方法一:使用 LinkedHashMap 实现(标准做法)

LinkedHashMap 是 Java 集合框架中一个特殊的类,它继承自 HashMap,并在其基础上维护了一个双向链表,这个链表记录了元素的插入顺序或访问顺序。

我们可以利用这个"访问顺序"特性来非常轻松地实现 LRU 缓存。

实现步骤
  1. 继承 LinkedHashMap :创建一个类 LRUCache<K, V> 继承 LinkedHashMap<K, V>
  2. 设置访问顺序 :在构造函数中,调用父类 LinkedHashMap 的构造函数,并将 accessOrder 参数设置为 true。这会让链表按照元素的访问顺序(从最近访问到最久未访问)进行排序,而不是插入顺序。
  3. 重写 removeEldestEntry 方法LinkedHashMap 本身提供了一个回调方法 removeEldestEntry(Map.Entry eldest)。在每次向 Map 中添加新元素后,此方法都会被调用。当此方法返回 true 时,LinkedHashMap 会自动移除最旧的条目(即链表的头节点)。我们只需在这个方法中判断当前 Map 的大小是否超过了我们设定的容量。
代码实现
复制代码
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * 使用 LinkedHashMap 实现的 LRU 缓存
 * @param <K> 键类型
 * @param <V> 值类型
 */
public class LRUCacheWithLinkedHashMap<K, V> extends LinkedHashMap<K, V> {

    private final int maxCapacity;

    /**
     * 构造函数
     * @param maxCapacity 缓存的最大容量
     */
    public LRUCacheWithLinkedHashMap(int maxCapacity) {
        // 调用父类构造函数
        // initialCapacity: 初始容量,设置为 maxCapacity 可以避免扩容
        // loadFactor: 加载因子,0.75 是默认值
        // accessOrder: true 表示按访问顺序排序,这是实现 LRU 的关键
        super(maxCapacity, 0.75f, true);
        this.maxCapacity = maxCapacity;
    }

    /**
     * 当插入新元素后,此方法会被调用。
     * 如果返回 true,则会移除最旧的元素。
     * @param eldest 最旧的元素(即将被移除的元素)
     * @return true 如果当前大小超过了最大容量,则移除最旧元素
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        // 当缓存大小超过最大容量时,返回 true,让 LinkedHashMap 自动移除最旧的条目
        return size() > maxCapacity;
    }

    // 为了演示,我们添加一个 main 方法
    public static void main(String[] args) {
        LRUCacheWithLinkedHashMap<Integer, String> cache = new LRUCacheWithLinkedHashMap<>(3);

        System.out.println("------ 初始插入 1, 2, 3 ------");
        cache.put(1, "A");
        cache.put(2, "B");
        cache.put(3, "C");
        System.out.println(cache); // 顺序应为 {1=A, 2=B, 3=C} (访问顺序)

        System.out.println("\n------ 访问键 1,使其变为最新 ------");
        cache.get(1);
        System.out.println(cache); // 顺序变为 {2=B, 3=C, 1=A}

        System.out.println("\n------ 插入键 4,此时应淘汰最旧的键 2 ------");
        cache.put(4, "D");
        System.out.println(cache); // 顺序应为 {3=C, 1=A, 4=D}
    }
}
优点
  • 代码极其简洁:只需要几行核心代码,利用了 Java 库的强大功能。
  • 高效可靠LinkedHashMap 是经过高度优化的,其性能和稳定性都有保障。
  • 易于理解和维护:代码意图非常明确。

方法二:使用 HashMap + 双向链表 实现(从零开始)

这种方式完全由我们自己构建数据结构,能更深刻地理解 LRU 的原理。

实现步骤
  1. 定义双向链表节点 :创建一个 Node 类,包含 key, value, prev(前驱指针)和 next(后继指针)。
  2. 定义数据结构
    • 一个 HashMap<Integer, Node> 用于存储键到节点的映射,实现 O(1) 时间复杂度的查找。
    • 一个双向链表,用于维护节点的访问顺序。链表头部是最新访问的节点,尾部是最久未访问的节点。
    • 两个哨兵节点:headtail,作为链表的头尾哨兵,可以简化边界条件的处理(如插入和删除时无需判断节点是否为 null)。
  3. 实现核心方法
    • get(key):
      1. 通过 HashMap 查找节点。
      2. 如果节点不存在,返回 null 或 -1。
      3. 如果节点存在,将该节点从链表中当前位置移除,并将其添加到链表头部(表示它刚刚被访问过)。最后返回节点的值。
    • put(key, value):
      1. 通过 HashMap 查找节点。
      2. 如果节点已存在(更新操作):
        • 更新节点的 value
        • 将该节点从链表中当前位置移除,并添加到链表头部。
      3. 如果节点不存在(新增操作):
        • 创建一个新节点。
        • 将新节点添加到链表头部。
        • 将新节点存入 HashMap
        • 检查缓存是否已满:如果 HashMap.size() > capacity,则执行淘汰操作。
      4. 淘汰操作 (evict) :
        • 获取链表尾部的节点(最久未使用)。
        • HashMap 中移除该节点对应的键。
        • 从链表中移除该节点。
  4. 辅助方法 :为了代码清晰,可以封装一些辅助方法,如 addToHead(Node node)removeNode(Node node)removeTail()
代码实现
复制代码
import java.util.HashMap;
import java.util.Map;

/**
 * 使用 HashMap + 双向链表实现的 LRU 缓存
 */
public class LRUCacheWithHashMapAndList {

    // 双向链表节点定义
    class Node {
        int key;
        int value;
        Node prev;
        Node next;

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

    private final int capacity;
    private final Map<Integer, Node> cacheMap;
    private final Node head; // 虚拟头节点
    private final Node tail; // 虚拟尾节点

    public LRUCacheWithHashMapAndList(int capacity) {
        this.capacity = capacity;
        this.cacheMap = new HashMap<>(capacity);
        // 初始化双向链表,使用虚拟头尾节点简化操作
        this.head = new Node(-1, -1);
        this.tail = new Node(-1, -1);
        head.next = tail;
        tail.prev = head;
    }

    public int get(int key) {
        Node node = cacheMap.get(key);
        if (node == null) {
            return -1; // 未找到
        }
        // 节点存在,将其移动到链表头部,表示最近访问
        moveToHead(node);
        return node.value;
    }

    public void put(int key, int value) {
        Node node = cacheMap.get(key);
        if (node != null) {
            // 节点已存在,更新值并移动到头部
            node.value = value;
            moveToHead(node);
        } else {
            // 节点不存在,创建新节点
            Node newNode = new Node(key, value);
            cacheMap.put(key, newNode);
            // 添加到链表头部
            addToHead(newNode);
            // 检查是否超出容量
            if (cacheMap.size() > capacity) {
                // 超出容量,移除尾部节点(最久未使用)
                Node tailNode = removeTail();
                cacheMap.remove(tailNode.key);
            }
        }
    }

    // --- 辅助方法 ---

    /**
     * 将节点添加到链表头部
     */
    private void addToHead(Node node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }

    /**
     * 从链表中移除指定节点
     */
    private void removeNode(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    /**
     * 将节点移动到链表头部
     */
    private void moveToHead(Node node) {
        removeNode(node);
        addToHead(node);
    }

    /**
     * 移除链表尾部节点,并返回该节点
     */
    private Node removeTail() {
        Node node = tail.prev;
        removeNode(node);
        return node;
    }

    // 为了演示,我们添加一个 main 方法
    public static void main(String[] args) {
        LRUCacheWithHashMapAndList cache = new LRUCacheWithHashMapAndList(3);

        System.out.println("------ 初始插入 1, 2, 3 ------");
        cache.put(1, 10);
        cache.put(2, 20);
        cache.put(3, 30);
        System.out.println("Get 1: " + cache.get(1)); // 输出 10,此时 1 变为最新

        System.out.println("\n------ 插入键 4,此时应淘汰最旧的键 2 ------");
        cache.put(4, 40);

        System.out.println("Get 1 (should be 10): " + cache.get(1)); // 1 还在
        System.out.println("Get 2 (should be -1): " + cache.get(2)); // 2 已被淘汰
        System.out.println("Get 3 (should be 30): " + cache.get(3)); // 3 还在
        System.out.println("Get 4 (should be 40): " + cache.get(4)); // 4 还在
    }
}
优点
  • 深入理解原理:完全手写一遍能让你对 LRU 的工作机制有透彻的理解。
  • 灵活性高:如果需求有变(例如实现 LFU),可以基于这个结构进行修改。

总结与对比

特性 LinkedHashMap 实现 HashMap + 双向链表 实现
代码量 非常少,核心逻辑只需几行 较多,需要定义节点和链表操作
实现难度 简单,只需了解 LinkedHashMap 特性 中等,需要熟练掌握链表和 HashMap
性能 优秀,与手写实现相当 优秀,所有操作均为 O(1)
适用场景 生产环境、日常开发 算法学习、技术面试
核心思想 利用 Java 库的现有功能 从零构建,展示底层原理