LRU与LFU缓存算法

一、背景

缓存算法也是也是我们日常使用的操作系统、应用程序内部用得比较多的一种调度算法,之前也是了解个过程没具体实现过,刚好LintCode上面刷题看到这两个算法,所以写这篇博客来整理一下LRUCache算法和LFUCache算法的过程和实现。

二、LRUCache算法

1.简单介绍

LRU(Least Recently Used)即最少最近使用算法,这个算法就是把每次都把最近访问或者添加的数据放到一端,当这个存放数据的容器满时,就从另一端开始移除元素,被移除的元素一定是近期没有使用或者较少使用的,其实核心也就是这些概念,可以看出,每次操作容器中的元素时都会有元素的位置改变这样一个额外的操作。

2.具体实现

因为整个容器是有一定的顺序的,要满足这个算法的规则,并且我们也需要快速定位来访问到元素,所以这里的实现的话使用哈希表加双链表的方式来实现,其实Java的标准库就刚好有这样一种数据结构,也就是LinkedHashMap,不过为了更清晰的了解解这个过程,还是自己去做具体的流程控制比较好。所以这里使用HashMap加上自己写的一个辅助类Node来实现。如下

    // 双向链表
    private class Node {
        Node prev;
        Node next;
        int key;
        int value;
        public Node(int key, int value) {
            this.key = key;
            this.value = value;
            prev = next = null;
        }
    }

这个Node类很直观了,用来表示容器中的每一个元素,构造方法传入的就是键值对,然后里面还存储了两个分别指向前一个节点和后一个节点的指针,然后我们还使用一个虚拟的头节点和尾节点方便我们进行访问。实现的话就是实现get()和set()两个方法了,有几个注意点,当get()一个元素,除了获取这个元素对应的value,还需要将这个元素从当前位置移动到整个容器的尾部;而进行set()操作时如果元素存在就更新这个元素的value,然后需要判断当前容器有没有满,满的话就从头节点移除一个元素,然后把新添加的元素移动到底部。这里的实现就是容器底部保留的是最近使用的元素,而顶部就存储的使用较少或没有使用过的元素,可以看到每次操作元素都需要进行一个移动到底部的操作。对应下面的代码

private void moveToTail(Node cur) {
        tail.prev.next = cur;
        cur.prev = tail.prev;
        cur.next = tail;
        tail.prev = cur;
    }

了解链表的操作最好的方式就是去画一下图,这里也就是把传进来的节点放到尾节点的前面,前面说过头节点和尾节点都是虚拟的,所以容器里面真实存储的元素实际上是在第二个节点到倒数第二个节点的这一段,因为是双链表,所以next和prev指针都要更新一下。整个过程也没有太多的操作,思路还是比较简单的,就是链表的维护看起来没那么直观,理解不清楚画一下就出来了,然后看看全部的代码,对应LintCode上面第134号题,重要的地方都有注释


    // 双向链表
    private class Node {
        Node prev;
        Node next;
        int key;
        int value;
        public Node(int key, int value) {
            this.key = key;
            this.value = value;
            prev = next = null;
        }
    }
    private int capacity;
    private Map<Integer, Node> map;
    // 虚拟头节点
    private Node head;
    // 虚拟尾节点
    private Node tail;

    public LRUCache(int capacity) {
        // do intialization if necessary
        this.capacity = capacity;
        this.map = new HashMap<>();
        head = new Node(-1, -1);
        tail = new Node(-1, -1);
        head.next = tail;
        tail.prev = head;
    }
    public int get(int key) {
        // write your code here
        if (map.containsKey(key)) {
            Node cur = map.get(key);
            // 查询到该节点后后取出该节点 移到底部
            cur.prev.next = cur.next;
            cur.next.prev = cur.prev;
            cur.prev = null;
            cur.next = null;
            moveToTail(cur);
            return map.get(key).value;
        } else {
            return -1;
        }
    }
    public void set(int key, int value) {
        // write your code here
        if (get(key) != -1) {
            map.get(key).value = value;
            return;
        }
        // 如果缓存容器满了就从顶部移除 (最近使用的都移动到了底部)
        // 因为头部和尾部都是虚拟节点 所以真实的元素是从head.next开始
        if (map.size() == capacity) {
            map.remove(head.next.key);
            head.next = head.next.next;
            head.next.prev = head;
        }
        Node newNode = new Node(key, value);
        map.put(key, newNode);
        moveToTail(newNode);
    }
    // 将元素移动到底部
    private void moveToTail(Node cur) {
        tail.prev.next = cur;
        cur.prev = tail.prev;
        cur.next = tail;
        tail.prev = cur;
    }
}

