LRU 缓存
问题
设计并实现一个 LRU(最近最少使用)缓存 机制。
答案
LRU(Least Recently Used)是一种缓存淘汰策略:当缓存满时,删除最久未使用的数据。
核心要求
get(key):获取缓存值,若存在则更新为"最近使用"put(key, value):设置缓存值,若超出容量则淘汰最久未使用的- 两个操作的时间复杂度都要求
方案一:使用 Map(ES6)
ES6 的 Map 会保持插入顺序,可以利用这个特性实现 LRU:
class LRUCache<T> {
private cache: Map<string, T>;
private capacity: number;
constructor(capacity: number) {
this.capacity = capacity;
this.cache = new Map();
}
get(key: string): T | undefined {
if (!this.cache.has(key)) {
return undefined;
}
// 获取值并移到最后(最近使用)
const value = this.cache.get(key)!;
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
put(key: string, value: T): void {
// 如果已存在,先删除
if (this.cache.has(key)) {
this.cache.delete(key);
}
// 如果达到容量,删除最久未使用的(第一个)
else if (this.cache.size >= this.capacity) {
const [oldestKey] = this.cache.keys();
this.cache.delete(oldestKey);
}
this.cache.set(key, value);
}
// 获取缓存大小
get size(): number {
return this.cache.size;
}
// 清空缓存
clear(): void {
this.cache.clear();
}
// 检查是否存在
has(key: string): boolean {
return this.cache.has(key);
}
}
// 测试
const cache = new LRUCache<number>(2);
cache.put('a', 1);
cache.put('b', 2);
console.log(cache.get('a')); // 1
cache.put('c', 3); // 淘汰 'b'
console.log(cache.get('b')); // undefined
console.log(cache.get('c')); // 3
方案二:双向链表 + 哈希表(面试标准答案)
这是面试官期望的经典实现,需要手写双向链表:
完整实现
// 双向链表节点
class ListNode<K, V> {
key: K;
value: V;
prev: ListNode<K, V> | null = null;
next: ListNode<K, V> | null = null;
constructor(key: K, value: V) {
this.key = key;
this.value = value;
}
}
class LRUCacheLinkedList<V> {
private capacity: number;
private cache: Map<string, ListNode<string, V>>;
private head: ListNode<string, V>; // 哨兵头节点
private tail: ListNode<string, V>; // 哨兵尾节点
constructor(capacity: number) {
this.capacity = capacity;
this.cache = new Map();
// 初始化哨兵节点
this.head = new ListNode<string, V>('' as string, null as unknown as V);
this.tail = new ListNode<string, V>('' as string, null as unknown as V);
this.head.next = this.tail;
this.tail.prev = this.head;
}
get(key: string): V | undefined {
const node = this.cache.get(key);
if (!node) {
return undefined;
}
// 移动到头部(最近使用)
this.moveToHead(node);
return node.value;
}
put(key: string, value: V): void {
const existingNode = this.cache.get(key);
if (existingNode) {
// 更新值并移到头部
existingNode.value = value;
this.moveToHead(existingNode);
} else {
// 创建新节点
const newNode = new ListNode(key, value);
// 达到容量,移除尾部节点
if (this.cache.size >= this.capacity) {
const tailNode = this.removeTail();
this.cache.delete(tailNode.key);
}
// 添加到头部
this.addToHead(newNode);
this.cache.set(key, newNode);
}
}
// 添加节点到头部
private addToHead(node: ListNode<string, V>): void {
node.prev = this.head;
node.next = this.head.next;
this.head.next!.prev = node;
this.head.next = node;
}
// 移除节点
private removeNode(node: ListNode<string, V>): void {
node.prev!.next = node.next;
node.next!.prev = node.prev;
}
// 移动节点到头部
private moveToHead(node: ListNode<string, V>): void {
this.removeNode(node);
this.addToHead(node);
}
// 移除尾部节点
private removeTail(): ListNode<string, V> {
const tailNode = this.tail.prev!;
this.removeNode(tailNode);
return tailNode;
}
// 获取所有缓存(按使用顺序)
entries(): Array<[string, V]> {
const result: Array<[string, V]> = [];
let current = this.head.next;
while (current !== this.tail) {
result.push([current!.key, current!.value]);
current = current!.next;
}
return result;
}
}
// 测试
const lru = new LRUCacheLinkedList<number>(3);
lru.put('a', 1);
lru.put('b', 2);
lru.put('c', 3);
console.log(lru.entries()); // [['c', 3], ['b', 2], ['a', 1]]
lru.get('a');
console.log(lru.entries()); // [['a', 1], ['c', 3], ['b', 2]]
lru.put('d', 4); // 淘汰 'b'
console.log(lru.entries()); // [['d', 4], ['a', 1], ['c', 3]]
方案对比
| 方案 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|
| Map | 代码简洁,利用 Map 特性 | 依赖 JS 引擎实现 | ||
| 双向链表 + 哈希表 | 标准实现,面试首选 | 代码较多 |
高级功能
带过期时间的 LRU
interface CacheItem<V> {
value: V;
expireAt: number;
}
class LRUCacheWithTTL<V> {
private cache: Map<string, CacheItem<V>>;
private capacity: number;
private defaultTTL: number;
constructor(capacity: number, defaultTTL: number = 60000) {
this.capacity = capacity;
this.cache = new Map();
this.defaultTTL = defaultTTL;
}
get(key: string): V | undefined {
if (!this.cache.has(key)) {
return undefined;
}
const item = this.cache.get(key)!;
// 检查是否过期
if (Date.now() > item.expireAt) {
this.cache.delete(key);
return undefined;
}
// 更新为最近使用
this.cache.delete(key);
this.cache.set(key, item);
return item.value;
}
put(key: string, value: V, ttl?: number): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.capacity) {
// 先清理过期项
this.cleanup();
// 仍然满则删除最旧的
if (this.cache.size >= this.capacity) {
const [oldestKey] = this.cache.keys();
this.cache.delete(oldestKey);
}
}
this.cache.set(key, {
value,
expireAt: Date.now() + (ttl ?? this.defaultTTL),
});
}
private cleanup(): void {
const now = Date.now();
for (const [key, item] of this.cache) {
if (now > item.expireAt) {
this.cache.delete(key);
}
}
}
}
带统计的 LRU
interface CacheStats {
hits: number;
misses: number;
hitRate: number;
}
class LRUCacheWithStats<V> {
private cache: Map<string, V>;
private capacity: number;
private hits: number = 0;
private misses: number = 0;
constructor(capacity: number) {
this.capacity = capacity;
this.cache = new Map();
}
get(key: string): V | undefined {
if (!this.cache.has(key)) {
this.misses++;
return undefined;
}
this.hits++;
const value = this.cache.get(key)!;
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
put(key: string, value: V): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.capacity) {
const [oldestKey] = this.cache.keys();
this.cache.delete(oldestKey);
}
this.cache.set(key, value);
}
getStats(): CacheStats {
const total = this.hits + this.misses;
return {
hits: this.hits,
misses: this.misses,
hitRate: total > 0 ? this.hits / total : 0,
};
}
resetStats(): void {
this.hits = 0;
this.misses = 0;
}
}
LeetCode 146: LRU 缓存(标准答案)
class LRUCacheLeetCode {
private capacity: number;
private cache: Map<number, number>;
constructor(capacity: number) {
this.capacity = capacity;
this.cache = new Map();
}
get(key: number): number {
if (!this.cache.has(key)) {
return -1;
}
const value = this.cache.get(key)!;
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
put(key: number, value: number): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.capacity) {
const [firstKey] = this.cache.keys();
this.cache.delete(firstKey);
}
this.cache.set(key, value);
}
}
常见面试问题
Q1: 为什么使用双向链表而不是单向链表?
答案:
| 操作 | 双向链表 | 单向链表 |
|---|---|---|
| 删除节点 | ,直接访问 prev | ,需要遍历找前驱 |
| 移动到头部 |
单向链表删除节点需要找到前驱节点,必须遍历;双向链表可直接通过 node.prev 访问。
Q2: 为什么要用哨兵节点?
答案:
// 没有哨兵节点:需要大量边界判断
function removeNode(node: ListNode<string, number>): void {
if (node.prev) {
node.prev.next = node.next;
} else {
// node 是头节点,需要特殊处理
this.head = node.next;
}
if (node.next) {
node.next.prev = node.prev;
} else {
// node 是尾节点,需要特殊处理
this.tail = node.prev;
}
}
// 有哨兵节点:代码简洁
function removeNodeWithSentinel(node: ListNode<string, number>): void {
node.prev!.next = node.next;
node.next!.prev = node.prev;
}
哨兵节点避免了头尾的空指针判断,简化代码。
Q3: LRU 和 LFU 的区别?
答案:
| 策略 | 全称 | 淘汰依据 | 适用场景 |
|---|---|---|---|
| LRU | Least Recently Used | 最久未使用 | 时间局部性强 |
| LFU | Least Frequently Used | 使用频率最低 | 频率分布稳定 |
// LFU 简单实现
class LFUCache<V> {
private cache: Map<string, { value: V; freq: number }>;
private capacity: number;
constructor(capacity: number) {
this.capacity = capacity;
this.cache = new Map();
}
get(key: string): V | undefined {
const item = this.cache.get(key);
if (!item) return undefined;
item.freq++;
return item.value;
}
put(key: string, value: V): void {
if (this.cache.has(key)) {
const item = this.cache.get(key)!;
item.value = value;
item.freq++;
return;
}
if (this.cache.size >= this.capacity) {
// 找到频率最低的
let minFreq = Infinity;
let minKey = '';
for (const [k, v] of this.cache) {
if (v.freq < minFreq) {
minFreq = v.freq;
minKey = k;
}
}
this.cache.delete(minKey);
}
this.cache.set(key, { value, freq: 1 });
}
}
Q4: 为什么 LRU 缓存要用双向链表 + 哈希表?只用 Map 不行吗?
答案:
在 JavaScript 中,Map 确实维护了键的插入顺序,可以通过"删除再重新插入"的方式模拟 LRU。但这种做法依赖的是 JS 引擎的实现细节,而非语言规范对性能的保证。
只用 Map 的简版 LRU:
class SimpleLRU<V> {
private cache: Map<string, V>;
private capacity: number;
constructor(capacity: number) {
this.capacity = capacity;
this.cache = new Map();
}
get(key: string): V | undefined {
if (!this.cache.has(key)) return undefined;
const value = this.cache.get(key)!;
// "删除再插入" 将 key 移到 Map 末尾(最近使用)
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
put(key: string, value: V): void {
if (this.cache.has(key)) this.cache.delete(key);
else if (this.cache.size >= this.capacity) {
// 获取第一个 key(最久未使用)
const [oldestKey] = this.cache.keys();
this.cache.delete(oldestKey);
}
this.cache.set(key, value);
}
}
为什么面试中仍推荐双向链表 + 哈希表:
| 对比维度 | Map 方案 | 双向链表 + 哈希表 |
|---|---|---|
get 操作 | delete + set(两次哈希操作) | 链表节点移动, 指针操作 |
put 淘汰 | keys().next() 获取最旧 key | 直接访问 tail.prev, |
| 移动到头部 | 必须删除再插入(可能触发 rehash) | 修改 4 个指针,无需 rehash |
| 跨语言通用性 | 仅 JS/TS(依赖 Map 有序特性) | 任何语言都可实现(C、Java、Go) |
| 面试考察点 | 考察 JS API 熟练度 | 考察数据结构设计能力 |
面试建议
- 先写 Map 版本展示你对 JS 的熟练度,再写双向链表版本展示数据结构功底
- 面试官问"只用 Map 行不行"时,回答"JS 中可以,但标准做法用双向链表",并解释原因
Q5: 如何给 LRU 缓存加上过期时间(TTL)?
答案:
给 LRU 加 TTL(Time To Live)需要在每个缓存节点中存储过期时间戳,并在 get 时检查是否过期。过期清理策略通常有两种:惰性清理(访问时检查)和定时清理(定时器扫描)。
interface TTLCacheEntry<V> {
value: V;
expireAt: number; // 过期时间戳(ms)
}
class LRUWithTTL<V> {
private cache: Map<string, TTLCacheEntry<V>>;
private capacity: number;
private defaultTTL: number;
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
constructor(capacity: number, defaultTTL: number = 60_000) {
this.capacity = capacity;
this.cache = new Map();
this.defaultTTL = defaultTTL;
}
get(key: string): V | undefined {
const entry = this.cache.get(key);
if (!entry) return undefined;
// 惰性清理:访问时检查是否过期
if (Date.now() > entry.expireAt) {
this.cache.delete(key);
return undefined;
}
// 更新为最近使用
this.cache.delete(key);
this.cache.set(key, entry);
return entry.value;
}
put(key: string, value: V, ttl?: number): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.capacity) {
this.evict();
}
this.cache.set(key, {
value,
expireAt: Date.now() + (ttl ?? this.defaultTTL),
});
}
// 淘汰:优先删除已过期的,否则删除最久未使用的
private evict(): void {
const now = Date.now();
// 先尝试淘汰过期项
for (const [key, entry] of this.cache) {
if (now > entry.expireAt) {
this.cache.delete(key);
return;
}
}
// 没有过期项,淘汰最久未使用的(Map 中第一个)
const [oldestKey] = this.cache.keys();
this.cache.delete(oldestKey);
}
// 定时清理:启动定时器批量清除过期项
startAutoCleanup(interval: number = 30_000): void {
this.cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, entry] of this.cache) {
if (now > entry.expireAt) {
this.cache.delete(key);
}
}
}, interval);
}
stopAutoCleanup(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
}
}
// 使用示例
const cache = new LRUWithTTL<string>(100, 5000); // 容量 100,默认 TTL 5 秒
cache.put('token', 'abc123');
cache.put('config', '{}', 60_000); // 指定 60 秒 TTL
cache.startAutoCleanup(10_000); // 每 10 秒清理一次过期项
两种清理策略的对比:
| 策略 | 触发时机 | 优点 | 缺点 |
|---|---|---|---|
| 惰性清理 | get 时检查 | 零额外开销,实现简单 | 过期项可能长时间占用内存 |
| 定时清理 | 定时器周期扫描 | 及时释放内存 | 扫描有 开销,需管理定时器 |
最佳实践
实际项目中通常两种策略结合使用:惰性清理保证读取时的正确性,定时清理作为兜底防止内存泄漏。Redis 的过期键也采用了类似的混合策略。
Q6: LRU 和 LFU 有什么区别?各自的适用场景?
答案:
LRU(Least Recently Used)和 LFU(Least Frequently Used)是两种最常见的缓存淘汰策略,核心区别在于淘汰依据不同。
| 对比维度 | LRU | LFU |
|---|---|---|
| 全称 | Least Recently Used | Least Frequently Used |
| 淘汰依据 | 最久没有被访问的 | 被访问次数最少的 |
| 数据结构 | 双向链表 + 哈希表 | 双哈希表 + 频率链表 |
| 时间复杂度 | get/put | get/put(需要精心设计) |
| 实现复杂度 | 较低 | 较高 |
| 对突发流量 | 敏感(新数据直接置顶) | 不敏感(需要多次访问才能提升) |
| 缓存污染 | 容易被偶发访问污染 | 抗污染能力强 |
适用场景:
- LRU 适合:大多数 Web 应用、页面缓存、组件缓存(如 Vue
keep-alive)。时间局部性强,最近用过的数据大概率还会用 - LFU 适合:热点数据明显的场景,如 CDN 缓存、搜索热词、热门商品。高频数据不会因为偶尔没被访问就被淘汰
LFU 实现(双哈希表 + 最小频率追踪):
class LFUCache<V> {
private capacity: number;
private minFreq: number = 0;
// key → { value, freq }
private keyMap: Map<string, { value: V; freq: number }>;
// freq → Set<key>(同频率的 key 集合,按插入顺序)
private freqMap: Map<number, Set<string>>;
constructor(capacity: number) {
this.capacity = capacity;
this.keyMap = new Map();
this.freqMap = new Map();
}
get(key: string): V | undefined {
const entry = this.keyMap.get(key);
if (!entry) return undefined;
// 更新频率
this.increaseFreq(key, entry);
return entry.value;
}
put(key: string, value: V): void {
if (this.capacity <= 0) return;
const entry = this.keyMap.get(key);
if (entry) {
// 已存在:更新值并增加频率
entry.value = value;
this.increaseFreq(key, entry);
} else {
// 不存在:可能需要淘汰
if (this.keyMap.size >= this.capacity) {
this.evictMinFreq();
}
// 新插入的频率为 1
const newEntry = { value, freq: 1 };
this.keyMap.set(key, newEntry);
if (!this.freqMap.has(1)) {
this.freqMap.set(1, new Set());
}
this.freqMap.get(1)!.add(key);
this.minFreq = 1; // 新插入的元素频率一定是最低的
}
}
private increaseFreq(key: string, entry: { value: V; freq: number }): void {
const oldFreq = entry.freq;
const newFreq = oldFreq + 1;
entry.freq = newFreq;
// 从旧频率集合中移除
const oldSet = this.freqMap.get(oldFreq)!;
oldSet.delete(key);
// 如果旧频率集合空了,删除它
if (oldSet.size === 0) {
this.freqMap.delete(oldFreq);
// 如果淘汰的是最小频率,更新 minFreq
if (this.minFreq === oldFreq) {
this.minFreq = newFreq;
}
}
// 加入新频率集合
if (!this.freqMap.has(newFreq)) {
this.freqMap.set(newFreq, new Set());
}
this.freqMap.get(newFreq)!.add(key);
}
private evictMinFreq(): void {
const minSet = this.freqMap.get(this.minFreq)!;
// Set 按插入顺序,第一个就是同频率中最久未使用的
const evictKey = minSet.values().next().value as string;
minSet.delete(evictKey);
if (minSet.size === 0) {
this.freqMap.delete(this.minFreq);
}
this.keyMap.delete(evictKey);
}
}
// 测试
const lfu = new LFUCache<string>(3);
lfu.put('a', 'A');
lfu.put('b', 'B');
lfu.put('c', 'C');
lfu.get('a'); // freq: a=2, b=1, c=1
lfu.get('a'); // freq: a=3, b=1, c=1
lfu.get('b'); // freq: a=3, b=2, c=1
lfu.put('d', 'D'); // 淘汰 c(频率最低为 1,c 是 freq=1 中最早的)
console.log(lfu.get('c')); // undefined(已被淘汰)
console.log(lfu.get('d')); // 'D'
LFU 实现要点
- 双哈希表:
keyMap存储键值和频率,freqMap按频率分组管理键集合 minFreq追踪:维护当前最小频率,淘汰时直接定位到最低频率组,保证- 同频率淘汰规则:同一频率组内按 LRU 策略淘汰最久未使用的(利用
Set的插入顺序)