LRU(Least Recently Used,最近最少使用)缓存机制,是面试中高频出现的设计题,也是实际开发中常用的缓存策略(比如浏览器缓存、Redis的LRU淘汰策略)。LeetCode 146题要求我们设计并实现满足LRU约束的数据结构,且get和put操作的平均时间复杂度必须为O(1)。
今天就来一步步拆解这道题,从思路分析到代码实现,再到细节优化,帮大家彻底搞懂LRU缓存的设计逻辑。
一、题目回顾(明确需求)
实现LRUCache类,包含以下三个核心方法:
-
LRUCache(int capacity):以正整数作为容量capacity初始化LRU缓存。
-
int get(int key):如果关键字key存在于缓存中,返回其对应的值;否则返回-1。
-
void put(int key, int value):如果key已存在,更新其value;如果不存在,插入该key-value。若插入后超出容量,逐出"最久未使用"的关键字。
核心约束:get和put操作的平均时间复杂度必须为O(1)。
二、核心思路分析(O(1)复杂度的关键)
要实现O(1)的查找、插入和删除,单一数据结构无法满足,必须结合两种数据结构的优势:
-
哈希表(Map):用于存储key和对应的缓存节点,实现O(1)时间的查找(get操作)和插入/删除(put操作中更新key)。
-
双向链表:用于维护缓存节点的"使用顺序"------最近使用的节点移到链表头部,最久未使用的节点留在链表尾部。这样逐出最久未使用节点时,直接删除链表尾部即可,实现O(1)删除;移动节点到头部也能通过调整指针实现O(1)操作。
补充说明:为什么用双向链表,而不是单向链表?
因为删除链表中的某个节点时,需要知道它的前驱节点,才能调整前驱节点的next指针。单向链表无法直接获取前驱节点,需要从头遍历,时间复杂度会变成O(n),不符合要求;双向链表每个节点有prev和next指针,可直接定位前驱和后继,实现O(1)删除。
三、代码实现与逐部分详解
下面结合给出的TypeScript代码,逐部分拆解,搞懂每个类、每个方法的作用,以及细节处理。
3.1 定义缓存节点类(CacheItem)
双向链表的每个节点,需要存储key、value,以及前驱(prev)和后继(next)指针,因此先定义一个CacheItem类封装节点信息。
typescript
class CacheItem {
key: number;
value: number;
prev: CacheItem | null;
next: CacheItem | null;
// 构造函数:初始化节点的key、value,以及前驱、后继指针(默认null)
constructor(key: number, value: number, prev: CacheItem | null, next: CacheItem | null) {
this.key = key;
this.value = value;
this.prev = prev;
this.next = next;
}
}
细节注意:存储key的原因------当缓存容量超出,需要删除链表尾部节点时,不仅要删除链表节点,还要从哈希表(Map)中删除对应的key。如果节点不存储key,就无法通过尾部节点获取到要删除的key,只能遍历哈希表,时间复杂度会变成O(n)。
3.2 实现LRUCache类(核心逻辑)
LRUCache类需要维护以下核心属性,以及get、put方法,还有一个私有方法moveToHead(抽离复用"移动节点到头部"的逻辑)。
3.2.1 类的属性定义
typescript
class LRUCache {
capacity: number = 0; // 缓存容量
cache: Map<number, CacheItem> = new Map(); // 哈希表:key -> 缓存节点,O(1)查找
head: CacheItem | null = null; // 双向链表头节点(最近使用的节点)
tail: CacheItem | null = null; // 双向链表尾节点(最久未使用的节点)
// ... 构造函数和方法
}
3.2.2 构造函数(初始化缓存)
typescript
constructor(capacity: number) {
this.capacity = capacity; // 初始化容量
this.head = null; // 初始化头节点为null(初始时缓存为空)
// 补充:tail可无需再次赋值,默认已为null
}
优化建议:可以增加容量合法性校验,避免传入非正整数导致异常(比如capacity=0时,put操作会一直插入节点),优化后如下:
typescript
constructor(capacity: number) {
if (capacity <= 0) {
throw new Error("缓存容量必须是正整数");
}
this.capacity = capacity;
this.head = null;
}
3.2.3 私有方法:moveToHead(移动节点到链表头部)
这是LRU缓存的核心辅助方法,无论是get已存在的节点,还是put已存在的节点(更新value),都需要将该节点移到链表头部(标记为"最近使用")。抽离成私有方法,可避免代码冗余,也便于维护。
typescript
private moveToHead(item: CacheItem): void {
// 1. 如果节点已经是头节点,无需任何操作
if (item === this.head) {
return;
}
// 2. 先将该节点从原位置"移除"(调整前驱和后继节点的指针)
// 处理前驱节点:如果有前驱,让前驱的next指向当前节点的next
if (item.prev) {
item.prev.next = item.next;
}
// 处理后继节点:如果有后继,让后继的prev指向当前节点的prev
if (item.next) {
item.next.prev = item.prev;
}
// 3. 特殊情况:如果当前节点是尾节点,删除后需要更新tail指针
if (item === this.tail) {
this.tail = item.prev; // 尾节点变为当前节点的前驱
}
// 4. 将当前节点"插入"到链表头部
item.prev = null; // 头部节点的前驱一定是null
item.next = this.head; // 当前节点的后继,指向原来的头节点
if (this.head) {
this.head.prev = item; // 原来的头节点,前驱指向当前节点
}
this.head = item; // 更新头节点为当前节点
// 5. 边界处理:如果链表只有一个节点(移动后),tail也指向该节点
if (!this.tail) {
this.tail = this.head;
}
}
关键细节:每一步指针调整都要考虑"null情况",比如节点可能是尾节点(item.next为null),也可能是链表中唯一的节点(item.prev和item.next都为null),避免出现空指针异常。
3.2.4 get方法(获取缓存值)
逻辑简单清晰,核心是"查找+移动节点到头部":
typescript
get(key: number): number {
// 1. 如果key不存在于缓存(Map中没有),返回-1
if (this.cache.has(key)) {
// 2. 如果存在,获取对应的节点
const item = this.cache.get(key)!;
// 3. 将该节点移到头部(标记为最近使用)
this.moveToHead(item);
// 4. 返回节点的value
return item.value;
} else {
return -1;
}
}
说明:cache.get(key)! 中的"!"是TypeScript的非空断言,表示我们确定key存在时,get返回的一定不是null/undefined,避免类型报错。
3.2.5 put方法(插入/更新缓存)
put方法分两种情况:key已存在(更新value)、key不存在(插入新节点),还要处理"容量超出时逐出最久未使用节点"的逻辑。
typescript
put(key: number, value: number): void {
// 情况1:key已存在,更新其value,并将节点移到头部(标记最近使用)
if (this.cache.has(key)) {
const item = this.cache.get(key)!;
item.value = value; // 更新value
this.moveToHead(item); // 移到头部
return; // 提前返回,避免执行下面的插入逻辑
}
// 情况2:key不存在,创建新节点并插入
const newItem = new CacheItem(key, value, null, null);
this.cache.set(key, newItem); // 先存入哈希表
// 2.1 将新节点插入到链表头部
if (this.head) {
// 如果链表不为空,调整指针:新节点的next指向原头,原头的prev指向新节点
newItem.next = this.head;
this.head.prev = newItem;
this.head = newItem; // 更新头节点为新节点
} else {
// 如果链表为空(缓存为空),头尾节点都指向新节点
this.head = newItem;
this.tail = newItem;
}
// 2.2 检查容量:如果缓存大小超出capacity,逐出最久未使用的节点(尾节点)
if (this.cache.size > this.capacity) {
const tailItem = this.tail!; // 确定尾节点存在(超出容量时,缓存至少有一个节点)
this.cache.delete(tailItem.key); // 从哈希表中删除尾节点的key
// 更新尾指针:尾节点变为原来尾节点的前驱
this.tail = tailItem.prev;
if (this.tail) {
this.tail.next = null; // 新尾节点的next设为null,断开与原尾节点的连接
} else {
// 边界处理:如果删除后缓存为空(容量为1时),头节点也设为null
this.head = null;
}
}
}
易错点总结(put方法的核心细节):
-
插入新节点时,要先更新哈希表,再调整链表指针,避免指针混乱。
-
逐出尾节点时,必须同时删除哈希表中的对应key,否则哈希表和链表会不一致(出现"孤儿key")。
-
边界处理:容量为1时,删除尾节点后,缓存为空,需要同时将head设为null,避免头指针指向无效节点。
四、常见问题与优化方向
5.1 常见错误排查
-
空指针异常:大多是因为没有处理prev/next为null的情况,比如删除尾节点时,没有判断tail是否为null。
-
哈希表与链表不一致:插入/删除节点时,只操作了链表或只操作了哈希表,导致get不到已存在的key,或删除后仍能get到。
-
moveToHead逻辑遗漏:get或put已存在节点时,忘记调用moveToHead,导致节点使用顺序错误。
5.2 优化方向
-
简化CacheItem构造函数:给prev和next设置默认值null,避免每次创建节点都传入null,比如:
constructor(key: number, value: number, prev: CacheItem | null = null, next: CacheItem | null = null) { this.key = key; this.value = value; this.prev = prev; this.next = next; } -
增加打印日志方法:方便调试时查看链表结构和缓存内容,比如添加printCache方法,打印当前缓存的key顺序(从最近使用到最久未使用)。
五、总结
LRU缓存的核心是"哈希表+双向链表"的组合,利用哈希表实现O(1)查找,双向链表实现O(1)移动和删除,两者结合满足题目要求的时间复杂度。
解题关键在于:
-
明确双向链表的指针调整逻辑(moveToHead、删除尾节点)。
-
保证哈希表和链表的一致性(插入/删除节点时,两者同时操作)。
-
处理好边界情况(缓存为空、容量为1、删除尾节点后缓存为空等)。
这道题不仅考察数据结构的应用,更考察细节处理能力,掌握后无论是面试还是实际开发,遇到LRU相关的场景都能轻松应对。建议大家自己动手敲一遍代码,调试测试用例,感受指针调整的细节,加深理解。