LFU缓存替换算法:least frequency unused 缓存替换算法

LFU缓存替换算法:least frequency unused 缓存替换算法

提示:LUR和LFU原理都类似的
之前讲过LRUCache:
LRU缓存内存替换算法:least recently unused 缓存内存替换算法

现在来讲LFUCache


题目

请你为 最不经常使用(LFU)缓存算法设计并实现数据结构。

实现 LFUCache 类:

LFUCache(int capacity) - 用数据结构的容量 capacity 初始化对象
int get(int key) - 如果键 key 存在于缓存中,则获取键的值,否则返回 -1 。
void put(int key, int value) - 如果键 key 已存在,则变更其值;
如果键不存在,请插入键值对。
当缓存达到其容量 capacity 时,则应该在插入新项之前,移除最不经常使用的项。
在此问题中,当存在平局(即两个或更多个键具有相同使用频率)时,应该去除 最近最久未使用 的键。【LRU】
为了确定最不常使用的键,可以为缓存中的每个键维护一个 使用计数器 。使用计数最小的键是最久未使用的键。【频率times】

当一个键首次插入到缓存中时,它的使用计数器被设置为 1 (由于 put 操作)。
对缓存中的键执行 get 或 put 操作,使用计数器的值将会递增。

函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。

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


一、审题

示例:
熟悉LRUCache的话,就知道LFU只需要在LRU上面多一个嵌入的frequency维度
即一个2维双向链表

看本题之前,一定把这个文章看了,了解LRU怎么实现的:
LRU缓存内存替换算法:least recently unused 缓存内存替换算法
看懂了LRU,就很容易理解LFU

不妨设capacity=3
put(A,17)
put(B,13)
put(C,1)
这3个都是操作频次为1的
现在这个双向链表就是LRU,按照使用时间顺序链接的,up和down指针相连的。
在这里插入图片描述
get©
C现在操作频次为2了,显然不是1,AB还是1,独立开来
在这里插入图片描述
其实现在LRFU由2个桶构成,桶1频次为1,桶2频次为2,
桶和桶之间是last和next指针链接

put(D,10)
此时ABC已经是满足容量了,所以要挤出去一个,挤出去谁呢?自然是频率count最低的那个桶(一个桶内就一个LRU),然后要删除head,操作就是LRU中的操作。
在这里插入图片描述
put(B,15)
然后,又操作B,B要去2频次的桶,而且比C晚一点
在这里插入图片描述
put(B,17)
再一次操作B,频次为3,自然要新增一个桶
在这里插入图片描述
count=1的频次最低,也即最左边的桶mostLeft
每次挤兑出去的一定是mostLeft中的head
这就是LFU
是不是有了LRU就能很容易理解?


二、解题

看了案例就知道,这就是LRU上多了一个频次维度,仅此而已
当然,代码非常考验细节!!!
联系coding能力
我看的是左神的代码:

桶的内部,是一个二维的双向链表,每一个节点有2个指针,这个节点被操作了几次,用times记录

// 节点的数据结构
    public static class Node {
        public Integer key;
        public Integer value;
        public Integer times; // 这个节点发生get或者set的次数总和
        public Node up; // 节点之间是双向链表所以有上一个节点
        public Node down;// 节点之间是双向链表所以有下一个节点

        public Node(int k, int v, int t) {
            key = k;
            value = v;
            times = t;
        }
    }

LRU操作的是NDLL
而LFU操作的是桶Bucket,桶比NDLL多了last和next指针(在同种,NDLL的last和next被称为up和down)
(1)Bucket内部某一个桶,它有节点吗?没有就是空,需要有判断函数:isEmpty()
(2)给桶新加一个节点,放入头addNodeFromHead(x)
(3)删除桶中的某个节点:分为删除头,中间x和尾,分情况讨论,删除方法不一样

// 桶结构
    public static class Bucket {//{}--{}--{}中的{},
        // 它内部装了一串双向量表,有head和tail,它自己还有左右接指针last和next
        public Node head; // 桶的头节点
        public Node tail; // 桶的尾节点
        public Bucket last; // 桶之间是双向链表所以有前一个桶---这就是二维双向链表????牛
        public Bucket next; // 桶之间是双向链表所以有后一个桶

        public Bucket(Node node) {//新建一个桶,必须给一个节点,至少
            head = node;
            tail = node;
        }

        // 把一个新的节点加入这个桶,新的节点都放在顶端变成新的头部,
        // 删除的时候就是最左侧桶的尾部,旧
        public void addNodeFromHead(Node newHead) {
            newHead.down = head;
            head.up = newHead;
            head = newHead;//挪上去
        }

        // 判断这个桶是不是空的
        public boolean isEmpty() {
            return head == null;
        }

        // 删除桶中的node节点,并保证node的上下环境重新连接
        public void deleteNode(Node node) {
            if (head == tail) {//一个节点,全部删除
                head = null;
                tail = null;
            } else {
                //多个节点
                if (node == head) {//是头
                    head = node.down;
                    head.up = null;
                } else if (node == tail) {//是尾
                    tail = node.up;
                    tail.down = null;
                } else {//中间节点,跳过直连
                    node.up.down = node.down;
                    node.down.up = node.up;
                }
            }

            node.up = null;
            node.down = null;//断开node前后,表示消失
        }
    }///桶结构结束

