【Day21】146.LRU缓存 (Least Recently Used)

文章目录

146.LRU缓存

题目:

请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。

实现 LRUCache 类:

  • LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存
  • int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
  • void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该 逐出 最久未使用的关键字。

函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。

示例:

输入

"LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"

\[2\], \[1, 1\], \[2, 2\], \[1\], \[3, 3\], \[2\], \[4, 4\], \[1\], \[3\], \[4\]

输出

null, null, null, 1, null, -1, null, -1, 3, 4

解释

LRUCache lRUCache = new LRUCache(2);

lRUCache.put(1, 1); // 缓存是 {1=1}

lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}

lRUCache.get(1); // 返回 1

lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3}

lRUCache.get(2); // 返回 -1 (未找到)

lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}

lRUCache.get(1); // 返回 -1 (未找到)

lRUCache.get(3); // 返回 3

lRUCache.get(4); // 返回 4

提示:

1 <= capacity <= 3000

0 <= key <= 10000

0 <= value <= 10^5

最多调用 2 * 10^5 次 get 和 put


思路:

1. LRU 缓存(Least Recently Used)要求:

  • 有一个固定容量 capacity 的缓存。
  • 每次访问某个 key(getput),这个 key 都会变成"最近使用"的。
  • 超过容量时,删除最久未使用的 key。
  • getput 要求平均 O(1) 时间复杂度。

所以本质上,需要维护两方面的数据结构:

  1. 数据的顺序

    • 用双向链表维护 key 的使用顺序,链表头表示最近使用,链表尾表示最久未使用
    • 将一个节点移到链表头可以分成两步:
      • 删除该节点(O(1))
      • 在链表头插入该节点(O(1))
    • 删除最久未使用的节点(链表尾)同样是 O(1)。
  2. 快速查找 key

    • 使用哈希表 map[key] -> node,保证 get 操作可以在 O(1) 时间直接找到对应节点,然后链表节点数据部分存储了键值对 entry 结构,根据 key 找到 value。
    • put 操作时,
      • 如果 key 已存在,同样可以在 O(1) 时间通过哈希表找到节点并更新;
      • 如果 key 不存在,也可以通过哈希表当前长度快速判断是否超过容量。

即:

  • get 操作:查哈希表 + 移动节点到头部 → O(1)
  • put 操作:查哈希表 + 插入/更新节点 + 删除尾部节点(如超过容量) → O(1)

因此,通过「哈希表 + 双向链表」的组合,LRU 缓存能够在平均 O(1) 时间完成 getput 操作,同时维护最近使用顺序。


2. 核心数据结构

为实现上述要求,经典做法是 哈希表 + 双向链表

数据结构 作用
哈希表 map[key] -> node 通过 key 快速找到对应节点,O(1) 查找
双向链表 保存使用顺序:链表头是最近使用,链表尾是最久使用。可以 O(1) 删除和插入节点

解释:

  • 双向链表 比单向链表合适,因为需要删除操作。删除一个节点不光要得到该节点本身的指针,也需要操作其前驱节点的指针,而双向链表才能支持直接查找前驱,保证操作的时间复杂度 O(1)。
  • 链表头最近使用的元素。
  • 链表尾最久未使用的元素。当缓存满了,要删除的就是链表尾节点。
  • 哈希表:直接通过 key 找到链表节点,无需遍历。

3. 注意点

  1. 最近使用的定义

    • 不管是 get 还是 put,访问过的 key 都算最近使用。
    • 如果两个 key 都没被访问过,插入时间更晚的那个算最近使用(链表头)。
  2. 虚拟头尾节点

    • 用虚拟头尾节点可以简化链表操作,不需要判断头尾是否为空。
    • 虚拟节点不是必需的,但面试写链表时非常常用,减少逻辑错误。
  3. 容量限制

    • 插入前判断长度是否超过 capacity,如果超过,要删除链表尾节点,同时删除哈希表记录。

