Ltd的算法大抄·Cache

leetcode:146.460

Posted by Ltd on Monday, April 3, 2023

这是算法记录的第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 ,则应该 逐出 最久未使用的关键字。

函数 getput 必须以 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 操作)。对缓存中的键执行 getput 操作,使用计数器的值将会递增。

函数 getput 必须以 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两种操作,不过这里维护了更多的双向链表,很容易越界操作,所以需要仔细。

  1. 对于从双向链表中删除节点的操作,需要判断删除后链表是否为空了,为空同时对应的freq还是minFreq就需要更新minFreq

  2. 对于每次的freq到链表的索引都需要判断不存在的情况,若不存在还需要创建新的双向链表,注意更新两个hash-map的赋值

  • Get:首先是判断是否存在该key,存在的话需要从旧的双向链表中删除,并对freq+1,然后移动到新的双向链表的头部,最后返回这个值;

  • Put:存入元素,需要判断是否是新的key

    • 若是旧的就修改这个值,从旧的双向链表中删除,并加入到freq+1的双向链表中,结束;
    • 若是新的需要创建一个新节点,同时判断当前是否已经满了
      • 若满了需要删除最少最不常用那个
        • 删除minFreq对应双向链表的尾节点(minFreq保证是使用频率最小的,尾节点保证是最久没用的
      • 若没满就加入这个值
        • 判断freq=1对应的双向链表是否存在
          • 若不存在先创建
          • 再将这个节点加入到freq=1的双向链表头部
          • minFreq更新为1,因为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算法的实现比较复杂,需要维护多个参数和指针,而且需要频繁地进行调整。