算法题就像搭乐高:手把手带你拆解 LFU 算法
NOTE:
一、key value
二、key frequency
二、frequency key
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) {}
public void put(int key, int val) {}
}
三、代码框架
LFU 的逻辑不难理解,但是写代码实现并不容易,因为你看我们要维护KV
表,KF
表,FK
表三个映射,特别容易出错。对于这种情况,labuladong 教你三个技巧:
**1、**不要企图上来就实现算法的所有细节,而应该自顶向下,逐步求精,先写清楚主函数的逻辑框架,然后再一步步实现细节。
**2、**搞清楚映射关系,如果我们更新了某个key
对应的freq
,那么就要同步修改KF
表和FK
表,这样才不会出问题。
**3、**画图,画图,画图,重要的话说三遍,把逻辑比较复杂的部分用流程图画出来,然后根据图来写代码,可以极大减少出错的概率。
下面我们先来实现get(key)
方法,逻辑很简单,返回key
对应的val
,然后增加key
对应的freq
:
public int get(int key) {
if (!keyToVal.containsKey(key)) {
return -1;
}
// 增加 key 对应的 freq
increaseFreq(key);
return keyToVal.get(key);
}
增加key
对应的freq
是 LFU 算法的核心,所以我们干脆直接抽象成一个函数increaseFreq
,这样get
方法看起来就简洁清晰了对吧。
下面来实现put(key, val)
方法,逻辑略微复杂,我们直接画个图来看:
这图就是随手画的,不是什么正规的程序流程图,但是算法逻辑一目了然,看图可以直接写出put
方法的逻辑:
public void put(int key, int val) {
if (this.cap <= 0) return;
/* 若 key 已存在,修改对应的 val 即可 */
if (keyToVal.containsKey(key)) {
keyToVal.put(key, val);
// key 对应的 freq 加一
increaseFreq(key);
return;
}
/* key 不存在,需要插入 */
/* 容量已满的话需要淘汰一个 freq 最小的 key */
if (this.cap <= keyToVal.size()) {
removeMinFreqKey();
}
/* 插入 key 和 val,对应的 freq 为 1 */
// 插入 KV 表
keyToVal.put(key, val);
// 插入 KF 表
keyToFreq.put(key, 1);
// 插入 FK 表
freqToKeys.putIfAbsent(1, new LinkedHashSet<>());
freqToKeys.get(1).add(key);
// 插入新 key 后最小的 freq 肯定是 1
this.minFreq = 1;
}
increaseFreq
和removeMinFreqKey
方法是 LFU 算法的核心,我们下面来看看怎么借助KV
表,KF
表,FK
表这三个映射巧妙完成这两个函数。
四、LFU 核心逻辑
首先来实现removeMinFreqKey
函数:
private void removeMinFreqKey() {
// freq 最小的 key 列表
LinkedHashSet<Integer> keyList = freqToKeys.get(this.minFreq);
// 其中最先被插入的那个 key 就是该被淘汰的 key
int deletedKey = keyList.iterator().next();
/* 更新 FK 表 */
keyList.remove(deletedKey);
if (keyList.isEmpty()) {
freqToKeys.remove(this.minFreq);
// 问:这里需要更新 minFreq 的值吗?
}
/* 更新 KV 表 */
keyToVal.remove(deletedKey);
/* 更新 KF 表 */
keyToFreq.remove(deletedKey);
}
删除某个键key
肯定是要同时修改三个映射表的,借助minFreq
参数可以从FK
表中找到freq
最小的keyList
,根据时序,其中第一个元素就是要被淘汰的deletedKey
,操作三个映射表删除这个key
即可。
但是有个细节问题,如果keyList
中只有一个元素,那么删除之后minFreq
对应的key
列表就为空了,也就是minFreq
变量需要被更新。如何计算当前的minFreq
是多少呢?
实际上没办法快速计算minFreq
,只能线性遍历FK
表或者KF
表来计算,这样肯定不能保证 O(1) 的时间复杂度。
但是,其实这里没必要更新minFreq
变量,因为你想想removeMinFreqKey
这个函数是在什么时候调用?在put
方法中插入新key
时可能调用。而你回头看put
的代码,插入新key
时一定会把minFreq
更新成 1,所以说即便这里minFreq
变了,我们也不需要管它。
下面来实现increaseFreq
函数:
private void increaseFreq(int key) {
int freq = keyToFreq.get(key);
/* 更新 KF 表 */
keyToFreq.put(key, freq + 1);
/* 更新 FK 表 */
// 将 key 从 freq 对应的列表中删除
freqToKeys.get(freq).remove(key);
// 将 key 加入 freq + 1 对应的列表中
freqToKeys.putIfAbsent(freq + 1, new LinkedHashSet<>());
freqToKeys.get(freq + 1).add(key);
// 如果 freq 对应的列表空了,移除这个 freq
if (freqToKeys.get(freq).isEmpty()) {
freqToKeys.remove(freq);
// 如果这个 freq 恰好是 minFreq,更新 minFreq
if (freq == this.minFreq) {
this.minFreq++;
}
}
}
更新某个key
的freq
肯定会涉及FK
表和KF
表,所以我们分别更新这两个表就行了。
和之前类似,当FK
表中freq
对应的列表被删空后,需要删除FK
表中freq
这个映射。如果这个freq
恰好是minFreq
,说明minFreq
变量需要更新。
能不能快速找到当前的minFreq
呢?这里是可以的,因为我们刚才把key
的freq
加了 1 嘛,所以minFreq
也加 1 就行了。
至此,经过层层拆解,LFU 算法就完成了。