手撕LFU算法

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算法的逻辑,我们列出实现要求:

  1. 调用get(key)时返回key对应的value
  2. 调用get(key)put(key,v)时要将key的频次freq加一
  3. 在put时要先检查当前数据容量是否已超过最大容量,超过则需要先移除最不经常使用的数据,即移除频次freq最小的数据,如果最小频次的数据有多个,则移除最早添加(最旧)的那个。再插入新的key-value。

数据结构选用

我们要实现O(1)时间复杂度。那么要如何选用数据结构呢?

  1. get(key)要快速查询key对应的value,那么需要key映射到value的HashMap
  2. 要快速对key的freq进行查询计算,那么需要key映射freq的HashMap
  3. 需要快速找到某个频次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);
}

也就三步:

  1. 判断key是否存在,不存在返回-1
  2. 存在则将key的频次加一
  3. 返回key对应的value

那么重点就是如何实现increaseFreq(key)

  1. 我们知道key的freq保存在KV表里,那么首先要把KV表里的key的频次加一
  2. 接下来在FK表里,要把原freq的列表里的key删除,然后移动到freq+1的列表尾部
  3. 如果原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,怎么移除呢?

  1. 在FK表里,定位最小频次minFreq的key列表
  2. 移除key列表里的第一个key
  3. 如果移除后列表为空,则同时把该freq也移除了,节省空间
  4. 在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);
 */
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
引用中提到,LFU(最少使用频率)缓存替换算法是一种常见的缓存替换算法。引用中提到,要理解LFU算法,首先要了解LRU(最近最少使用)算法,因为它们的原理类似。LFU算法和LRU算法都是用来管理缓存中的数据的替换策略。 LFU算法主要基于缓存中数据的使用频率来进行替换。每当缓存中的数据被访问时,其使用频率就会增加。当需要替换缓存中的数据时,LFU算法会选择使用频率最低的数据进行替换,即使用频率最低的数据很有可能是最不常用的数据。 与LFU算法类似,LRU算法是基于最近访问时间来进行替换。每当数据被访问,就会更新其最近访问时间。当需要替换数据时,LRU算法会选择最久未被访问的数据进行替换。 虽然LFU和LRU算法在实现上有一些差异,但它们都是为了优化缓存的性能。LFU算法主要关注数据的使用频率,而LRU算法则关注数据的最近访问时间。这些算法可以帮助我们更好地管理缓存中的数据,提高缓存的命中率和性能。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* *3* [LFU缓存替换算法:least frequency unused 缓存替换算法](https://blog.csdn.net/weixin_46838716/article/details/124369765)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值