LRU实现
使用 Java 实现 LRU(Least Recently Used,最近最少使用)缓存是一个非常经典的面试题,它考验了候选人对数据结构、面向对象设计以及 Java 集合框架的掌握程度。
LRU 缓存的核心思想
LRU 缓存的核心思想是“如果数据最近被访问过,那么它在将来被访问的概率也更高”。当缓存容量已满,需要插入新数据时,它会淘汰最久未被使用的数据。
为了实现这个逻辑,我们需要满足两个基本操作:
- 快速查找:当访问一个数据时,需要能够快速判断它是否在缓存中,并且要能快速地更新它的“最近使用”状态。
HashMap
的平均 O(1) 时间复杂度的查找非常适合。 - 快速排序/维护顺序:我们需要一种结构来记录所有数据的“最近使用”顺序,并且当数据被访问或更新时,能快速地将其移动到“最新”的位置,当需要淘汰时,能快速地找到“最旧”的数据。双向链表非常适合这个场景,因为它可以在 O(1) 时间内完成节点的插入和删除。
方法一:使用 LinkedHashMap
实现(标准做法)
LinkedHashMap
是 Java 集合框架中一个特殊的类,它继承自 HashMap
,并在其基础上维护了一个双向链表,这个链表记录了元素的插入顺序或访问顺序。
我们可以利用这个“访问顺序”特性来非常轻松地实现 LRU 缓存。
实现步骤
- 继承
LinkedHashMap
:创建一个类LRUCache<K, V>
继承LinkedHashMap<K, V>
。 - 设置访问顺序:在构造函数中,调用父类
LinkedHashMap
的构造函数,并将accessOrder
参数设置为true
。这会让链表按照元素的访问顺序(从最近访问到最久未访问)进行排序,而不是插入顺序。 - 重写
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 如果当前大小超过了最大容量,则移除最旧元素*/@Overrideprotected 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 的原理。
实现步骤
- 定义双向链表节点:创建一个
Node
类,包含key
,value
,prev
(前驱指针)和next
(后继指针)。 - 定义数据结构:
- 一个
HashMap<Integer, Node>
用于存储键到节点的映射,实现 O(1) 时间复杂度的查找。 - 一个双向链表,用于维护节点的访问顺序。链表头部是最新访问的节点,尾部是最久未访问的节点。
- 两个哨兵节点:
head
和tail
,作为链表的头尾哨兵,可以简化边界条件的处理(如插入和删除时无需判断节点是否为 null)。
- 一个
- 实现核心方法:
get(key)
:- 通过
HashMap
查找节点。 - 如果节点不存在,返回
null
或 -1。 - 如果节点存在,将该节点从链表中当前位置移除,并将其添加到链表头部(表示它刚刚被访问过)。最后返回节点的值。
- 通过
put(key, value)
:- 通过
HashMap
查找节点。 - 如果节点已存在(更新操作):
- 更新节点的
value
。 - 将该节点从链表中当前位置移除,并添加到链表头部。
- 更新节点的
- 如果节点不存在(新增操作):
- 创建一个新节点。
- 将新节点添加到链表头部。
- 将新节点存入
HashMap
。 - 检查缓存是否已满:如果
HashMap.size() > capacity
,则执行淘汰操作。
- 淘汰操作 (
evict
):- 获取链表尾部的节点(最久未使用)。
- 从
HashMap
中移除该节点对应的键。 - 从链表中移除该节点。
- 通过
- 辅助方法:为了代码清晰,可以封装一些辅助方法,如
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 库的现有功能 | 从零构建,展示底层原理 |