一、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;
// 双向链表,存储 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 更复杂,需要巧妙地组合多个哈希表:
-
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 -> 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: 关注访问频率,并结合时间作为次要淘汰标准(频率相同时淘汰最旧的)。实现更复杂,需要维护频率信息和访问时序。适合需要保留高频访问数据,即使它不是最近访问的场景。