三、LFUCache缓存算法

1.简单介绍

LFU(Least Frequently Used)即最近不经常使用算法。这个算法就是核心是记录每一个元素的访问频率,当容器满了的时候,每次移出的都是访问频率较小的元素,如果两个元素的频率相同的话,就先移除添加容器较早的元素。

2.具体实现

对应LintCode上面24号题,这里给出两种实现的方式,第一种是直接用一个Node类保留要查找的键对应的value值、元素访问的次数
和上次访问的时间,感觉没有太多说的,重点就是记录访问频率,直接上代码

public class LFUCache1 {

    private class Node {
        int value;
        // 使用的次数
        int useCount;
        // 上次访问的时间
        long lastGetTime;
        public Node(int value, int useCount) {
            this.value = value;
            this.useCount = useCount;
        }
    }

    private Map<Integer, Node> cache;
    private int capacity;

    public LFUCache1(int capacity) {
        // do intialization if necessary
        this.capacity = capacity;
        this.cache = new HashMap<>();
    }

    public void set(int key, int value) {
        // write your code here
        if (get(key) != -1) {
            cache.get(key).value = value;
            return;
        }
        if (capacity == 0) return;
        // 容器满时移除访问频率最小的元素
        if (cache.size()>=capacity) {
            removeMin();
        }
        Node node = new Node(value, 0);
        node.lastGetTime = System.nanoTime();
        cache.put(key, node);
    }

    public int get(int key) {
        // write your code here
        if (!cache.containsKey(key)) {
            return -1;
        }
        cache.get(key).useCount++;
        cache.get(key).lastGetTime = System.nanoTime();
        return cache.get(key).value;
    }

    // 移除使用频率最小的元素
    private void removeMin() {
        int minCount = Integer.MAX_VALUE;
        long currTime = System.nanoTime();
        int minKey = 0;
        Iterator<Integer> iterator = cache.keySet().iterator();
        while (iterator.hasNext()) {
            int key = iterator.next();
            Node node = cache.get(key);
            if (node.useCount<minCount || (node.useCount==minCount && node.lastGetTime<currTime)) {
                minKey = key;
                minCount = node.useCount;
                currTime = node.lastGetTime;
            }
        }
        cache.remove(minKey);
    }

    public static void main(String[] args) {
        LFUCache1 lfuCache = new LFUCache1(3);
        lfuCache.set(1, 10);
        lfuCache.set(2, 20);
        lfuCache.set(3, 30);
        System.out.print("["+lfuCache.get(1)+", ");
        lfuCache.set(4, 40);
        System.out.print(lfuCache.get(4)+", ");
        System.out.print(lfuCache.get(3)+", ");
        System.out.print(lfuCache.get(2)+", ");
        System.out.print(lfuCache.get(1)+", ");
        lfuCache.set(5, 50);
        System.out.print(lfuCache.get(1)+", ");
        System.out.print(lfuCache.get(2)+", ");
        System.out.print(lfuCache.get(3)+", ");
        System.out.print(lfuCache.get(4)+", ");
        System.out.print(lfuCache.get(5)+"]");
        System.out.println();
        System.out.println(lfuCache.cache.size());
    }
}

这里可以跑一下,出来的结果是符合预期的,这种方式是将节点相关的信息保存在节点内部,并且就使用了HashMap来储存,缺点就是每次移除元素时需要扫一下整个容器,这个操作需要消耗的时间是不太能接受的,当数据量大,操作频繁时,就凸显出来了,不过放到LintCode上面跑也能通过。下面来看到一种比较高效的方式吧。


第二种方法也是把元素的访问次数封装到Node内部,但是使用一个额外的Map来记录节点的访问次数,访问次数作为键,LinkedHashSet作为值,LinkedHashSet里面记录的是元素对应的key,每个元素肯定是唯一的,并且LinkedHashSet是按照插入顺序来进行排列的,所以每次访问到某个元素时,先更新元素Node对应的频率后,还需要更新频率对应的LinkedHashSet,然后使用一个全局变量记录最小的访问频率min,当容器满的时候,就根据这个最小访问频率去获取LinkedHashSet里面对应的第一个键key,因为访问频率相同时,优先移除较早添加的元素,获得这个key后就可以直接去保存Node的容器里面去进行删除基本就是这么一个过程,这样在容器里面删除元素时不需要扫描整个容器,只需要O(1)的复杂度就可以完成这个操作,具体的实现还有一点需要注意的,就是最小访问频率min为更新之前的次数,并且当对应频率的LinkedHashSet为空时,才需更新值为当前的次数,此外每次添加节点时也需要把这个值置为0,因为新节点访问次数默认是0。
重要的地方都说清楚了,然后看看全部的代码

