这是算法记录的第15天,主要是缓存LRU和LFU算法
对应leetcode题目为146.460
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]
思路
数据结构
考虑到需要以O(1)的平均时间复杂度运行,所以肯定是需要用到map来进行快速索引;
而对于“最近”这一判断可以使用双向链表进行快速删除插入:
- 对于Get和Put成功的话就放到链表头;
- 对于需要删除的情况,直接删除链表尾的;
所以整体考虑使用一个map和一个双向链表来进行操作,map使用key和链表的element进行映射,便于快速找到对应的值;而对于链表节点的值需要保存key和value就可以了。
理论操作
LRU的两种操作:Get和Put。
- Get:需要获取指定的val,但需要判断key是否存在,若成功获取了就将其放到链首;
- Put:首先判断这个key是否存在,若存在的话就是对其进行更新,然后放到链首,接下来判断容量是否够用于新增一个
- 若不够,删除链尾的元素;
- 若够了,就直接放到链头;
所以整体来说LRU算法比较简单,只要知道是使用hash+双向链表的话也比较好实现,注意使用节点类型的断言便于进行节点操作。
代码
package main
import "container/list"
type LRUCache struct {
cap int // 最大容量
list *list.List // 双向链表便于删除操作
cacheMap map[int]*list.Element // map结构便于在O(1)时间操作缓存
}
type Node struct {
key, value int
}
func Constructor1(capacity int) LRUCache {
return LRUCache{
cap: capacity,
list: list.New(),
cacheMap: make(map[int]*list.Element),
}
}
func (l *LRUCache) Get(key int) int {
//若存在就先把这个放到链表头,再返回这个对应的值
if element, exist := l.cacheMap[key]; exist {
l.list.MoveToFront(element)
return element.Value.(*Node).value
}
return -1
}
func (l *LRUCache) Put(key, value int) {
//若这个key已经存在了,需要更新值
if element, exist := l.cacheMap[key]; exist {
l.list.MoveToFront(element)
element.Value.(*Node).value = value
return
}
//若此时链表长度已经大于cap了,就删除尾部节点(过程淘汰)
if l.list.Len() >= l.cap {
tail := l.list.Back()
k := tail.Value.(*Node).key
l.list.Remove(tail)
delete(l.cacheMap, k)
}
//将这个k-v放到链表头
element := l.list.PushFront(&Node{key, value})
l.cacheMap[key] = element
}
// list.element包含list字段,这个字段包含了根节点和长度用于判断是否是这个list中的节点
460.LFU缓存
请你为 最不经常使用(LFU)缓存算法设计并实现数据结构。
实现 LFUCache
类:
LFUCache(int capacity)
- 用数据结构的容量capacity
初始化对象int get(int key)
- 如果键key
存在于缓存中,则获取键的值,否则返回-1
。void put(int key, int value)
- 如果键key
已存在,则变更其值;如果键不存在,请插入键值对。当缓存达到其容量capacity
时,则应该在插入新项之前,移除最不经常使用的项。在此问题中,当存在平局(即两个或更多个键具有相同使用频率)时,应该去除 最近最久未使用 的键。
为了确定最不常使用的键,可以为缓存中的每个键维护一个 使用计数器 。使用计数最小的键是最久未使用的键。
当一个键首次插入到缓存中时,它的使用计数器被设置为 1
(由于 put 操作)。对缓存中的键执行 get
或 put
操作,使用计数器的值将会递增。
函数 get
和 put
必须以 O(1)
的平均时间复杂度运行。
示例:
输入:
["LFUCache", "put", "put", "get", "put", "get", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [3], [4, 4], [1], [3], [4]]
输出:
[null, null, null, 1, null, -1, 3, null, -1, 3, 4]
思路
这个基本思路和上面其实是差不多,但由于加上了一个freq参数,导致更加的复杂,特别是需要考虑更多情况
数据结构
这道题有很多种方式,而方便且好理解的就是hash+小顶堆,但由于堆的操作是O(logN),不符合要求;另一个就是LinkedHashSet结构,但由于Go并不内置这种结构所以我们还是考虑哈希+双向链表。
沿用上一道题的思路,我们发现可以用一个hash-map和一个双向链表进行快速的查找并能够确定顺序,不过这里新增了一个参数,将会导致维度上升。
这里还是考虑使用map+双向链表,不过这里使用频率是更优先的,所以不再是维护一个双向链表,而是对每一个使用频率(比如使用了一次的,使用了两次的…)都维护一个对应的双向链表,这样我们可以在相同使用频率的节点中快速找到最久没用的那个,并进行淘汰操作。而对于最低使用频率这一值可以维护一个字段进行保存。
由于多了一个参数freq,所以在LRU的基础上需要freq到对应双向链表的映射,便于快速淘汰,同时需要minFreq
字段保存当前最小的使用频率。
type LFUCache struct {
capacity int
//保存使用频率的最小值
minFreq int
// 映射key到节点
cache map[int]*list.Element
// 映射freq到双向链表的map
freqMap map[int]*list.List
}
而对于每个节点的类型,也需要新加上freq
进行对应链表的索引
// 每个双向链表节点的类型
type listElement struct {
key int
val int
freq int
}
理论操作
还是Get和Put两种操作,不过这里维护了更多的双向链表,很容易越界操作,所以需要仔细。
-
对于从双向链表中删除节点的操作,需要判断删除后链表是否为空了,为空同时对应的
freq
还是minFreq
就需要更新minFreq
-
对于每次的freq到链表的索引都需要判断不存在的情况,若不存在还需要创建新的双向链表,注意更新两个hash-map的赋值
-
Get:首先是判断是否存在该key,存在的话需要从旧的双向链表中删除,并对freq+1,然后移动到新的双向链表的头部,最后返回这个值;
-
Put:存入元素,需要判断是否是新的key
- 若是旧的就修改这个值,从旧的双向链表中删除,并加入到freq+1的双向链表中,结束;
- 若是新的需要创建一个新节点,同时判断当前是否已经满了
- 若满了需要删除最少最不常用那个
- 删除minFreq对应双向链表的尾节点(minFreq保证是使用频率最小的,尾节点保证是最久没用的)
- 若没满就加入这个值
- 判断freq=1对应的双向链表是否存在
- 若不存在先创建
- 再将这个节点加入到freq=1的双向链表头部
- minFreq更新为1,因为1是最小的了
- 判断freq=1对应的双向链表是否存在
- 若满了需要删除最少最不常用那个
代码
package main
import "container/list"
//执行耗时:460 ms,击败了67.11% 的Go用户
//内存消耗:72.6 MB,击败了94.22% 的Go用户
type LFUCache struct {
capacity int
//保存使用频率的最小值
minFreq int
// 映射key到节点
cache map[int]*list.Element
// 映射freq到双向链表的map
freqMap map[int]*list.List
}
// 每个双向链表节点的类型
type listElement struct {
key int
val int
freq int
}
func Constructor(capacity int) LFUCache {
return LFUCache{
cache: make(map[int]*list.Element),
freqMap: make(map[int]*list.List),
capacity: capacity,
minFreq: 0}
}
// Get 获取元素 需要从旧的双向链表中删除,并对freq+1,然后移动到新的双向链表的头部,最后返回这个值
func (l *LFUCache) Get(key int) int {
if l.capacity == 0 {
return -1
}
//获取这个节点元素
element, exist := l.cache[key]
if !exist {
return -1
}
listNode := element.Value.(*listElement)
//将这个元素从双向链表中删除
l.freqMap[listNode.freq].Remove(element)
//当旧的freq双向链表中没有节点了
if l.freqMap[listNode.freq].Len() == 0 {
//删除这个双向链表
//delete(l.freqMap, listNode.freq)
//如果那个freq正好是minFreq,那么就把minFreq改为新的freq
if l.minFreq == listNode.freq {
l.minFreq++
}
}
//freq加一
listNode.freq++
//判断freq对应的双向链表是否存在
newList, exist := l.freqMap[listNode.freq]
if !exist {
//不存在的话newList是nil,需要新建一个
newList = list.New()
//并让新的freq指向这个双向链表
l.freqMap[listNode.freq] = newList
}
//移动到新的双向链表头部
l.cache[key] = newList.PushFront(listNode)
return listNode.val
}
/*
Put操作的基本思路
存入元素,需要判断是否是新的key
- 若是旧的就修改这个值,从旧的双向链表中删除,并加入到freq+1的双向链表中,结束
- 若新的需要创建一个新节点,同时判断当前是否已经满了
- 若满了需要删除最少最不常用那个
- 删除minFreq对应双向链表的尾节点
- 若没满就加入这个值
- 判断freq=1对应的双向链表是否存在
- 若不存在先创建
- 再将这个节点加入到freq=1的双向链表头部
*/
func (l *LFUCache) Put(key int, value int) {
if l.capacity == 0 {
return
}
val, exist := l.cache[key]
//若key存在
if exist {
element := val.Value.(*listElement)
//从旧的双向链表里面删除
l.freqMap[element.freq].Remove(val)
if l.freqMap[element.freq].Len() == 0 {
//delete(l.freqMap, element.freq)
if l.minFreq == element.freq {
l.minFreq++
}
}
//修改值
element.val = value
element.freq++
//将这个放到新的并重新赋值
newList, exist := l.freqMap[element.freq]
if !exist {
newList = list.New()
l.freqMap[element.freq] = newList
}
l.cache[key] = newList.PushFront(element)
return
}
//创建新的节点
newElement := &listElement{key, value, 1}
//如果满了就需要先删除再进行下面的操作
if l.capacity == len(l.cache) {
deletedNode := l.freqMap[l.minFreq].Back()
//删除最后面的
l.freqMap[l.minFreq].Remove(deletedNode)
//if l.freqMap[l.minFreq].Len() == 0 {
// delete(l.freqMap, l.minFreq)
//}
//在cache表中也删除
delete(l.cache, deletedNode.Value.(*listElement).key)
}
//如果没有满
l.minFreq = 1
//判断是否存在
newList, exist := l.freqMap[l.minFreq]
if !exist {
newList = list.New()
//将这个freq指向这个双向链表
l.freqMap[l.minFreq] = newList
}
//将新的元素放进双向链表的头
l.cache[key] = newList.PushFront(newElement)
}
注意:对于空的双向链表似乎并不用删除,删除后又创建反复操作对内存的使用更频繁了,leetcode对于删除了的内存使用更高。
总结
上面对LRU和LFU两种缓存算法进行了分析实现,LRU相对简单,而LFU的思路其实并不太难,难点在于具体实现上,一个细节不注意就是无尽的改Bug环节。而上面也有些重复代码应该可以提取优化一下——还得等脑子清晰的时候。
同时之前做缓存中间件的时候也了解到ARC(Adaptive Replacement Cache)算法,是对LRU和LFU的一个综合平衡,leetcode里没找到这个题,不过有时间可以实现一下。
ARC(Adaptive Replacement Cache)算法是一种自适应的缓存替换算法,可以根据缓存的历史访问情况动态地调整缓存大小和替换策略,从而提高缓存的命中率。
ARC算法将缓存分为两个部分:L1和L2。L1是最近访问过的数据,L2是最近访问过的但已经从L1中替换出来的数据。ARC算法根据当前缓存的命中率和访问历史情况动态地调整L1和L2的大小。如果缓存命中率高,则扩大L1的大小;如果缓存命中率低,则减小L1的大小并扩大L2的大小。ARC算法通过控制L1和L2的大小来平衡“最近使用过的数据”和“最近不常使用的数据”两种不同的访问模式。
ARC算法的替换策略采用了两个指针:T1和T2。T1指向L1中最近访问的数据,T2指向L2中最近访问的数据。当需要替换数据时,ARC算法首先在T1中查找是否存在可以替换的数据,如果找到则替换;如果没有,则将T1指向的数据移到L2中,并将T2移动到L2中最近访问的数据。当L2中的数据太多时,ARC算法会从L2中替换掉最近访问时间最早的数据。
ARC算法的优点是可以动态地适应访问模式的变化,并且能够处理高频和低频访问的数据。但是,ARC算法的实现比较复杂,需要维护多个参数和指针,而且需要频繁地进行调整。