实现(Go):

  1. 使用哈希表快速查找 key
  • map[key]*list.Element,可以在 O(1) 时间找到链表中的节点。
  1. 使用双向链表维护访问顺序
  • Go 的 container/list 是双向链表。
  • 链表头表示最近使用(Most Recently Used)。
  • 链表尾表示最久未使用(Least Recently Used)。
  1. 访问操作(get)
  • 查找 key 对应的节点,如果存在,把它移动到链表头(表示最近使用),返回值。
  • 如果不存在,返回 -1。
  1. 插入/更新操作(put)
  • 如果 key 已存在:
    • 更新 value
    • 移动到链表头
  • 如果 key 不存在:
    • 判断是否超出容量

      • 如果没有:
        • 插入新节点到链表头
      • 如果超出:
        • 插入新节点到链表头
        • 删除链表尾节点(最久未使用)
        • 同时从哈希表删除对应 key

使用结构体类型 List 表示双向链表

List 讲解

包和类型
go 复制代码
import "container/list" // 引入 Go 内置双向链表包
go 复制代码
// List 表示整个双向链表
type List struct {
    root Element // 哨兵节点(循环链表,简化边界处理,头尾不为 nil)
    len  int     // 链表长度
}

// Element 表示链表中的一个节点
type Element struct {
    Next, Prev *Element   // 前驱和后继节点指针
    list       *List      // 指向所属链表
    Value      interface{} // 节点存储的值,可以是任意类型
}

说明:

  • Value 可以存任意类型,Go 用 interface{} 实现泛型效果。
  • 内部使用循环哨兵节点,空链表时 root.Nextroot.Prev 都指向 root 本身,操作无需处理特殊情况。

常用操作

创建

go 复制代码
l := list.New() // *list.List 类型,表示整个链表

// 内部结构:
type List struct {
    root Element  // 哨兵节点,root.Next是头节点,root.Prev是尾节点
    len  int     // 链表长度
}

插入

go 复制代码
l.PushFront(v) // 在链表头插入值 v,返回新节点 *Element
l.PushBack(v)  // 在链表尾插入值 v,返回新节点 *Element
  • 时间复杂度:O(1)
  • 特点:直接操作指针,无需遍历链表。
  • 返回值:新插入的节点,可以用来快速删除或移动。

删除

go 复制代码
l.Remove(e) // 删除节点 e(*Element)
  • 时间复杂度:O(1)
  • 说明:通过节点指针直接删除,无需遍历链表。
  • 注意 :删除后节点的 NextPrev 指针会被清空,防止误用。

移动节点

go 复制代码
l.MoveToFront(e) // 将节点 e 移动到链表头部
l.MoveToBack(e)  // 将节点 e 移动到链表尾部
  • 本质操作

    1. 从当前位置删除节点(O(1))
    2. 在新位置插入节点(O(1))

访问头尾节点

go 复制代码
l.Front() // 返回链表头节点 *Element
l.Back()  // 返回链表尾节点 *Element
  • 访问值e.Value
  • 链表为空 :返回 nil

代码实现:

力扣提交需自行注释或者去掉 包声明、导入部分以及Go 程序的入口 main 函数。

go 复制代码
package main

import (
	"container/list"
	"fmt"
)

// entry 结构表示缓存中的一条记录
// key: 缓存的 key
// value: 缓存的 value
type entry struct {
	key, value int
}

// LRUCache 数据结构,一个整体容器
// 存储 容量、双向链表(维护访问顺序)、哈希表(快速访问),实现 LRU 缓存
type LRUCache struct {
	capacity  int                   // 缓存容量
	cacheList *list.List            // 双向链表,维护使用顺序,头是最近使用,尾是最久未使用
	keyToNode map[int]*list.Element // 哈希表,快速通过 key 查找对应的链表节点
}

// 构造函数,初始化 LRU 缓存
func Constructor(capacity int) LRUCache {
	return LRUCache{
		capacity:  capacity,
		cacheList: list.New(),                  // 创建一个空的双向链表
		keyToNode: make(map[int]*list.Element), // 创建空哈希表
	}
}

// Get 方法:获取 key 的值,如果不存在返回 -1
func (c *LRUCache) Get(key int) int { // c:方法的指针接收者 (*LRUCache):修改会影响原对象。
	node := c.keyToNode[key] // O(1) 哈希表查找
	if node == nil {
		return -1 // key 不存在
	}

	// key 存在,将节点移动到链表头,表示最近使用
	c.cacheList.MoveToFront(node) // O(1),删除 + 插入头部
	return node.Value.(entry).value
	// 1. 取出链表节点 node 的 Value(类型是 interface{})
	// 2. 把 node.Value 从 interface{} 类型断言成 entry 类型
	// 3. 访问 entry 里的 value 字段
	// 4. 返回结果(就是缓存的值)
}