拿着这个桶,实现LFU
(1)LFU属性:
——capacity,最多能缓存几个点?容量
——size目前已经缓存了几个点?
——哈希表:keyNodeMap,key的代表节点Node:代表Father
——头桶headBucket(leftMost最左测那个桶)

(2)这个函数:boolean modifyHeadBucket(Bucket removeNodeBucket)
判断刚刚减少了一个节点的桶是不是已经空了。
removeNodeBucket:刚刚减少了一个节点的那个桶
——如果不空,什么也不做
在这里插入图片描述

——如果空了,假如removeNodeBucket还是整个缓存结构最左的桶(headBucket)。
则:删掉这个桶的同时,也要让最左的桶变成removeNodeBucket的下一个。
在这里插入图片描述

——如果空了,假如removeNodeBucket不是整个缓存结构最左的桶(headBucket)。
则:把这个桶删除,并保证上一个的桶和下一个桶之间还是双向链表的连接方式
在这里插入图片描述

函数的返回值表示刚刚减少了一个节点的桶是不是已经空了,空了返回true;不空返回false

(3)移动move(Node node, Bucket oldBucket) 函数
由于操作来了,node这个节点的次数+1了,这个节点原来在oldBucket里。既然频次增加了,就需要放到跟高一个频次的桶中,所以:
把node从oldBucket删掉,然后放到次数+1的桶中接入尾部
整个过程既要保证桶之间仍然是双向链表,也要保证节点之间仍然是双向链表
显然,oldBucket的上一个桶preBucket需要知道是谁?
oldBucket的下一个桶nextBucket也需要知道是谁?

在这里插入图片描述
(4)新加节点函数add(key,value)
——有了就是更新
——没有就是新加,容量多了,先移除最左边桶的头,然后加入最左边桶的尾部,和LRU一样

(5)获取key,get(x)
只需要将其移动到高频次那个桶
很复杂,了解核心思想即可:

