LeetCode 146. LRU缓存:题解+代码详解

LRU(Least Recently Used,最近最少使用)缓存机制,是面试中高频出现的设计题,也是实际开发中常用的缓存策略(比如浏览器缓存、Redis的LRU淘汰策略)。LeetCode 146题要求我们设计并实现满足LRU约束的数据结构,且get和put操作的平均时间复杂度必须为O(1)。

今天就来一步步拆解这道题,从思路分析到代码实现,再到细节优化,帮大家彻底搞懂LRU缓存的设计逻辑。

一、题目回顾(明确需求)

实现LRUCache类,包含以下三个核心方法:

  1. LRUCache(int capacity):以正整数作为容量capacity初始化LRU缓存。

  2. int get(int key):如果关键字key存在于缓存中,返回其对应的值;否则返回-1。

  3. void put(int key, int value):如果key已存在,更新其value;如果不存在,插入该key-value。若插入后超出容量,逐出"最久未使用"的关键字。

核心约束:get和put操作的平均时间复杂度必须为O(1)。

二、核心思路分析(O(1)复杂度的关键)

要实现O(1)的查找、插入和删除,单一数据结构无法满足,必须结合两种数据结构的优势:

  1. 哈希表(Map):用于存储key和对应的缓存节点,实现O(1)时间的查找(get操作)和插入/删除(put操作中更新key)。

  2. 双向链表:用于维护缓存节点的"使用顺序"------最近使用的节点移到链表头部,最久未使用的节点留在链表尾部。这样逐出最久未使用节点时,直接删除链表尾部即可,实现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 优化方向

  1. 简化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; }

  2. 增加打印日志方法:方便调试时查看链表结构和缓存内容,比如添加printCache方法,打印当前缓存的key顺序(从最近使用到最久未使用)。

五、总结

LRU缓存的核心是"哈希表+双向链表"的组合,利用哈希表实现O(1)查找,双向链表实现O(1)移动和删除,两者结合满足题目要求的时间复杂度。

解题关键在于:

  • 明确双向链表的指针调整逻辑(moveToHead、删除尾节点)。

  • 保证哈希表和链表的一致性(插入/删除节点时,两者同时操作)。

  • 处理好边界情况(缓存为空、容量为1、删除尾节点后缓存为空等)。

这道题不仅考察数据结构的应用,更考察细节处理能力,掌握后无论是面试还是实际开发,遇到LRU相关的场景都能轻松应对。建议大家自己动手敲一遍代码,调试测试用例,感受指针调整的细节,加深理解。

相关推荐
烟花落o2 小时前
【数据结构系列03】链表的回文解构、相交链表
数据结构·算法·链表·刷题
fu的博客2 小时前
【数据结构4】单向循环链表实现
数据结构·链表
努力学算法的蒟蒻2 小时前
day87(2.16)——leetcode面试经典150
数据结构·leetcode·面试
追随者永远是胜利者2 小时前
(LeetCode-Hot100)17. 电话号码的字母组合
java·算法·leetcode·职场和发展·go
不想看见4042 小时前
Shortest Bridge -- 广度优先搜索 --力扣101算法题解笔记
算法·leetcode·宽度优先
流云鹤2 小时前
2026牛客寒假算法基础集训营5(B D G J F )
算法
教男朋友学大模型2 小时前
LoRA 为什么必须把一个矩阵初始化为0
人工智能·算法·面试·求职招聘
青衫码上行2 小时前
Redis持久化 (快速入门)
数据库·redis·缓存
得一录2 小时前
Python 算法高级篇:布谷鸟哈希算法与分布式哈希表
python·算法·aigc·哈希算法