// Put 方法:插入或更新 key-value
func (c *LRUCache) Put(key int, value int) {
	if node := c.keyToNode[key]; node != nil {
		// key 已存在
		c.cacheList.MoveToFront(node)  // 把节点移动到链表头,表示最近使用
		node.Value = entry{key, value} // 更新 value
		return
	}
	// key 不存在,创建新节点,放到链表头
	newNode := c.cacheList.PushFront(entry{key, value}) // O(1)
	c.keyToNode[key] = newNode                          // 更新哈希表

	// 如果超出容量,需要删除最久未使用的节点(链表尾部)
	if c.cacheList.Len() > c.capacity {
		backNode := c.cacheList.Back()                  // 获取尾节点 O(1)
		c.cacheList.Remove(backNode)                    // 从链表中删除 O(1)
		delete(c.keyToNode, backNode.Value.(entry).key) // 从哈希表中删除 O(1)
	}
}

func main() {
	cache := Constructor(2)   // 容量为 2
	cache.Put(1, 1)           // 缓存 {1=1}
	cache.Put(2, 2)           // 缓存 {2=2, 1=1}
	fmt.Println(cache.Get(1)) // 返回 1,缓存更新为 {1=1, 2=2}

	cache.Put(3, 3)           // 插入 3=3, 删除最久未使用 key=2, 缓存 {3=3, 1=1}
	fmt.Println(cache.Get(2)) // 返回 -1, key=2 已被删除

	cache.Put(4, 4)           // 插入 4=4, 删除最久未使用 key=1, 缓存 {4=4, 3=3}
	fmt.Println(cache.Get(1)) // 返回 -1
	fmt.Println(cache.Get(3)) // 返回 3, 缓存更新为 {3=3, 4=4}
	fmt.Println(cache.Get(4)) // 返回 4, 缓存更新为 {4=4, 3=3}
}

内置函数 delete

在 Go 里,,用来从 map 中删除指定的键值对。

语法

go 复制代码
delete(map, key)
  • map:要操作的 map
  • key:要删除的键

删除后:

  • map[key] 再访问就是零值(如果是指针就是 nil,如果是 int 就是 0 等)
  • map 长度减少 1

双向链表的删除插入

删除节点x:

前节点(prev)的 next 指向当前节点的 next

后节点(next)的 prev 指向当前节点的 prev

在头部插入节点x:

新节点的 prev 指向 虚拟头节点。

新节点的 next 指向 虚拟头节点的下一个节点(原第一个节点)。

虚拟头节点的 next 指向新节点。

原第一个节点的 prev 指向新节点。


相关推荐
橘子汽水1684 小时前
Leetcode 763,45 划分字母区间 跳跃游戏II
数据结构·算法·leetcode
报错小能手5 小时前
GO语言接口 泛型 错误处理
golang
6Hzlia5 小时前
【Classic 150 刷题计划】 LeetCode 228. 汇总区间 | C++ 锚点游标与断点检测法
c++·算法·leetcode
wdfk_prog16 小时前
ROS教程:01 搭建 ROS1 Noetic Docker 开发环境
运维·缓存·docker·容器·ros
wdfk_prog17 小时前
ros教程02:创建 catkin Workspace、Package 与第一个 ROS1 C++ Node
运维·缓存·docker·容器·ros
Navigator_Z20 小时前
LeetCode //C - 1250. Check If It Is a Good Array
c语言·算法·leetcode
圣保罗的大教堂1 天前
leetcode 3483. 不同三位偶数的数目 简单
leetcode
mmmmath_31 天前
LeetCode.018.四数之和
数据结构·算法·leetcode
草莓熊Lotso1 天前
【Redis 进阶】主从复制深度解析:从配置落地到 PSYNC 同步原理
linux·开发语言·网络·数据库·redis·缓存·php
圣保罗的大教堂1 天前
leetcode 836. 矩形重叠 简单
leetcode