3.1-3.2LFU&LRU算法

3.1 手把手教你写LRU缓存淘汰算法

3.1.1 LRU(Least Recently Used)算法介绍

  • LRU算法:是一种缓存淘汰策略,最近最少使用置换算法
  • **操作系统上的解释:**LRU是一种页面置换算法,在页面置换时,会根据每个页面上次被访问时的时间戳,比较当前内存页框中的所有页的时间戳,如果时间戳最远,则优先淘汰它,这是一种假设,假设以过去预测将来,过去很久没有用过的页在将来也大概率不会用到。
  • LRU置换算法的关键是按照访问时序来淘汰

3.2.2 题目实例

  • 来看到leetcode.146题

  • 请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。
    实现 LRUCache 类:

  • LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存

  • int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。

  • void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;

  • 如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该逐出最久未使用的关键字。

  • 首先题目要接收一个capacity参数作为缓存的最大容量,然后实现两个API,一个是put(key,val)get(key),方法获得key所对应的val,如果key不存在则返回-1

  • 注意,使用遍历暴力模拟的方式是过不了这道题的,必须使得get和put的时间复杂度为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]

解释
LRUCache lRUCache = new LRUCache(2);
lRUCache.put(1, 1); // 缓存是 {1=1}
lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
lRUCache.get(1);    // 返回 1 {2=2, 1=1},因为访问了键1,所以2会提到队头
lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3},会把队尾元素出队
lRUCache.get(2);    // 返回 -1 (未找到)
lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
lRUCache.get(1);    // 返回 -1 (未找到)
lRUCache.get(3);    // 返回 3
lRUCache.get(4);    // 返回 4

来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/lru-cache
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

3.1.3 算法设计

  • [1] 显然Cache中的元素必须具有时序,以区分最近使用的和久未使用的数据,当容量满了之后要删除最久未使用的那个元素腾位置
  • [2] 要在Cache中快速查找某个key是否存在并得到对应的val
  • [3] 每次访问cache中的某个key,需要将这个元素变为最近使用的,也就是说cache要支持在任意位置快速插入的和删除元素
  • 分析:哈希表查找快,但是数据无固定顺序,链表有顺序之分,插入删除快,但是在这之前定位节点需要时间复杂度为O(n)的查找,所以结合两者的特点,使用LinkedHashMap作为我们的数据结构,JAVA中已经有这个数据结构了,如果是其他语言的话还需要自己实现一下,其具体的结构如下

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EQzH6Wrd-1661227302574)(./image/146-1.png)]

  • 哈希链表:可以根据key值快速查找到节点,在遍历时也可以根据插入的顺序来保证遍历顺序的唯一性。

    • 如果每次默认从链表尾部添加元素,那么显然越靠近尾部的元素就越是最近使用的,越靠近头部的元素就是越久未使用的

    • 对于某一个key,可以通过哈希表快速定位到链表中节点,从而取到对应的val

    • 链表显然是支持在任意位置的快速插入和删除的,只需要修改指针即可,只不过传统的链表无法安装索引快速访问某一个位置的元素,而这里借助了哈希表,可以通过key快速映射到任意一个链表节点,然后进行插入和删除

  • 我们首先从零开始实现一个双向链表

//定义双链表的节点类,注意双链表的话是需要有两个指针,一个指针指向前面,一个指针指向后面
class Node {
    public int key;
    public int val;
    public Node next,prev;
    public Node(int k,int v){
        this.key=k;
        this.val=v;
    }
}
//依靠Node类型构建双链表
class DoubleList{
    //头尾的虚节点
    private Node head,tail;
    //链表的元素个数
    private int size;

    public DoubleList(){
        //初始化双向链表的数据
        head = new Node(0,0);
        tail = new Node(0,0);
        head.next=tail;
        //头结点没有上一个节点,下一个节点是尾结点
        tail.prev=head;
        //尾结点有上一个节点,没有下一个节点
        this.size = 0;
    }

    //在链表尾部添加节点x,时间复杂度为O(1)
    public void addList(Node x){
        //修改指针
        //x节点的上一个节点设置为tail的上一个节点
        x.prev = tail.prev;
        //x节点的下一个节点设置为tail
        x.next = tail;
        //tail节点的上一个节点的下一个节点属性设置为x
        tail.prev.next = x;
        //tail节点的上一个节点设置为x
        tail.prev =x;
        //链表长度+1
        this.size++;
    }

    //删除链表中的x节点(x一定存在)
    //由于是双链表而且给的是目标Node节点,时间复杂度是O(1)
    public void remove(Node x){
        //先处理之前节点的关系
        //使得x的上一个节点连接x的下一个节点
        x.prev.next = x.next;
        //使得x的下一个节点连接x的上一个节点
        x.next.prev = x.prev;
        this.size--;
    }

