LeetCode146 LRU缓存(手动造轮子)

92 阅读4分钟

leetcode.cn/problems/lr…

image.png

解法一:哈希链表

LRU是我们在学习操作系统时常见的一种内存淘汰策略,全称是 Least Recently Used,也就是说最近使用过的数据应该是是「有用的」,很久都没用过的数据应该是「无用的」,内存满了时,就优先删那些很久没用过的数据。

题意要求 put和 get方法的时间复杂度为 O(1),可以总结出 LRUcache 这个数据结构必要的条件:

  • 显然LRUcache中的元素必须有时序,才能区分最近使用的和最久未使用的数据,当容量满了之后要删除最久未使用的那个元素。
  • 需要在LRUcache中快速找到某个 key对应的 val
  • 每次访问/更新/插入LRUcache中的某个 key,需要将这个元素提升为最近使用的,当容量满了时要删除最久未使用的那个元素以腾出位置插入新元素,也就是说要支持在任意位置快速插入和删除元素。

哈希表查找快,但是数据无固定顺序;链表有顺序之分,支持快速插入/删除元素,但是查找慢。所以结合一下,形成一种新的数据结构:哈希链表 LinkedHashMap

LRU 缓存算法的核心数据结构就是哈希链表,双向链表和哈希表的结合体。 image.png

借助这个数据结构,我们再来分析上面的3个必要条件:

  • 如果我们每次默认从链表尾部添加元素,那么越靠尾部的元素就是最近使用的,越靠头部的元素就是最久未使用的。
  • 对于某一个 key,我们可以通过哈希表快速定位到链表中的节点,从而取得对应 val
  • 链表显然是支持在任意位置快速插入和删除的,改改指针就行。只不过传统的链表无法按照索引快速访问某一个位置的元素,需要从头结点开始逐个遍历判断,而这里可借助哈希表,通过 key 快速映射到任意一个链表节点,然后再进行链表上的插入和删除操作。
type Node struct { // 双向链表节点, 节点元素是一个key value结构
	Key  int
	Val  int
	Prev *Node
	Next *Node
}

type MyDoubleLinkedList struct {
	// 虚拟头、尾节点
	Head *Node
	Tail *Node
	Size int
}

func NewDoubleLinkedList() *MyDoubleLinkedList {
	head := &Node{}
	tail := &Node{}
	head.Next = tail
	tail.Prev = head
	return &MyDoubleLinkedList{
		Head: head,
		Tail: tail,
		Size: 0,
	}
}

// 在链表尾部添加节点 x,时间复杂度 O(1)
func (this *MyDoubleLinkedList) AddLast(x *Node) {
	tmp := this.Tail.Prev // 真正存数据的尾节点
	tmp.Next = x
	x.Prev = tmp
	x.Next = this.Tail
	this.Tail.Prev = x
	this.Size++
}

// 删除链表中的节点 x(节点 x一定存在),时间复杂度 O(1)
func (this *MyDoubleLinkedList) Remove(x *Node) {
	x.Prev.Next = x.Next
	x.Next.Prev = x.Prev
	this.Size--
}

// 删除链表中第一个节点,并返回该节点,时间复杂度 O(1)
func (this *MyDoubleLinkedList) PopFirst() *Node {
	if this.Size == 0 { // 链表为空
		return nil
	}
	first := this.Head.Next
	this.Remove(first)
	return first
}

type LRUCache struct {
	Cap   int
	MyMap map[int]*Node
	List  *MyDoubleLinkedList
}

func Constructor(capacity int) LRUCache {
	return LRUCache{
		Cap:   capacity,
		MyMap: make(map[int]*Node),
		List:  NewDoubleLinkedList(),
	}
}

func (this *LRUCache) Get(key int) int {
	if v, ok := this.MyMap[key]; ok {
		// 将该数据提升为最近访问的
		this.List.Remove(v)  // 先删除该节点
		this.List.AddLast(v) // 重新插到链表尾部
		return v.Val
	}
	return -1
}

func (this *LRUCache) Put(key int, value int) {
	if v, ok := this.MyMap[key]; ok { // 如果该key存在,更新,并提升为最近使用的
		v.Val = value
		this.List.Remove(v)  // 先删除该节点
		this.List.AddLast(v) // 重新插到链表尾部
		return
	}
	// 否则,需要插入新key
	if this.Cap == this.List.Size { // 判断缓存容量是否已满
		// 需要淘汰最久未使用的数据,即链表的第一个元素
		deleteN := this.List.RemoveFirst()
		// 记得还要从 map中删除它的 key
		delete(this.MyMap, deleteN.Key)
	}
	// 新插入元素标记为最近使用的
	newN := &Node{
		Key: key,
		Val: value,
	}
	this.List.AddLast(newN) // 插到链表尾部
	this.MyMap[key] = newN
}

/**
 * Your LRUCache object will be instantiated and called as such:
 * obj := Constructor(capacity);
 * param_1 := obj.Get(key);
 * obj.Put(key,value);
 */
Q&A 1:为什么要用双向链表?

因为我们需要删除操作。删除一个链表节点不仅要得到该节点本身的指针,也需要操作其前驱节点的指针,而双向链表才能支持快速查找前驱节点,保证整体操作的时间复杂度 O(1)。

Q&A 2:为什么要在链表中同时存储 key 和 val,而不是只存储 val?

map 中的key映射到的value是链表上的节点,当判断缓存容量已满,我们不仅仅要删除最后一个 Node 节点,还要把 map 中映射到该节点的 key 同时删除,而这个 key 只能由 Node 得到。如果 Node 结构中只存储 val,那么我们就无法得知 key 是什么,就无法删除 map 中的键。

参考

labuladong.online/algo/data-s…