public class LFUCache {
    
    private class Node {
        int value;
        int count;
        public Node(int value, int count) {
            this.value = value;
            this.count = count;
        }
    }

    private int capacity;
    private Map<Integer, Node> cache;
    private Map<Integer, LinkedHashSet<Integer>> freqList;
    private int min;

    public LFUCache(int capacity) {
        // do intialization if necessary
        this.capacity = capacity;
        this.cache = new HashMap<>();
        this.freqList = new HashMap<>();
        freqList.put(0, new LinkedHashSet<>());
        min = -1;
    }

    public void set(int key, int value) {
        // write your code here
        if (get(key) != -1) {
            Node node = cache.get(key);
            node.value = value;
            return;
        }
        // 当容器缓存满时 这段时间使用频率最小的key
        if (cache.size() == capacity) {
            Integer evict = freqList.get(min).iterator().next();
            cache.remove(evict);
            freqList.get(min).remove(evict);
        } 
        // 添加新节点时更新min
        min = 0;
        Node newNode = new Node(value, 0);
        cache.put(key, newNode);
        freqList.get(0).add(key);
    }

    public int get(int key) {
        // write your code here
        if (capacity == 0) return -1;
        if (cache.containsKey(key)) {
            Node node = cache.get(key);
            node.count++;
            // 更新节点对应的频率表
            freqList.get(node.count-1).remove(key);
            if (!freqList.containsKey(node.count)) {
                freqList.put(node.count, new LinkedHashSet<>());
            }
            freqList.get(node.count).add(key);
            // 如果之前对应的频率表为空 就更新最小频率为当前的key
            if (min==node.count-1 && freqList.get(min).isEmpty()) {
                min = node.count;
            }
            return node.value;
        } else {
            return -1;
        }
    }
    public static void main(String[] args) {
          LFUCache lfuCache = new LFUCache(3);
          lfuCache.set(1, 10);
          lfuCache.set(2, 20);
          lfuCache.set(3, 30);
          System.out.print("["+lfuCache.get(1)+", ");
          lfuCache.set(4, 40);
          System.out.print(lfuCache.get(4)+", ");
          System.out.print(lfuCache.get(3)+", ");
          System.out.print(lfuCache.get(2)+", ");
          System.out.print(lfuCache.get(1)+", ");
          lfuCache.set(5, 50);
          System.out.print(lfuCache.get(1)+", ");
          System.out.print(lfuCache.get(2)+", ");
          System.out.print(lfuCache.get(3)+", ");
          System.out.print(lfuCache.get(4)+", ");
          System.out.print(lfuCache.get(5)+"]");
          System.out.println();
          System.out.println(lfuCache.cache.size());
    }
}

可以跑一下,也是一样的结果,比起第一种方法有更高的速度,虽然维护了多的结构,但是影响不是很大,我们对时间的需求一般都是要大的多,所以大多数情况下我们总是愿意以空间换时间。

四、总结

LRUCacahe和LFUCache都是比较经典的缓存算法了。LRUCache完全通过最后一次的访问来进行整体排序,所以同一时间访问较多的元素可能会被后面新来的的元素淘汰;LFUCache则是通过访问次数来进行,所以可能比较大一段时间内访问次数较多的元素会被短时间内访问更多的元素所淘汰。因为有着不同的特性,所以很多时候都是把两种缓存算法混合来使用。这篇博客就先到这里了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容

  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 42,014评论 11 349
  •   DOM(文档对象模型)是针对 HTML 和 XML 文档的一个 API(应用程序编程接口)。   DOM 描绘...
    霜天晓阅读 3,563评论 0 7
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,034评论 1 32
  • 年初,我去了福州,写了福州茶亭桥和往事,时隔8年再去,以为会在那待下来,怀念和重新开始,找了DTP工作,但是仅仅两...
    糖糖poppy阅读 348评论 0 1
  • 吃饭上我不太挑,只要是清淡的我都喜欢。但有一样是一周必吃一次的,就是豆制品。 所有黄豆为原材料制作的...
    SukiQi苏琪阅读 177评论 0 3