【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 指向新节点。


相关推荐
潮流coder1 小时前
vscode修改缓存保存路径
ide·vscode·缓存
圣保罗的大教堂1 小时前
leetcode 3531. 统计被覆盖的建筑 中等
leetcode
卜锦元6 小时前
Golang中make()和new()的区别与作用?
开发语言·后端·golang
海上彼尚6 小时前
Go之路 - 3.go的数据类型与转换
开发语言·后端·golang
龙门吹雪6 小时前
Go 语言包初始化顺序详解
golang·init·初始化顺序·依赖包·导入包
Tisfy6 小时前
LeetCode 3531.统计被覆盖的建筑:最大最小值
算法·leetcode·题解·桶排序
月明长歌7 小时前
【码道初阶】Leetcode.189 轮转数组:不熟悉ArrayList时踩得坑,被Arraylist初始化骗了?
java·算法·leetcode·职场和发展
海上彼尚7 小时前
Go之路 - 4.go的集合[完整版]
开发语言·后端·golang