    //删除链表中的第一个节点,并返回该节点,时间复杂度为O(1)
    public Node removeFirstNode(){
        if(head.next == tail){
            return null;
        }
        Node x = head.next;
        //头节点的下一个节点指向第一个节点的下一个节点
        head.next = x.next;
        //第一个节点的下一个节点的上一个节点属性指向头结点
        x.next.prev = head;
        this.size--;
        return x;
    }

    //获取链表总长度
    public int getSize(){
        return this.size;
    }
    
}
  • [问题1] 为什么要是双向链表呢而不是单链表呢?
    • 我们想要达到的效果是删除API的时间复杂度是O(1)而我们想要进行删除操作,那么就得到目标节点的时候,我们也希望能够以O(1)的方式得到其前驱节点,其前驱节点在单链表中是无法得到的,要得到的话只能通过O(n)的时间复杂度遍历得到,那么我们维护一个前驱指针来便于我们得到其前驱节点
  • 注意:我们所实现的双链表API只能从尾部插入,也就是靠尾部的数据是最近使用的,靠头部的数据是最久未使用的
public class LRU {
    //key->Node(key,val)
    private HashMap<Integer, Node> map;
    //Node(k1,v1)<->Node(k2,v2)
    private DoubleList cache;
    //最大容量
    private int cap;
    public LRU(int cap){
        this.cap = cap;
        map = new HashMap<>();
        cache = new DoubleList();
    }
}
  • 由于要同时维护一个双链表cache和一个哈希表map,很容易漏掉一些操作,比如删除某个key的时候,在cache中删除了对应的node,但是却忘记在map中删除key
  • 解决这种问题的有效方法是:在这两种数据结构之上提供一层抽象API,就是尽量让LRU的主方法getput避免直接操作mapcache的细节,简单来说,就是你不是会忘记这个忘记那个的嘛,那就把Cache和map的操作合并在一起就行
public class LRU {
    //key->Node(key,val)
    private HashMap<Integer, Node> map;
    //Node(k1,v1)<->Node(k2,v2)
    private DoubleList cache;
    //最大容量
    private int cap;
    public LRU(int cap){
        this.cap = cap;
        map = new HashMap<>();
        cache = new DoubleList();
    }
    /*将某个key提升为最近使用*/
    private void makeRecently(int key){
        Node x= map.get(key);
        //先从链表中删除该节点
        cache.remove(x);
        //将该节点插入到表尾
        cache.addList(x);
    }

    /**
     * 添加最近使用的元素
     * @param key
     * @param val
     */
    private void addRecently(int key,int val){
        Node x = new Node(key,val);
        //链表尾部就是最近使用的元素
        cache.addList(x);
        //别忘了在map中添加key的映射
        map.put(key,x);
    }

    /**
     * 删除某一个key
     * @param key
     */
    private void deleteKey(int key){
        Node x = map.get(key);
        //从链表中删除该key所对应的节点
        cache.remove(x);
        //从map中删除
        map.remove(x);
    }

    /**
     * 删除最久没有使用的元素
     */
    public void removeLeastRecently(){
        //链表头部的第一个元素就是最久未使用的
        Node deleteNode = cache.removeFirstNode();
        //同时从map中删除它的key
        int key = deleteNode.key;
        map.remove(key);
    }
    
}
  • 写完代码后来理一下思路

  • [问题2] 我们需要封装什么API?

    • 如果某个节点最近被使用过了,那么这个节点就应该被放到链表的尾部,也就是将指定节点移动到链表的尾部,这时候只需要操作cache
    • 如果需要将某个节点加入到Cache,那么这个节点应当是新节点,也就是将将指定节点移动到链表尾部并且在cache中添加数据
    • 如果需要置换某个节点,根据LRU算法的特点,也就是需要将链表头部节点删除,并且在cache中将其删除
    • 同时第三个API而言,我们还可以根据指定的key来删除指定的节点
  • [问题3] 既然哈希表中已经存了key,为什么链表中还要存key和val呢?只存val不就行了嘛?

    • 当缓存容量已满的时候,不仅要删除最后一个Node节点,还要把map中映射到该节点的key同时删除,而这个key只能由node来得到,如果node只存val的话就会造成没办法删除map中的键,造成错误
    /**
     * 外部取cache的值
     * @param key
     * @return
     */
    public int get(int key){
        if(!map.containsKey(key)){
            return -1;
        }
        //将该数据提升为最近使用的
        makeRecently(key);
        return map.get(key).val;
    }

    /**
     *
     * @param key
     * @param val
     */
    public void put(int key,int val){
        if(map.containsKey(key)){
            //如果有相同的key值,那么就删除旧的数据
            deleteKey(key);
            //新插入的数据为最近使用的数据
            this.addRecently(key,val);
            return;
        }
        //如果是新的数据,那么就需要淘汰一页后插入
        if(cap == cache.getSize()){
            //删除最久未使用的元素
            removeLeastRecently();
        }
        //添加最近使用的元素
        addRecently(key, val);

    }
  • 了解了原理之后我们用Java内置的数据结构来完成这个事情
