1 题目描述
请你为 最不经常使用(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]
解释:
// cnt(x) = 键 x 的使用计数
// cache=[] 将显示最后一次使用的顺序(最左边的元素是最近的)
LFUCache lfu = new LFUCache(2);
lfu.put(1, 1); // cache=[1,_], cnt(1)=1
lfu.put(2, 2); // cache=[2,1], cnt(2)=1, cnt(1)=1
lfu.get(1); // 返回 1
// cache=[1,2], cnt(2)=1, cnt(1)=2
lfu.put(3, 3); // 去除键 2 ,因为 cnt(2)=1 ,使用计数最小
// cache=[3,1], cnt(3)=1, cnt(1)=2
lfu.get(2); // 返回 -1(未找到)
lfu.get(3); // 返回 3
// cache=[3,1], cnt(3)=2, cnt(1)=2
lfu.put(4, 4); // 去除键 1 ,1 和 3 的 cnt 相同,但 1 最久未使用
// cache=[4,3], cnt(4)=1, cnt(3)=2
lfu.get(1); // 返回 -1(未找到)
lfu.get(3); // 返回 3
// cache=[3,4], cnt(4)=1, cnt(3)=3
lfu.get(4); // 返回 4
// cache=[3,4], cnt(4)=2, cnt(3)=3
2 解答
🌸 「分析一下需求:」
要求你写⼀个类,接受⼀个 capacity 参数,实现 get 和 put ⽅法:
get(key) ⽅法会去缓存中查询键 key,如果 key 存在,则返回 key 对应的 val,否则返回 -1。
put(key, value) ⽅法插⼊或修改缓存。如果 key 已存在,则将它对应的值改为 val;如果 key 不存 在,则插⼊键值对 (key, val)。
当缓存达到容量 capacity 时,则应该在插⼊新的键值对之前,删除使⽤频次(后⽂⽤ freq 表示)最低的 键值对。如果 freq 最低的键值对有多个,则删除其中最旧的那个。
🌸 「分析一下思路:」
⼀定先从最简单的开始,根据 LFU 算法的逻辑,我们先列举出算法执⾏过程中的⼏个显⽽易⻅的事实:
-
调⽤
get(key)⽅法时,要返回该key对应的val。 -
只要⽤
get或者put⽅法访问⼀次某个key,该key的freq就要加⼀。 -
如果在容量满了的时候进⾏插⼊,则需要将 freq 最⼩的 key 删除,如果最⼩的 freq 对应多个 key, 则删除其中最旧的那⼀个。
-
希望能够在
O(1)的时间内解决这些需求- 1、使⽤⼀个
HashMap存储key到val的映射,就可以快速计算get(key)。 - 2、使⽤⼀个
HashMap存储key到freq的映射,就可以快速操作key对应的freq。 - 3、这个需求应该是 LFU 算法的核⼼,所以我们分开说。
- 3.1 ⾸先,肯定是需要 freq 到 key 的映射,⽤来找到 freq 最⼩的 key。
- 3.2 将 freq 最⼩的 key 删除,那你就得快速得到当前所有 key 最⼩的 freq 是多少。想要时间复杂度 O(1) 的话,肯定不能遍历⼀遍去找,那就⽤⼀个变量 minFreq 来记录当前最⼩的 freq 吧。
- 3.3 可能有多个 key 拥有相同的 freq,所以 freq 对 key 是⼀对多的关系,即⼀个 freq 对应⼀个 key 的 列表。
- 3.4 希望 freq 对应的 key 的列表是存在时序的,便于快速查找并删除最旧的 key。
- 3.5 希望能够快速删除 key 列表中的任何⼀个 key,因为如果频次为 freq 的某个 key 被访问,那么它的 频次就会变成
freq+1,就应该从freq对应的key列表中删除,加到freq+1对应的key的列表中。
- 1、使⽤⼀个
HashMap<Integer, LinkedHashSet<Integer>> freqToKeys;
int minFreq = 0;
介绍⼀下这个 LinkedHashSet,它满⾜我们 3.3,3.4,3.5 这⼏个要求。
你会发现普通的链表 LinkedList 能够满⾜ 3.3,3.4 这两个要求,但是由于普通链表不能快速访问链表中的某⼀个节点,所以⽆法满⾜ 3.5 的要求。
LinkedHashSet 顾名思义,是链表和哈希集合的结合体。链表不能快速访问链表节点,但是插⼊元素具有时序;哈希集合中的元素⽆序,但是可以对元素进⾏快速的访问和删除。
那么,它俩结合起来就兼具了哈希集合和链表的特性,既可以在 O(1) 时间内访问或删除其中的元素,⼜可以保持插⼊的时序,⾼效实现 3.5 这个需求。
综上,我们可以写出 LFU 算法的基本数据结构:
class LFUCache {
// key 到 val的映射,KV表
HashMap<Integer, Integer> keyToVal;
// key 到 freq的映射,KF表
HashMap<Integer, Integer> keyToFreq;
// freq 到 key的映射,FK表
HashMap<Integer, LinkedHashSet<Integer>> freqToKeys;
// 记录最小的频次
int minFreq;
// 记录LFU缓存的最大容量
int cap;
public LFUCache(int capacity) {
keyToVal = new HashMap<>();
keyToFreq = new HashMap<>();
freqToKeys = new HashMap<>();
this.cap = capacity;
this.minFreq = 0;
}
public int get(int key) {
return 0;
}
public void put(int key, int value) {
}
}
LFU 的逻辑不难理解,但是写代码实现并不容易,因为你看我们要维护 KV 表,KF 表,FK 表三个映射,特别容易出错。
建议:
-
不要企图上来就实现算法的所有细节,⽽应该⾃顶向下,逐步求精,先写清楚主函数的逻辑框架,然后再⼀步步实现细节。
-
搞清楚映射关系,如果我们更新了某个
key对应的freq,那么就要同步修改KF表和FK表,这样才不 会出问题。 -
画图,画图,画图,重要的话说三遍,把逻辑⽐较复杂的部分⽤流程图画出来,然后根据图来写代码,可以极⼤减少出错的概率。
解法一:
get(key) ⽅法:逻辑很简单,返回 key 对应的 val,然后增加 key 对应的 freq