深入理解缓存淘汰策略:LRU 与 LFU 算法详解及 Java 实现
一、LRU (Least Recently Used - 最近最少使用)
LRU 策略的核心思想是:当缓存空间不足时,优先淘汰最近最长时间未被访问的数据。它基于“时间局部性”原理,即最近被访问的数据,在未来被访问的概率也更高。
LeetCode 146. LRU 缓存机制
这道题要求我们设计并实现一个满足 LRU 约束的数据结构。
核心思路:哈希表 + 双向链表
为了同时满足快速查找(get 操作)和快速增删(维护访问顺序)的需求,我们采用“哈希表 + 双向链表”的组合:
-
HashMap<Integer, Node>: 哈希表用于存储键(Key)到链表节点(Node)的映射。这使得我们能够以 O(1) 的平均时间复杂度通过 Key 快速定位到链表中的节点。
-
双向链表 (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;// 双向链表,存储 Nodeprivate 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 更复杂,需要巧妙地组合多个哈希表:
-
HashMap<Integer, Integer> keyToVal: 存储 Key 到 Value 的映射,用于 O(1) 获取值。
-
HashMap<Integer, Integer> keyToFreq: 存储 Key 到其访问频次(Frequency)的映射,用于 O(1) 获取和更新 Key 的频次。
-
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 是最佳选择。
-
-
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 -> valueHashMap<Integer, Integer> keyToVal;// key -> frequencyHashMap<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 已存在,更新 valuekeyToVal.put(key, value);// 增加 key 对应的频次increaseFreq(key);// 注意:LFU 的更新操作仅涉及更新值和增加频率,不需要像 LRU 那样显式地“移动”} else {// key 不存在,需要插入新 key// 检查容量是否已满if (this.cap <= keyToVal.size()) {// 容量已满,需要淘汰一个 keyremoveMinFreqKey();}// 插入新的 key 和 valuekeyToVal.put(key, value);// 新 key 的初始频次为 1keyToFreq.put(key, 1);// 将新 key 加入频次为 1 的集合中freqToKeys.putIfAbsent(1, new LinkedHashSet<>());freqToKeys.get(1).add(key);// 插入新 key 后,最小频次一定是 1this.minFreq = 1;}}/* 增加 key 对应的频次 */private void increaseFreq(int key) {int freq = keyToFreq.get(key); // 获取当前频次// 更新 key 的频次keyToFreq.put(key, freq + 1);// 从旧频次的 key 集合中移除 keyLinkedHashSet<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,则需要更新 minFreqif (freq == this.minFreq) {// 新的 minFreq 变成了 freq + 1this.minFreq++;}}}/* 淘汰一个最小频次且最旧的 key */private void removeMinFreqKey() {// 获取最小频次对应的 key 集合 (按插入顺序)LinkedHashSet<Integer> keyList = freqToKeys.get(this.minFreq);// 第一个元素就是最旧的 keyint 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 中移除该 keykeyToVal.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: 关注访问频率,并结合时间作为次要淘汰标准(频率相同时淘汰最旧的)。实现更复杂,需要维护频率信息和访问时序。适合需要保留高频访问数据,即使它不是最近访问的场景。