import java.util.LinkedHashMap;

class LRUCache {
    private LinkedHashMap<Integer,Integer> cache = new LinkedHashMap<>();
    int cap;
    public LRUCache(int capacity) {
        this.cap = capacity;
    }

    public int get(int key) {
        if(!cache.containsKey(key)){
            return -1;
        }
        //将key变为最近使用
        //1.将cache中的key对应的key在删除前保存下来
        int val = cache.get(key);
        //2.删除该key
        cache.remove(key);
        //3.将该key放到链表的尾部
        cache.put(key,val);
        return val;
    }
 
    public void put(int key, int value) {
        //首先检查cache中是否有该key,如果有,则覆盖
        if(cache.containsKey(key)){
            cache.remove(key);
            cache.put(key,value);
            return;
        }
        //如果cache中没有该key,则先检查是否需要置换
        if(cache.size() >= this.cap){
            //此时需要置换,需要删除链表的最后一个元素
            int oldKey  = cache.keySet().iterator().next();//取到链表的头部
            cache.remove(oldKey);
        }
        cache.put(key,value);
    }
}

/**
 * Your LRUCache object will be instantiated and called as such:
 * LRUCache obj = new LRUCache(capacity);
 * int param_1 = obj.get(key);
 * obj.put(key,value);
 */

3.2 实现LFU(Least Frequently Used)缓存算法

3.2.1 LFU算法介绍

LFU算法:每次淘汰那些使用次数最少的数据,LRU算法相当于把数据按照时间进行排序,这个需求借助链表很自然就能够实现,一直从链表头部加入元素的话,就可以实现这个需求,其越接近头部的话就是越新的数据,越接近尾部的元素就越是旧的数据,进行缓存淘汰的时候只需要简单将尾部的元素淘汰即可。

LFU算法相当于把数据按照访问频次进行排序,如果多个数据拥有相同的访问频次就应该删除最早插入的那个数据,也就是说LFU算法是淘汰访问频次最低的数据,如果访问频次最低的数据有多条,需要淘汰最旧的数据。

3.2.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
  • 要求你写一个类,接收一个capacity参数,实现get和put方法
  • get(key)方法去缓存中查询键key,如果 key已经存在,则返回key所对应的val,否则返回-1
  • put(key,val)方法插入或者修改缓存,如果key已经存在,则将它对应的值改为val,如果key不存在,则插入键值对(key,val)
  • 当容量达到capacity的时候,则应该在插入新的键值对之前,删除使用频次(下面使用freq来表示)最低的键值对,如果freq最低的键值有对多个,则删除最旧的那个

3.2.3 思路分析

  • 首先需要计算从key到val的映射,这里我们使用一个HashMap<Integer,Integer> keyToVal,就可以快速计算get(key)
  • 使用一个HashMap<Integer,Integer> keyToFreq就可以快速操作key对应的freq
  • LFU算法核心
    • 需要freqkey的映射
    • freq最小的key删除,那就应该快速得到当前所有key最小的freq是多少。想要时间复杂度为O(1)的话,肯定不能进行遍历的,对于这种需求,我们可以维护一个临时变量(备忘录思想)minFreq来记录当前最小的freq
    • 可能有多个key拥有相同的freq,所以freqkey是一对多的关系,也就是freq对应一个key列表
    • 希望freq对应的key的列表是存在时序,便于快速查找并删除最旧的key
    • 希望能够快速删除key列表中的任意一个key,因为如果频次为freq的某个key被访问了,那么它的频次就应该变成freq+1,就应该从freq对应key列表中删除,加到freq+1对应的列表中
  • 于是设计出以下数据结构
    HashMap<Integer,Integer> keyToVal;//用于快速获取key=>val的值
    HashMap<Integer,Integer> keyToFreq;//用于快速获取每个值的访问频率
    HashMap<Integer, LinkedHashSet<Integer>> freqToKeys;//用于形成一张表,该表中保存着每个频率所对应的key值
    //LinkedHashSet是链表和哈希集合的结合体,链表不能快速访问链表节点,需要遍历执行,无法达到我们想要快速删除目标值的目的
    //哈希集合中的元素无需,但是可以对元素快速的访问和删除
    int minFreq = 0;
    //临时维护的变量,用来记录最小的频次
    int cap = 0;
    //记录LFU缓存的最大容量

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-41eUnghk-1661227302575)(./image/LFU-01.png)]

