如何实现一个LFU算法

在正文开始之前先讲讲我个人吧。持续了这么一段时间写作,发现一周一篇文章对目前的我来说还是有点吃力的,因为最近加班比较多,周末又容易摆烂,很少有时间想好要写什么。充电时间也感觉不够,所以准备先换成两周一篇文章了。暂时先这么定,后续适应了,再加大强度好了。

 

好了,开始来填坑了,之前有提到过一个LFU算法。那么什么是LFU呢?全名为:Least Frequently Used。也就是最少被使用的方法。核心思想是:如果数据过去被访问多次,那么将来被访问的频率也更高。

 

所以我们可以提取关键词“多次”,也就是说对于访问的元素,会有个次数统计。例如访问顺序为:1 1 1 2 6 2。那么假设真的触发淘汰的。淘汰顺序应该是 6 > 2 > 1,因为按照LFU的思想,访问次数越多越有可能被访问,也就更不应该淘汰。

 

原理

原理其实也很简单,就是在访问元素时,累计次数加1,然后循环判断访问次数是否大于等于它上一个元素,如果是则交换位置。具体流程如下:

 


 

当然,实现的数据结构依然是:散列表+双向链表。具体原因前一篇文章已经讲解,不清楚的小伙伴可以去看看,知道的小伙伴也可以看看,已经看过的小伙伴当然也可以复习下。

 

接着就show the code好了。

源码

具体逻辑依然在源码中打上注释,我这里就不再赘述了。

public final class LFUDemo<K, V> {

    @Setter
    @Getter
    private static final class Node<K, V> {
        private K key;
        private V value;
        private int count;
        private Node<K, V> prev;
        private Node<K, V> next;

        public Node(K key, V value) {
            this.key = key;
            this.value = value;
            // 初始化代表第一次访问,则访问次数为1
            this.count = 1;
        }

        public void visit() {
            count++;
        }

        @Override
        public String toString() {
            return "Node{" +
                    "key=" + key +
                    ", value=" + value +
                    ", count=" + count +
                    ", next=" + next +
                    '}';
        }
    }

    private final int bufferSize;

    private final Map<K, Node<K, V>> valueMap;

    private final Node<K, V> head;
    private final Node<K, V> tail;

    public LFUDemo() {
        this(10);
    }

    public LFUDemo(int bufferSize) {
        this.bufferSize = bufferSize;
        // 防止空指针
        head = new Node<>(null, null);
        tail = new Node<>(null, null);
        head.next = tail;
        tail.prev = head;
        valueMap = new HashMap<>();
    }

    public void put(K k, V v) {
        // 判断是否触发淘汰
        replacementIfNecessary();
        // 判断是否已经存在
        Node<K, V> kvNode = valueMap.get(k);
        if (kvNode != null) {
            // 标记已经访问过
            kvNode.visit();
            kvNode.setValue(v);
        } else {
            kvNode = new Node<>(k, v);
            valueMap.put(k, kvNode);
            // 先尾插入.成为链表的一个节点
            kvNode.next = tail;
            kvNode.prev = tail.prev;
            tail.prev.next = kvNode;
            tail.prev = kvNode;

        }
        // 循环判断是否需要与上一个元素交换位置
        movePrevIfNecessary(kvNode);
    }

    public V get(K k) {

        Node<K, V> kvNode = valueMap.get(k);
        if (kvNode == null) {
            return null;
        }
        kvNode.visit();
        movePrevIfNecessary(kvNode);
        return kvNode.getValue();
    }

    /**
     * 判断是否需要淘汰尾部元素
     */
    private void replacementIfNecessary() {
        if (valueMap.size() >= bufferSize) {
            Node<K, V> last = tail.prev;
            tail.prev = last.prev;
            last.prev.next = tail;
            valueMap.remove(last.getKey());
        }
    }

    public static void main(String[] args) {
        LFUDemo<String, Integer> lfuDemo = new LFUDemo<>(3);
        lfuDemo.put("1", 1);
        lfuDemo.put("1", 1);
        lfuDemo.put("1", 3);
        lfuDemo.put("3", 4);
        lfuDemo.put("3", 4);
        lfuDemo.put("5", 5);
        lfuDemo.put("6", 6);
        lfuDemo.put("7", 7);
        lfuDemo.put("8", 8);
        lfuDemo.put("1", 1);
        lfuDemo.put("7", 7);
        System.out.println(lfuDemo.get("5"));
        System.out.println(lfuDemo);
    }

    private void movePrevIfNecessary(Node<K, V> node) {
        Node<K, V> prev = node.prev;
        // 当前节点已经在头部,无需移动
        if(prev == head) {
            return;
        }
        // 如果当前节点的访问数大于等于上一个节点,表明当前节点更有可能被访问,所以需要和上一个节点交换位置
        if (node.count >= prev.count) {
            node.prev = prev.prev;
            node.prev.next = node;
            prev.prev = node;
            prev.next = node.next;
            prev.next.prev = prev;
            node.next = prev;
            // 继续与上一个判断
            movePrevIfNecessary(node);
        }
        // 因为这里链表是有序的,所以如果当前节点的访问次数小于上一节点的话,那么就一定会小于包含上一节点的前列全部节点
    }

    @Override
    public String toString() {
        return "LFUDemo{" +
                "head=" + head +
                '}';
    }
}

 

核心逻辑其实就是movePrevIfNecessaryreplacementIfNecessary。交换节点的逻辑,相信经过上一篇文章的阅读,小伙伴们也能够自己画出来,那我就不在这里贴出来了。

 

在写这段交换逻辑的时候,可以先把架子搭出来,然后想想,自己一共是需要换6跟线的,在换的时候注意下当时换的那个节点是否正确即可。

 

使用场景

那么这种算法的适用场景又是哪些呢?这种其实比较适合访问热点数据的淘汰或者统计等。当然,计数统计的算法能用到的地方简直不要太多,甚至JVM的热点代码也是用到计数的。

缺点

缺点其实也有,就是如果该数据一开始很热门,访问量特别多,可是经过一段时间之后它已经是个过期的访问量为0的数据了,但按照我们的逻辑,可能它的计数一直都很高所以不会被淘汰。

 

典型的就比如视频播放,视频这种基本都是开始播放时热度很高,计数会很大,可是大家看完之后不会二刷了,如果使用LFU,而没有做其他措施的话,就可能导致某个点击率很高,大家都看过的视频一直没有被淘汰。

 

解决方案也有,加个衰减的策略,半数衰减或者指数衰减。具体逻辑,就是定期将各自节点的访问数量二进制右移一位或者十进制右移一位,当然实际落地还是得考虑一些其他问题的,比如这种时候统计就不正确,那是否得新加一个域用于计数等。

总结

今天依然没有考虑线程安全问题,感兴趣的小伙伴可以自行实现。今天的逻辑讲解部分其实也是有点潦草,没有画出每次节点交换时的图,对于理解会稍稍有点困难。

 

不过如果这样也给了大家一个思考的空间,希望小伙伴们能够边看逻辑边画图,这样才能记忆深刻些,而且理解了上一篇的LRU的话,对LFU应该也是手到擒来才对。

今天的分享到这里就结束了。咱们,下期间~

posted @   aischen  阅读(103)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
点击右上角即可分享
微信分享提示