1966年,计算机科学家们在设计操作系统时面临一个核心问题:当内存空间有限而需要存储的数据量无限增长时,应该删除哪些数据?这个看似简单的问题催生了缓存淘汰策略的研究。在众多策略中,LRU(Least Recently Used,最近最少使用)因其简单直观的思想和优秀的实际表现,成为应用最广泛的缓存淘汰算法之一。
今天,LRU缓存不仅出现在操作系统的页面置换中,还广泛应用于数据库的缓冲池管理、Web服务器的缓存系统、浏览器的资源缓存等场景。LeetCode第146题"LRU Cache"更是高频面试题,考察的是对数据结构设计的深刻理解。
为什么需要缓存淘汰策略
缓存的核心价值在于用空间换时间——将频繁访问的数据存储在更快的存储介质中,从而减少访问延迟。但缓存容量永远有限,当新数据到来而缓存已满时,必须决定淘汰哪个旧数据。
选择淘汰策略需要回答一个关键问题:什么样的数据"更有价值",应该保留在缓存中?不同的策略给出了不同的答案:
- FIFO(First In First Out):先进入缓存的先被淘汰。实现简单,但完全忽略了数据的访问模式。
- LFU(Least Frequently Used):淘汰访问次数最少的数据。适合访问模式稳定的场景,但对突发性访问不够敏感。
- LRU(Least Recently Used):淘汰最近最少被访问的数据。假设"最近被访问的数据在未来也更有可能被访问"。
LRU的假设源于程序的局部性原理(Locality of Reference)——程序在一段时间内往往集中访问某一局部区域的数据。这个原理在1968年由Peter Denning正式提出,成为计算机系统设计的基石之一。
LRU算法的核心操作
LRU缓存需要支持两个核心操作:
get(key):查询操作。如果key存在于缓存中,返回其value,并将该key标记为"最近使用";否则返回-1。
put(key, value):插入或更新操作。如果key已存在,更新其value并标记为"最近使用";如果不存在,插入新的键值对。如果插入后超过容量,淘汰"最近最少使用"的key。
关键挑战在于:两个操作的时间复杂度都必须是O(1)。
让我们分析要实现O(1)需要哪些条件:
- 快速查找:判断key是否存在,并获取其value
- 快速删除:淘汰时能快速删除某个元素
- 维护顺序:区分"最近使用"和"最近最少使用"
单一数据结构无法同时满足这三个条件。哈希表查找快但没有顺序;链表有序但查找慢。解决方案是组合两种数据结构。
数据结构设计:HashMap + 双向链表
核心思想是让两种数据结构各司其职:
- HashMap:提供O(1)的key查找能力
- 双向链表:维护访问顺序,支持O(1)的插入和删除
┌─────────────────────────────────────────────────────────────┐
│ HashMap │
│ key → Node pointer (for O(1) lookup) │
│ │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ 1 │ │ 2 │ │ 3 │ │ 4 │ │
│ └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Doubly Linked List │ │
│ │ HEAD ⟷ [1:A] ⟷ [2:B] ⟷ [3:C] ⟷ [4:D] ⟷ TAIL │ │
│ │ ↑ ↑ │ │
│ │ MRU LRU │ │
│ │ (Most Recently Used) (Least Recently Used) │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
链表头部(HEAD端)存放"最近使用"的数据,尾部(TAIL端)存放"最近最少使用"的数据。
为什么必须是双向链表
单向链表删除节点时,需要知道前驱节点,这需要从头遍历,时间复杂度为O(n)。双向链表的每个节点同时存储前驱和后继指针,删除操作可以在O(1)内完成——这正是我们需要的。
哑节点的设计技巧
为了简化边界处理,我们引入两个哑节点:head和tail,分别代表链表的虚拟头尾。实际数据节点位于head和tail之间。这样,无论链表是否为空,插入和删除操作都可以统一处理,无需特殊判断。
// 初始化
head = new Node(0, 0);
tail = new Node(0, 0);
head.next = tail;
tail.prev = head;
为什么节点需要存储key
这是一个容易被忽略的细节。当缓存满时,我们需要淘汰tail.prev处的节点,同时需要从HashMap中删除对应的key。如果节点只存储value,我们无法知道要删除哪个key。
// 错误示例:无法从HashMap中删除
Node lru = tail.prev;
removeNode(lru);
// map.remove(???) // 不知道key是什么
// 正确做法:节点存储key
Node lru = tail.prev;
removeNode(lru);
map.remove(lru.key); // 可以正确删除
LeetCode 146:LRU Cache 完整Java实现
import java.util.HashMap;
import java.util.Map;
// 双向链表节点定义
class DoublyLinkedNode {
int key;
int value;
DoublyLinkedNode prev;
DoublyLinkedNode next;
public DoublyLinkedNode(int key, int value) {
this.key = key;
this.value = value;
}
}
public class LRUCache {
private final Map<Integer, DoublyLinkedNode> cacheMap;
private final DoublyLinkedNode head; // 哑头节点
private final DoublyLinkedNode tail; // 哑尾节点
private final int capacity;
private int size;
public LRUCache(int capacity) {
this.capacity = capacity;
this.cacheMap = new HashMap<>();
this.size = 0;
// 初始化哑节点
head = new DoublyLinkedNode(0, 0);
tail = new DoublyLinkedNode(0, 0);
head.next = tail;
tail.prev = head;
}
// 获取key对应的value
public int get(int key) {
if (!cacheMap.containsKey(key)) {
return -1;
}
DoublyLinkedNode node = cacheMap.get(key);
moveToHead(node); // 标记为最近使用
return node.value;
}
// 插入或更新键值对
public void put(int key, int value) {
if (cacheMap.containsKey(key)) {
// key已存在,更新value并移到头部
DoublyLinkedNode node = cacheMap.get(key);
node.value = value;
moveToHead(node);
} else {
// key不存在,创建新节点
DoublyLinkedNode newNode = new DoublyLinkedNode(key, value);
cacheMap.put(key, newNode);
addToHead(newNode);
size++;
// 超出容量,淘汰LRU
if (size > capacity) {
DoublyLinkedNode lru = removeTail();
cacheMap.remove(lru.key);
size--;
}
}
}
// 将节点移到头部(标记为最近使用)
private void moveToHead(DoublyLinkedNode node) {
removeNode(node);
addToHead(node);
}
// 从链表中删除节点
private void removeNode(DoublyLinkedNode node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
// 将节点添加到头部
private void addToHead(DoublyLinkedNode node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
// 删除尾部节点(LRU节点)
private DoublyLinkedNode removeTail() {
DoublyLinkedNode lru = tail.prev;
removeNode(lru);
return lru;
}
}
执行流程示例
以题目示例为例:LRUCache(2),然后依次执行 put(1,1), put(2,2), get(1), put(3,3), get(2), put(4,4), get(1), get(3), get(4):
Step 1: LRUCache(2)
Cache: 空
Step 2: put(1, 1)
Cache: [1:1] (MRU)
Step 3: put(2, 2)
Cache: [2:2] ⟷ [1:1]
MRU LRU
Step 4: get(1) → 返回 1
Cache: [1:1] ⟷ [2:2] // 1被移到头部
MRU LRU
Step 5: put(3, 3) → 淘汰key=2
Cache: [3:3] ⟷ [1:1]
MRU LRU
Step 6: get(2) → 返回 -1 (已被淘汰)
Step 7: put(4, 4) → 淘汰key=1
Cache: [4:4] ⟷ [3:3]
Step 8: get(1) → 返回 -1 (已被淘汰)
Step 9: get(3) → 返回 3
Step 10: get(4) → 返回 4
时间复杂度与空间复杂度
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| get(key) | O(1) | HashMap查找 + 链表指针操作 |
| put(key, value) | O(1) | HashMap操作 + 链表指针操作 |
| 空间复杂度 | O(capacity) | 最多存储capacity个节点 |
Java LinkedHashMap的巧妙实现
Java标准库中的LinkedHashMap本质上就是HashMap与双向链表的结合体。通过覆盖removeEldestEntry方法,可以一行代码实现LRU缓存:
import java.util.LinkedHashMap;
public class LRUCacheSimple extends LinkedHashMap<Integer, Integer> {
private final int capacity;
public LRUCacheSimple(int capacity) {
super(capacity, 0.75f, true); // accessOrder=true表示按访问顺序排序
this.capacity = capacity;
}
public int get(int key) {
return super.getOrDefault(key, -1);
}
public void put(int key, int value) {
super.put(key, value);
}
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > capacity; // 返回true时自动删除最老的条目
}
}
注意:面试时面试官可能要求手写实现,而非使用内置类。
LRU vs LFU vs FIFO:如何选择
不同的缓存淘汰策略各有优劣,选择取决于具体场景:
| 策略 | 淘汰依据 | 适用场景 | 缺陷 |
|---|---|---|---|
| LRU | 最近访问时间 | 热点数据随时间变化的场景 | 对突发性大量访问可能缓存污染 |
| LFU | 访问频率 | 热点数据稳定的场景 | 历史热点可能长期占用缓存 |
| FIFO | 进入顺序 | 访问模式随机或简单场景 | 完全忽略访问模式 |
LRU的缺陷:缓存污染
考虑一个场景:顺序扫描一个远大于缓存容量的数据集。每个数据只会被访问一次,但LRU会不断淘汰旧数据装入新数据,而这些都是"一次性"数据,不会再次访问。这种现象称为缓存污染。
为此,数据库和操作系统引入了改进版本:
- LRU-K:考虑最近K次访问的时间,过滤掉一次性访问
- 2Q(Two Queues):使用两个队列区分热数据和冷数据
- ARC(Adaptive Replacement Cache):动态调整LRU和LFU的混合比例
LeetCode 460:LFU Cache 进阶
LFU在LRU基础上增加了频率维度的考量:当需要淘汰时,优先淘汰访问频率最低的数据;如果存在多个频率相同的,则淘汰其中最近最少使用的。这要求我们维护两层顺序:频率顺序和同一频率内的访问时间顺序。
数据结构设计
- key到节点的映射:
Map<Integer, Node> keyMap - 频率到节点链表的映射:
Map<Integer, DoubleLinkedList> freqMap - 全局最小频率:
minFreq,用于快速定位淘汰目标
Java实现
import java.util.HashMap;
import java.util.Map;
class Node {
int key, value, freq;
Node prev, next;
public Node(int key, int value) {
this.key = key;
this.value = value;
this.freq = 1;
}
}
class DoubleLinkedList {
Node head, tail;
int size;
public DoubleLinkedList() {
head = new Node(0, 0);
tail = new Node(0, 0);
head.next = tail;
tail.prev = head;
}
public void addToHead(Node node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
size++;
}
public void removeNode(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
size--;
}
public Node removeTail() {
if (size == 0) return null;
Node node = tail.prev;
removeNode(node);
return node;
}
public boolean isEmpty() {
return size == 0;
}
}
public class LFUCache {
private final Map<Integer, Node> keyMap;
private final Map<Integer, DoubleLinkedList> freqMap;
private final int capacity;
private int minFreq;
public LFUCache(int capacity) {
this.capacity = capacity;
this.keyMap = new HashMap<>();
this.freqMap = new HashMap<>();
this.minFreq = 0;
}
public int get(int key) {
if (!keyMap.containsKey(key)) {
return -1;
}
Node node = keyMap.get(key);
increaseFreq(node);
return node.value;
}
public void put(int key, int value) {
if (capacity == 0) return;
if (keyMap.containsKey(key)) {
Node node = keyMap.get(key);
node.value = value;
increaseFreq(node);
} else {
if (keyMap.size() >= capacity) {
evict();
}
Node newNode = new Node(key, value);
keyMap.put(key, newNode);
freqMap.computeIfAbsent(1, k -> new DoubleLinkedList()).addToHead(newNode);
minFreq = 1;
}
}
private void increaseFreq(Node node) {
int oldFreq = node.freq;
DoubleLinkedList oldList = freqMap.get(oldFreq);
oldList.removeNode(node);
if (oldFreq == minFreq && oldList.isEmpty()) {
minFreq++;
}
node.freq++;
freqMap.computeIfAbsent(node.freq, k -> new DoubleLinkedList()).addToHead(node);
}
private void evict() {
DoubleLinkedList minFreqList = freqMap.get(minFreq);
Node node = minFreqList.removeTail();
keyMap.remove(node.key);
}
}
LFU时间复杂度
| 操作 | 时间复杂度 |
|---|---|
| get(key) | O(1) |
| put(key, value) | O(1) |
虽然LFU比LRU多维护了频率信息,但通过精巧的数据结构设计,依然可以实现O(1)操作。
实际应用:操作系统与数据库中的LRU
操作系统页面置换
当物理内存不足时,操作系统需要将部分内存页换出到磁盘。LRU是一种经典的页面置换策略。但精确实现LRU需要维护每个页面的访问时间戳,开销过大。因此实际系统使用近似算法:
- Clock算法:环形链表 + 访问位,模拟LRU行为
- Clock-Pro:引入重用距离的概念,进一步优化
MySQL InnoDB缓冲池
MySQL的InnoDB存储引擎使用变体LRU管理缓冲池。为了避免全表扫描污染缓存,InnoDB将LRU链表分为两部分:
- Young区域(前5/8):存放频繁访问的热数据
- Old区域(后3/8):新读取的页先进入这里
只有当数据在Old区域被再次访问,才会晋升到Young区域。这种设计有效防止了一次性扫描数据挤占缓存。
┌─────────────────────────────────────────────────────┐
│ InnoDB Buffer Pool LRU │
│ │
│ ┌──────────────────────┐ ┌─────────────────────┐ │
│ │ Young Region │ │ Old Region │ │
│ │ (热数据) │ │ (新数据入口) │ │
│ │ 5/8 容量 │ │ 3/8 容量 │ │
│ └──────────────────────┘ └─────────────────────┘ │
│ MRU Midpoint LRU│
└─────────────────────────────────────────────────────┘
图片来源: MySQL Documentation
面试技巧与常见陷阱
常见错误
- 忘记在put时移动已存在key的位置:更新value后必须移到头部
- 淘汰顺序错误:应该先淘汰再插入新节点,否则可能淘汰刚插入的数据
- 指针操作顺序错误:链表操作时先保存后继节点,避免丢失引用
手写代码检查清单
- 哑节点正确初始化:
head.next = tail; tail.prev = head - 节点类包含key字段
- get操作成功时移到头部
- put更新已存在key时移到头部
- 淘汰时同时删除链表节点和HashMap条目
相关LeetCode题目一览
| 题号 | 题目 | 难度 | 核心考点 |
|---|---|---|---|
| 146 | LRU Cache | 中等 | HashMap + 双向链表 |
| 460 | LFU Cache | 困难 | 多层频率映射 |
| 432 | All O`one Data Structure | 困难 | 双向链表 + 嵌套结构 |
| 355 | Design Twitter | 中等 | 多路归并 + 缓存思想 |
总结
LRU缓存算法的核心在于HashMap与双向链表的精妙结合:HashMap提供O(1)的查找能力,双向链表维护访问顺序并支持O(1)的插入删除。理解这个设计,不仅是为了通过面试,更是掌握了一种将两种基础数据结构组合解决复杂问题的思维方式。
从操作系统的页面置换到数据库的缓冲池管理,从Web服务器的资源缓存到分布式系统的热点数据调度,LRU的思想无处不在。而LFU等变体算法则展示了如何在此基础上引入新的维度,解决更复杂的问题。
当你下次在面试中遇到"设计一个缓存"这样的问题时,希望这篇文章能帮助你从容应对——不仅写出正确的代码,更能讲清楚背后的设计权衡。
参考资料
- O’Neil, E. J., O’Neil, P. E., & Weikum, G. (1993). The LRU-K page replacement algorithm for database disk buffering. ACM SIGMOD Record, 22(2), 297-306.
- Denning, P. J. (1968). The working set model for program behavior. Communications of the ACM, 11(5), 323-333.
- Cache replacement policies - Wikipedia: https://en.wikipedia.org/wiki/Cache_replacement_policies
- MySQL 9.6 Reference Manual - Making the Buffer Pool Scan Resistant: https://dev.mysql.com/doc/refman/9.6/en/innodb-performance-midpoint_insertion.html
- GeeksforGeeks - LRU Cache Implementation: https://www.geeksforgeeks.org/dsa/lru-cache-implementation-using-double-linked-lists/
- Baeldung - How to Implement LRU Cache in Java: https://www.baeldung.com/java-lru-cache