LFU(least frequently used (LFU) page-replacement algorithm)。即最不经常使用页置换算法,要求在页置换时置换引用计数最小的页,因为经常使用的页应该有一个较大的引用次数。
LFU 算法相当于是淘汰访问频次最低的数据,如果访问频次最低的数据有多条,需要淘汰最旧的数据。把数据按照访问频次进行排序,而且频次还会不断变化,这也是其相较于LRU更难的原因。
其实他们逻辑都是固定的,问题在于如何写出高效优雅的代码。我们为get/put这两种操作设计时间复杂度为 O(1) 的实现。
class LFUCache {
// 构造容量为 capacity 的缓存
public LFUCache(int capacity) {}
// 在缓存中查询 key
public int get(int key) {}
// 将 key 和 val 存入缓存
public void put(int key, int val) {}
}
根据LFU算法的逻辑,我们列出实现要求:
- 调用
get(key)
时返回key对应的value - 调用
get(key)
或put(key,v)
时要将key的频次freq加一 - 在put时要先检查当前数据容量是否已超过最大容量,超过则需要先移除最不经常使用的数据,即移除频次freq最小的数据,如果最小频次的数据有多个,则移除最早添加(最旧)的那个。再插入新的key-value。
数据结构选用
我们要实现O(1)
时间复杂度。那么要如何选用数据结构呢?
get(key)
要快速查询key对应的value,那么需要key映射到value的HashMap- 要快速对key的freq进行查询计算,那么需要key映射freq的HashMap
- 需要快速找到某个频次freq下的所有key,那么需要freq映射key的HashMap。
3.1 注意某个频次下的key可能有多个,即freq和key是一对多的关系,所以需要一个列表来存放key
3.2 需要找到freq最小的key进行删除,那么需要一个变量minFreq
来记录最小频次,否则需要遍历所有freq,时间复杂度不符合O(1)
3.3 需要将key按照其加入顺序存放,以便快速查询到最旧的key。
3.4 将key的频次加一时,需要将原freq的列表里的key快速删除,然后把key添加到freq+1的列表里。所以需要对列表里的key快速删除。
针对3.3,我们可能会想到用双向链表,但还不够,双向链表无法对某个位置的key快速删除,无法满足3.4,而哈希表可以,将这两个结合起来,所以我们需要用哈希链表LinkedHashSet
。它与我们在LRU用到的LinkedHashMap
基本结构是一致的。
LinkedHashSet顾名思义,是链表和哈希集合的结合体。链表不能快速访问链表节点,但是插入元素具有时序;哈希集合中的元素无序,但是可以对元素进行快速的访问和删除。它俩结合起来就兼具了哈希集合和链表的特性,既可以在 O(1) 时间内访问或删除其中的元素,又可以保持插入的时序,高效实现 3.4 这个需求。
综上,我们可以确定LFUCache的基本结构了。
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表三个映射,特别容易出错。
get(key)
我们先实现get操作,比较简单,先把基本框架写出来。
public int get(int key) {
if(!keyToVal.containsKey(key)){
return -1;
}
// 将key的频次加一
increaseFreq(key);
return keyToVal.get(key);
}
也就三步:
- 判断key是否存在,不存在返回-1
- 存在则将key的频次加一
- 返回key对应的value
那么重点就是如何实现increaseFreq(key)
。
- 我们知道key的freq保存在KV表里,那么首先要把KV表里的key的频次加一
- 接下来在FK表里,要把原freq的列表里的key删除,然后移动到
freq+1
的列表尾部 - 如果原freq的列表在我们删除key后,是空的,那么可以移除该freq,减少空间占用;同时,如果原freq刚好等于minFreq,说明什么呢?说明需要把minFreq+1了是不。
void increaseFreq(int key){
int freq = keyToFreq.get(key);
// 将key的freq+1
keyToFreq.put(key,freq+1);
freqToKey.putIfAbsent(freq+1,new LinkedHashSet<Integer>());
// 移动key到freq+1的列表
freqToKey.get(freq+1).add(key);
// 在原freq里删除该key
freqToKey.get(freq).remove(key);
// 如果原freq列表为空
if(freqToKey.get(freq).isEmpty()){
//空的话移除,减少内存占用
freqToKey.remove(freq);
//如果原频率是最小频率,那么移除后最小频率应该加一,
if(this.minFreq==freq)
this.minFreq++;
}
}
put(key,value)
接下来看put流程,稍微复杂一丢丢。先梳理下基本流程:
然后我们就可以写出put的基本逻辑了
public void put(int key, int value) {
//如果原map已存在该key,则更新频率和value
if(keyToVal.containsKey(key)){
keyToVal.put(key,value);
increaseFreq(key);
return;
}
//达到容量阈值,则移除最久的最少使用的key
if(keyToVal.size()>=this.cap){
removeMinFreqKey();
}
// cap大于0才能添加数据,这里要做处理
if(this.cap>0)
// 添加key、value
putVal(key,value);
// 重置最小频率
this.minFreq=1;
}
接下来要实现removeMinFreqKey
,怎么移除呢?
- 在FK表里,定位最小频次
minFreq
的key列表 - 移除key列表里的第一个key
- 如果移除后列表为空,则同时把该freq也移除了,节省空间
- 在KV、KF表同时删除对应的key
void removeMinFreqKey(){
LinkedHashSet<Integer> keyList = freqToKey.get(this.minFreq);
if(set==null)
return;
//移除第一个元素,即是最早添加、使用频率最低的
int oldestKey = keyList.iterator().next();
keyList.remove(oldestKey);
//如果移除后该freq没有key了,则移除该freq
if(keyList.isEmpty()){
freqToKey.remove(this.minFreq);
//问:在这里需要更新minFreq吗?
}
keyToVal.remove(oldestKey);
keyToFreq.remove(oldestKey);
}
有个细节问题,如果keyList中只有一个元素,那么删除之后minFreq对应的key列表就为空了,也就是minFreq变量需要被更新。如何计算当前的minFreq是多少呢?
实际上没办法快速计算minFreq,只能线性遍历FK表或者KF表来计算,这样肯定不能保证 O(1) 的时间复杂度。
但是,其实这里没必要更新minFreq变量,因为removeMinFreqKey这个函数只在put方法中插入新key时可能调用。而你回头看put的代码,插入新key时一定会把minFreq更新成 1,所以说即便这里minFreq变了,我们也不需要管它。
putVal
添加元素就比较简单了,注意不能漏掉任何一个表:
void putVal(int key,int value){
keyToVal.put(key,value);
// 新数据,频次为1
keyToFreq.put(key,1);
freqToKey.putIfAbsent(1,new LinkedHashSet<Integer>());
//在频次1的列表添加该key
freqToKey.get(1).add(key);
}
至此,我们的LFU算法就完成啦。
完整代码:
class LFUCache {
HashMap<Integer,Integer> keyToVal = new HashMap<>();
HashMap<Integer,Integer> keyToFreq = new HashMap<>();
HashMap<Integer,LinkedHashSet<Integer>> freqToKey = new HashMap<>();
int cap;
int minFreq = Integer.MAX_VALUE;
public LFUCache(int capacity) {
this.cap = capacity;
}
public int get(int key) {
if(!keyToVal.containsKey(key)){
return -1;
}
increaseFreq(key);
return keyToVal.get(key);
}
public void put(int key, int value) {
//如果原map已存在该key,则更新value和频次
if(keyToVal.containsKey(key)){
keyToVal.put(key,value);
increaseFreq(key);
return;
}
//超过最大容量,则移除最久的最少使用的
if(keyToVal.size()>=cap){
removeMinFreqKey();
}
if(cap!=0)
putVal(key,value);
this.minFreq=1;
}
void increaseFreq(int key){
int freq = keyToFreq.get(key);
keyToFreq.put(key,freq+1);
freqToKey.putIfAbsent(freq+1,new LinkedHashSet<Integer>());
//移动key到新的freq集合
freqToKey.get(freq+1).add(key);
freqToKey.get(freq).remove(key);
if(freqToKey.get(freq).isEmpty()){
//空的话移除,减少内存占用
freqToKey.remove(freq);
//如果原频率是最小频率,那么移除后最小频率应该加一,
if(this.minFreq==freq)
this.minFreq++;
}
}
void removeMinFreqKey(){
LinkedHashSet<Integer> set = freqToKey.get(this.minFreq);
if(set==null)
return;
//移除第一个元素,即是最早添加、使用频率最低的
int oldestKey = set.iterator().next();
set.remove(oldestKey);
//如果移除后该freq没有key了,则移除该freq
if(set.isEmpty()){
freqToKey.remove(this.minFreq);
}
keyToVal.remove(oldestKey);
keyToFreq.remove(oldestKey);
}
void putVal(int key,int value){
keyToVal.put(key,value);
keyToFreq.put(key,1);
freqToKey.putIfAbsent(1,new LinkedHashSet<Integer>());
//在频率1添加该key
freqToKey.get(1).add(key);
}
}
/**
* Your LFUCache object will be instantiated and called as such:
* LFUCache obj = new LFUCache(capacity);
* int param_1 = obj.get(key);
* obj.put(key,value);
*/