算法背景
LFU(Least Frequently Used,最不经常使用)缓存是计算机系统中经典的缓存淘汰策略。与LRU(最近最少使用)基于时间局部性不同,LFU基于频率局部性——认为被访问次数越多的数据越重要。LFU在数据库缓存、CDN内容分发、搜索引擎热点查询等场景中表现优异,因为它能有效识别真正的”热点数据”而非仅”最近数据”。
LeetCode 460题要求设计并实现一个满足O(1)时间复杂度的LFU缓存,这是数据结构设计中的高难度题目,需要巧妙结合双向链表、哈希表和频率桶结构。
核心思路
LFU的核心挑战在于:当缓存满时需要淘汰访问频率最低的数据,如果多个数据频率相同则淘汰最久未使用的。
三层数据结构:
1. keyToNode 哈希表:O(1)通过key找到节点
2. freqToList 哈希表:O(1)通过频率找到该频率下的所有节点(双向链表)
3. minFreq:记录当前最小频率,O(1)知道淘汰哪个频率桶
访问操作:
– 获取节点后,将其从旧频率链表移除,加入新频率链表
– 如果旧频率链表变空且等于minFreq,则minFreq++
插入操作:
– 新节点频率为1,加入freq=1的链表
– minFreq重置为1
淘汰操作:
– 直接淘汰minFreq对应链表的尾部节点(最久未使用)
完整Java实现
import java.util.HashMap;
import java.util.Map;
/**
* LFU缓存 - 最不经常使用缓存淘汰策略
* get和put操作时间复杂度均为O(1)
*/
public class LFUCache {
// 双向链表节点
static class Node {
int key, value, freq;
Node prev, next;
Node(int key, int value) {
this.key = key;
this.value = value;
this.freq = 1; // 新节点频率为1
}
}
// 双向链表(同一频率的节点集合)
static class DoublyLinkedList {
Node head, tail;
int size;
DoublyLinkedList() {
head = new Node(0, 0); // 哨兵头节点
tail = new Node(0, 0); // 哨兵尾节点
head.next = tail;
tail.prev = head;
size = 0;
}
// 在链表头部添加节点(最新使用)
void addToHead(Node node) {
node.next = head.next;
node.prev = head;
head.next.prev = node;
head.next = node;
size++;
}
// 移除指定节点
void remove(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
size--;
}
// 移除尾部节点(最久未使用)
Node removeTail() {
if (size == 0) return null;
Node node = tail.prev;
remove(node);
return node;
}
boolean isEmpty() {
return size == 0;
}
}
private final int capacity;
private int minFreq; // 当前最小频率
private final Map<Integer, Node> keyToNode; // key -> node
private final Map<Integer, DoublyLinkedList> freqToList; // freq -> list
public LFUCache(int capacity) {
this.capacity = capacity;
this.minFreq = 0;
this.keyToNode = new HashMap<>();
this.freqToList = new HashMap<>();
}
public int get(int key) {
if (!keyToNode.containsKey(key)) {
return -1;
}
Node node = keyToNode.get(key);
increaseFreq(node);
return node.value;
}
public void put(int key, int value) {
if (capacity <= 0) return;
if (keyToNode.containsKey(key)) {
// 更新已有节点
Node node = keyToNode.get(key);
node.value = value;
increaseFreq(node);
return;
}
// 新节点,检查是否需要淘汰
if (keyToNode.size() >= capacity) {
evict();
}
// 创建新节点,频率为1
Node newNode = new Node(key, value);
keyToNode.put(key, newNode);
freqToList.computeIfAbsent(1, k -> new DoublyLinkedList()).addToHead(newNode);
minFreq = 1; // 新节点频率为1,最小频率重置
}
/**
* 增加节点频率
*/
private void increaseFreq(Node node) {
int freq = node.freq;
DoublyLinkedList oldList = freqToList.get(freq);
oldList.remove(node);
// 如果旧频率链表为空且是当前最小频率,更新minFreq
if (oldList.isEmpty() && freq == minFreq) {
minFreq++;
}
// 加入新频率链表
node.freq++;
freqToList.computeIfAbsent(freq + 1, k -> new DoublyLinkedList()).addToHead(node);
}
/**
* 淘汰最小频率中最久未使用的节点
*/
private void evict() {
DoublyLinkedList list = freqToList.get(minFreq);
Node removed = list.removeTail();
if (removed != null) {
keyToNode.remove(removed.key);
}
}
public void printStatus() {
System.out.println("缓存状态: size=" + keyToNode.size() + ", minFreq=" + minFreq);
for (Map.Entry<Integer, DoublyLinkedList> entry : freqToList.entrySet()) {
System.out.print(" freq=" + entry.getKey() + ": ");
Node cur = entry.getValue().head.next;
while (cur != entry.getValue().tail) {
System.out.print("[" + cur.key + "=" + cur.value + "] ");
cur = cur.next;
}
System.out.println();
}
}
public static void main(String[] args) {
System.out.println("=== LFU缓存测试 ===\n");
LFUCache cache = new LFUCache(3);
// 插入三个元素
cache.put(1, 10);
cache.put(2, 20);
cache.put(3, 30);
System.out.println("插入1,2,3后:");
cache.printStatus();
// 访问1和2各两次,3不访问
cache.get(1);
cache.get(1);
cache.get(2);
cache.get(2);
System.out.println("\n访问1两次、2两次后:");
cache.printStatus();
// 插入4,应该淘汰频率最低的3
cache.put(4, 40);
System.out.println("\n插入4(淘汰频率最低的3)后:");
cache.printStatus();
// 访问4一次,频率变为2
cache.get(4);
System.out.println("\n访问4一次后:");
cache.printStatus();
// 插入5,此时minFreq=1的链表为空,最小频率是2
// 淘汰频率2中最久未使用的(1或2)
cache.put(5, 50);
System.out.println("\n插入5后:");
cache.printStatus();
// 验证get
System.out.println("\nget(1)=" + cache.get(1));
System.out.println("get(3)=" + cache.get(3)); // 已被淘汰
}
}
复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| get | O(1) | O(1) | 哈希表查找 + 链表移动 |
| put | O(1) | O(1) | 哈希表操作 + 链表操作 |
| 淘汰 | O(1) | O(1) | 直接访问minFreq对应链表尾部 |
总空间复杂度为O(capacity),因为缓存最多存储capacity个节点。
LFU vs LRU 对比
| 特性 | LFU | LRU |
|---|---|---|
| 淘汰依据 | 访问频率最低 | 最久未访问 |
| 适用场景 | 热点数据稳定 | 局部性明显 |
| 对新数据友好度 | 较差(冷启动问题) | 较好 |
| 实现复杂度 | 较高(三层结构) | 较低(两层结构) |
| 时间复杂度 | O(1) | O(1) |
LFU的冷启动问题:新数据频率为1,容易被快速淘汰。解决方案是引入”老化机制”,定期衰减所有频率计数。
扩展思考
- 频率衰减:每隔一段时间将所有频率减半,避免历史热点长期占据缓存。
- W-TinyLFU:Caffeine缓存使用的现代LFU变体,结合窗口LFU和分段LRU,在真实工作负载上表现更优。
- 分布式LFU:在分布式缓存中,需要合并各节点的访问频率统计,可用Gossip协议或集中式计数器。