算法 - LRU 缓存机制 (LRU Cache) ⭐全栈必考

1. 题目

设计并实现一个 LRU(Least Recently Used,最近最少使用)缓存,支持 get 和 put 操作,要求两者都是 O(1) 时间复杂度。

  • 构造时给定正整数 capacity 表示缓存容量。
  • get(key):存在则返回 value,否则返回 -1。
  • put(key, value):写入数据。若写入导致容量超限,应驱逐最久未使用的键。
  • 关键:get 和 put 都要更新「使用顺序」,被访问的键变成「最近使用」。

示例:

1
2
3
4
5
6
7
LRUCache cache = new LRUCache(2);
cache.put(1, 1);
cache.put(2, 2);
cache.get(1); // 返回 1,此时 1 变为最新,2 变为最旧
cache.put(3, 3); // 容量满,驱逐最旧的 2
cache.get(2); // 返回 -1(未找到)
cache.get(3); // 返回 3

2. 解题思路

LRU 的两大需求互相矛盾:

  1. O(1) 按 key 查找 —— 需要哈希表。
  2. O(1) 维护使用顺序、淘汰尾部 —— 需要能在头部/尾部任意增删的有序结构。

答案:哈希表 + 双向链表。这正是浏览器缓存、Redis allkeys-lru、各类内存缓存的标准实现。

  • 双向链表:越靠近 head 越「新」,越靠近 tail 越「旧」。淘汰时直接删 tail 前一个。
  • 哈希表:key -> 链表节点,实现 O(1) 定位到某个节点。
  • 命中或写入后,把该节点用 addToHead(先 remove 再插入头部)移到最前。

为什么是双向而非单向?因为删除任意节点时需要 O(1) 拿到它的前驱,单向链表做不到。

巧用语言特性:JavaScript 的 Map 本身就维护插入顺序,把 key 重新删除再插入等价于「移到最新」,可以极简实现。面试时建议两种都讲:Map 版展示语言理解,手写双向链表版展示数据结构功底。

3. TypeScript 实现

3.1 极简版:利用 Map 的插入有序性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class LRUCache {
private map = new Map<number, number>();
constructor(private capacity: number) {}

get(key: number): number {
if (!this.map.has(key)) return -1;
const value = this.map.get(key)!;
// 删除后重新插入 = 移动到最新(Map 尾部为最近使用)
this.map.delete(key);
this.map.set(key, value);
return value;
}

put(key: number, value: number): void {
if (this.map.has(key)) {
this.map.delete(key);
} else if (this.map.size >= this.capacity) {
// 删除第一个 key,即最久未使用(队首)
const oldest = this.map.keys().next().value!;
this.map.delete(oldest);
}
this.map.set(key, value);
}
}

3.2 经典版:哈希表 + 手写双向链表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
interface DLinkedNode {
key: number;
value: number;
prev: DLinkedNode | null;
next: DLinkedNode | null;
}

class LRUCacheAdvanced {
private map = new Map<number, DLinkedNode>();
private size = 0;
// 哨兵节点,避免大量 null 判断
private head: DLinkedNode = { key: 0, value: 0, prev: null, next: null };
private tail: DLinkedNode = { key: 0, value: 0, prev: null, next: null };

constructor(private capacity: number) {
this.head.next = this.tail;
this.tail.prev = this.head;
}

get(key: number): number {
const node = this.map.get(key);
if (!node) return -1;
this.moveToHead(node); // 命中即刷新为最新
return node.value;
}

put(key: number, value: number): void {
const node = this.map.get(key);
if (node) {
node.value = value;
this.moveToHead(node);
return;
}

const newNode: DLinkedNode = { key, value, prev: null, next: null };
this.map.set(key, newNode);
this.addToHead(newNode);
this.size++;

if (this.size > this.capacity) {
const removed = this.removeTail(); // 淘汰最旧
this.map.delete(removed.key);
this.size--;
}
}

private addToHead(node: DLinkedNode): void {
node.prev = this.head;
node.next = this.head.next;
this.head.next!.prev = node;
this.head.next = node;
}

private removeNode(node: DLinkedNode): void {
node.prev!.next = node.next;
node.next!.prev = node.prev;
}

private moveToHead(node: DLinkedNode): void {
this.removeNode(node);
this.addToHead(node);
}

private removeTail(): DLinkedNode {
const node = this.tail.prev!;
this.removeNode(node);
return node;
}
}
  • 时间复杂度:get / put 均为 O(1)。
  • 空间复杂度:O(capacity)。

4. 面试延伸

  • LFU 缓存(LeetCode 460) 是 LRU 的进阶,需要频率桶 + 双向链表,更烧脑。
  • 真实工程:Redis 用近似 LRU(随机采样淘汰)省内存;前端 keep-alive、图片缓存、请求缓存都用 LRU 思想。
  • 讲题要点:先说清「为什么单结构做不到 O(1)」,再自然引出「哈希 + 双向链表」的组合,展示的是权衡能力而非背代码。

难度:中等 | LeetCode 146 题 | 全栈数据结构压轴题