//LFUCache数据结构--
    //有了桶,桶中有一串链表,就可以整一个最左侧的桶,开始一个桶一个桶地往下挂接,形成一个二维链表
    //第一维度:headBucket--{}--{}--{}
    //二维度:每一个桶中有一串链表:{head--Node-tail}

    private int capacity; // 缓存的大小限制,即K
    private int size; // 缓存目前有多少个节点
    private HashMap<Integer, Node> keyNodeMap;// 表示key(Integer)由哪个节点(Node)代表
    private HashMap<Node, Bucket> nodeBucketMap; // 表示节点(Node)在哪个桶(Bucket)里--重点
    private Bucket headBucket; // 整个结构中位于最左的桶---最左侧的桶


    // 本题测试链接 : https://leetcode.com/problems/lfu-cache/
    // 提交时把类名和构造方法名改为 : LFUCache
    public Top89LFU(int K) {//初始化这个缓存机制的数据结构时,需要告诉缓存的容量K
        capacity = K;
        size = 0;//现在有几个节点?
        keyNodeMap = new HashMap<>();
        nodeBucketMap = new HashMap<>();
        headBucket = null;
    }

    // removeNodeBucket:刚刚减少了一个节点的桶
    // 这个函数的功能是,判断刚刚减少了一个节点的桶是不是已经空了。
    // 1)如果不空,什么也不做
    //
    // 2)如果空了,removeNodeBucket还是整个缓存结构最左的桶(headBucket)。
    // 删掉这个桶的同时也要让最左的桶变成removeNodeBucket的下一个。
    //
    // 3)如果空了,removeNodeBucket不是整个缓存结构最左的桶(headBucket)。
    // 把这个桶删除,并保证上一个的桶和下一个桶之间还是双向链表的连接方式
    //
    // 函数的返回值表示刚刚减少了一个节点的桶是不是已经空了,
    // 空了返回true;不空返回false
    private boolean modifyHeadBucket(Bucket removeNodeBucket) {
        if (removeNodeBucket.isEmpty()) {
            //桶空了
            if (headBucket == removeNodeBucket) {
                headBucket = removeNodeBucket.next;//更新最左侧的桶
                if (headBucket != null) {
                    headBucket.last = null;//断开之前的桶
                }
            } else {
                //最左侧的桶,不是刚刚删除了一个节点的桶--跳过本桶直连前后,与链表类似
                removeNodeBucket.last.next = removeNodeBucket.next;
                if (removeNodeBucket.next != null) {//确实后面还要,就要跳指
                    removeNodeBucket.next.last = removeNodeBucket.last;
                }
            }
            return true;
        }
        //不空无所谓
        return false;
    }

    // 函数的功能
    // node这个节点的次数+1了,这个节点原来在oldBucket里。
    // 把node从oldBucket删掉,然后放到次数+1的桶中
    // 整个过程既要保证桶之间仍然是双向链表,也要保证节点之间仍然是双向链表
    private void move(Node node, Bucket oldBucket) {
        oldBucket.deleteNode(node);
        // preBucket表示次数+1的桶的前一个桶是谁
        // 如果oldBucket删掉node之后还有节点,oldBucket就是次数+1的桶的前一个桶,就是node所在的桶
        // 如果oldBucket删掉node之后空了,
        // oldBucket是需要删除的,所以次数+1的桶的前一个桶,是oldBucket的前一个
        Bucket preBucket = modifyHeadBucket(oldBucket) ? oldBucket.last : oldBucket;
        // nextBucket表示次数+1的桶的后一个桶是谁
        Bucket nextBucket = oldBucket.next;
        if (nextBucket == null) {
            Bucket newBucket = new Bucket(node);//因为操作频次+1了,右边没有桶的话,就需要生成一个再放入
            if (preBucket != null) {
                preBucket.next = newBucket;
            }
            newBucket.last = preBucket;
            if (headBucket == null) {
                headBucket = newBucket;
            }
            nodeBucketMap.put(node, newBucket);
        } else {//后面的桶不是空的,有
            if (nextBucket.head.times.equals(node.times)) {//而且频次确实是本次操作之后+1的频次
                nextBucket.addNodeFromHead(node);//然后加进去就行
                nodeBucketMap.put(node, nextBucket);//同步记录节点的地址
            } else {//频次不一样,依然需要重新生成一个桶
                Bucket newBucket = new Bucket(node);
                if (preBucket != null) {
                    preBucket.next = newBucket;
                }
                newBucket.last = preBucket;
                newBucket.next = nextBucket;
                nextBucket.last = newBucket;
                if (headBucket == nextBucket) {
                    headBucket = newBucket;
                }
                nodeBucketMap.put(node, newBucket);
            }
        }
    }

    //新加节点
    public void put(int key, int value) {
        if (capacity == 0) {
            return;
        }
        if (keyNodeMap.containsKey(key)) {//修改值
            Node node = keyNodeMap.get(key);
            node.value = value;
            node.times++;
            Bucket curNodeList = nodeBucketMap.get(node);
            move(node, curNodeList);//完成移动工作
        } else {
            if (size == capacity) {//已经放满了节点,需要干掉之前的最低频的那个节点,然后才能新加
                Node node = headBucket.tail;
                headBucket.deleteNode(node);//需要删除那个最久没用的
                modifyHeadBucket(headBucket);
                keyNodeMap.remove(node.key);
                nodeBucketMap.remove(node);
                size--;//这样才有一个空间给新来的数据添加机会
            }
            Node node = new Node(key, value, 1);//新来的频次为1
            if (headBucket == null) {
                headBucket = new Bucket(node);//没有桶
            } else {
                if (headBucket.head.times.equals(node.times)) {
                    headBucket.addNodeFromHead(node);//最左边那个确实是为1频次
                } else {//否则就要造一个1频次的桶
                    Bucket newBucket = new Bucket(node);
                    newBucket.next = headBucket;
                    headBucket.last = newBucket;
                    headBucket = newBucket;
                }
            }
            keyNodeMap.put(key, node);//同步记录刚刚来的这个bucket和节点的关系
            nodeBucketMap.put(node, headBucket);
            size++;//新增一条记录
        }
    }

    //获取key
    public int get(int key) {
        if (!keyNodeMap.containsKey(key)) {
            return -1;
        }
        Node node = keyNodeMap.get(key);//有,操作频次++
        node.times++;
        Bucket curNodeList = nodeBucketMap.get(node);
        move(node, curNodeList);//完成移动操作--当前所在的桶就是旧桶的位置
        return node.value;
    }
    //思想要搞明白,大厂有人考过………………

总结

提示:重要经验:

1)LFU就是LRU的扩展,然后多了频次这个维度,基础数据结构是桶:Bucket
2)核心思想就和LRU很相似,如果面试遇到的话说核心思想,然后尝试这沟通看看能否写其中一个函数,太复杂了这个玩意。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

冰露可乐

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值