解法一:哈希链表
LRU是我们在学习操作系统时常见的一种内存淘汰策略,全称是 Least Recently Used,也就是说最近使用过的数据应该是是「有用的」,很久都没用过的数据应该是「无用的」,内存满了时,就优先删那些很久没用过的数据。
题意要求 put和 get方法的时间复杂度为 O(1),可以总结出 LRUcache 这个数据结构必要的条件:
- 显然
LRUcache中的元素必须有时序,才能区分最近使用的和最久未使用的数据,当容量满了之后要删除最久未使用的那个元素。 - 需要在
LRUcache中快速找到某个 key对应的 val - 每次访问/更新/插入
LRUcache中的某个key,需要将这个元素提升为最近使用的,当容量满了时要删除最久未使用的那个元素以腾出位置插入新元素,也就是说要支持在任意位置快速插入和删除元素。
哈希表查找快,但是数据无固定顺序;链表有顺序之分,支持快速插入/删除元素,但是查找慢。所以结合一下,形成一种新的数据结构:哈希链表 LinkedHashMap。
LRU 缓存算法的核心数据结构就是哈希链表,双向链表和哈希表的结合体。
借助这个数据结构,我们再来分析上面的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 中的键。