import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;

/**
 * 即将要维护KV表,KF表,FK表三个映射
 */
class LFUCache {
    HashMap<Integer,Integer> keyToVal;//用于快速获取key=>val的值
    HashMap<Integer,Integer> keyToFreq;//用于快速获取每个值的访问频率
    HashMap<Integer, LinkedHashSet<Integer>> freqToKeys;//用于形成一张表,该表中保存着每个频率所对应的key值
    //LinkedHashSet是链表和哈希集合的结合体,链表不能快速访问链表节点,需要遍历执行,无法达到我们想要快速删除目标值的目的
    //哈希集合中的元素无需,但是可以对元素快速的访问和删除
    int minFreq ;
    //临时维护的变量,用来记录最小的频次
    int cap ;
    //记录LFU缓存的最大容量

    public LFUCache(int capacity) {
        keyToVal = new HashMap<>();
        keyToFreq = new HashMap<>();
        freqToKeys = new HashMap<>();
        this.cap = capacity;
        this.minFreq = 0;//一开始还没有进行访问,故先初始化为0
    }

    public int get(int key) {
        //如果K-V表没有这个key,直接返回-1
        if(!keyToVal.containsKey(key)){
            return -1;
        }
        //否则就是有这个key,返回值之外,还需要对频率进行修改
        increase(key);
        return keyToVal.get(key);
    }

    public void put(int key, int value) {
        //根据算法思路图转换为代码
        if(this.cap<=0){
            return ;
        }
        //1.检查key是否存在,以KV表为准
        if(keyToVal.containsKey(key)){
            //当存在该key的时候
            keyToVal.put(key,value);
            //同时更新频次相关信息
            increase(key);
            return;
        }

        //2.如果key不存在,检查当前容量是否已经满了
        //FK KV KF
        if(keyToVal.size() >= this.cap){
            //如果已经满了,则需要置换
            /**LFU置换算法**/
            //首先获取freq最小的key列表
            LinkedHashSet<Integer> keys = freqToKeys.get(this.minFreq);
            //这些key都是待淘汰的,我们直接将最久没有使用过的key给它淘汰掉
            int oldest = keys.iterator().next();//这里获取的是链式集合头部的元素,越后面的就是越先插入的,淘汰最头部的
            //1.更新频次相关信息=>我们将FK表的信息给更新了先
            keys.remove(oldest);
            if(keys.isEmpty()){//如果删除了这个频次的相关信息后,再也没有这个频次所对应的key表了,直接就直接删除这个key表
                freqToKeys.remove(this.minFreq);
                //此处是否应该需要更新minFreq
                //假如说需要更新minFreq,那么该操作的时间复杂度就不是O(1)了,而是O(n)
                //我们将算法流程走一遍下来哈
                //首先就是什么时候会走这个分支?
                //是当需要进行置换,而且插入一个新key的时候才会被调用
                //那么插入新key的时候,最小的访问频率是不是一定是1?
                //那么就没有必要更新minFreq了
            }
            //更新key-val表,已经预处理完毕
            keyToVal.remove(oldest);
            //更新KF表
            keyToFreq.remove(oldest);
        }
        //否则未满则直接插入即可
        keyToVal.put(key,value);//将对应的值插入到主表
        keyToFreq.put(key,1);//初次访问设置为1
        //同时设置fk表
        freqToKeys.putIfAbsent(1,new LinkedHashSet<>());
        freqToKeys.get(1).add(key);
        //插入新key后的最小freq肯定是1
        this.minFreq = 1;
    }

    void increase(int key){
        //1.修改key=>freq表
        int freq = keyToFreq.get(key);
        keyToFreq.put(key,freq+1);
        //2.修改freq=>key表
        //2.1 将freq=>对应的key删除掉
        LinkedHashSet<Integer> keys = freqToKeys.get(freq);
        keys.remove(key);
        //将key加入到freq+1对应的列表里
        freqToKeys.putIfAbsent(freq+1,new LinkedHashSet<>());
        freqToKeys.get(freq+1).add(key);
        if(keys.isEmpty()){
            //那么就移除这个表
            freqToKeys.remove(freq);
            //如果这个freq恰好就是最小的,已经没有比它更小的了,符合逻辑,删除
            if(freq == this.minFreq){
                this.minFreq++;
            }
        }
    }
}
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值