LeetCode 460. LFU缓存

题目描述

实现一个LFU缓存(Least Frequently Used)。 在需要移除元素时,移除最近访问频率最低的。可以对每个元素增加一个计数器,访问一次就计数加一。若2个或多个元素拥有相同的最少访问次数时,则移除最久没有被访问的。

与实现LRU缓存类似,为了确保get操作的复杂度为 O ( 1 ) O(1) O(1),我们都会用一个Map来存储所有key-value, 关键在于put时需要移除元素的情况,要如何操作。

思路

先不考虑多个元素具有相同的访问次数,且次数都是最少的情况。先单独考虑,如果只需要移除访问次数最少的元素。这种每次都需要获取一个最值的情况。容易想到用来做。由于我们每次移除元素时,需要移除访问次数最少的,则根据访问次数,建一个小根堆,堆顶元素是最小值。

那么,用一个Map加上一个小根堆,就能满足需求。

接下来考虑,如果访问次数最少的元素有多个,需要移除最久没有被访问的。那么对于每个元素,我们需要记录一个访问时间,然后在堆中排序时,不再只根据访问次数来排序,而是根据【访问次数,访问时间】,进行双关键字排序。这样就能保证在访问次数相同时,访问时间更早的元素,会排在更前面。则堆顶的元素就是访问次数最少,且访问时间最早。

实现

其中,堆用数组实现。

访问时间用一个int变量在每次进行getput时进行累加,来模拟时间戳。

class LFUCache {private Node[] heap;private Map<Integer, Node> map;private int capacity; // 最大容量private int size;  // 当前大小private int time; // 模拟时间戳public LFUCache(int capacity) {
        heap = new Node[capacity + 1]; // 堆下标从1开始, 方便计算父子节点的下标
        map = new HashMap<>(capacity);
        this.capacity = capacity;
        this.size = 0;
        this.time = 0; // 模拟时间戳
    }
    
    public int get(int key) {
        if (map.containsKey(key)) {
            Node x = map.get(key);
            x.cnt++; // 访问次数+1
            x.time = ++time; // 访问时间更新为当前时间戳
            down(x.index); // 当前节点排序只可能变大, 只需要向下调整即可
            return x.val;
        }
        return -1;
    }
    
    public void put(int key, int value) {
        if (capacity <= 0) return;
        if (map.containsKey(key)) {
            Node x = map.get(key);
            x.cnt++;
            x.time = ++time;
            x.val = value;
            down(x.index);
            return;
        }
        if (size == capacity) {
            map.remove(heap[1].key); // 移除堆顶
            swap(1, size--); // 交换堆顶和堆尾, 堆大小减1
            down(1); // 向下调整堆顶
        }
        Node x = new Node(key, value, ++size);
        x.time = ++time;
        x.cnt = 1;
        map.put(key, x);
        heap[size] = x; // 插入堆尾
        up(size); // 向上调整
    }private void down(int i) {
        int min = i;
        if (2 * i <= size && compare(2 * i, min) < 0) min = 2 * i;
        if (2 * i + 1 <= size && compare(2 * i + 1, min) < 0) min = 2 * i + 1;
        if (min != i) {
            swap(i, min);
            down(min);
        }
    }private void up(int i) {
        while (i / 2 >= 1 && compare(i / 2, i) > 0) {
            swap(i, i / 2);
            i /= 2;
        }
    }// 交换堆中2个元素, 记得重设节点在数组中的下标信息
    private void swap(int i, int j) {
        Node t = heap[i];
        heap[i] = heap[j];
        heap[j] = t;
        heap[i].index = i;
        heap[j].index = j;
    }// 按访问次数和访问时间戳, 双关键字排序
    private int compare(int i, int j) {
        Node ni = heap[i], nj = heap[j];
        if (ni.cnt != nj.cnt) return ni.cnt - nj.cnt;
        return ni.time - nj.time;
    }class Node {
        private int key;
        private int val;
        private int cnt; // 访问次数
        private int time; // 最近访问的时间戳
        private int index; // 这个node在堆中的下标public Node(int key, int val, int index) {
            this.key = key;
            this.val = val;
            this.index = index;
            this.cnt = 0;
        }
    }
}

解法二

其实上面这样使用Map加小根堆的实现,getput操作的时间复杂度并不是 O ( 1 ) O(1) O(1),因为每次getput,都需要调整堆。所以getput的时间复杂度都是 O ( l o g n ) O(log n) O(logn)的。

当然,小根堆的也实现可以借助jdk中的TreeSet或者PriorityQueue

另外还有一种,双Map的解法,能做到时间复杂度 O ( 1 ) O(1) O(1)。待后续补充 -> 2022/05/25更新,已补充:

用一个Map来存真实数据,另一个Map以出现频次freqkey,维护一个双向链表,双向链表中全部都是频次相同的Node,且链表的插入顺序就是其访问时间的早晚。若每次插入采用尾插法,则链表头就是访问时间最早的,再移除元素时,移除链表头即可。
还需要另外维护一个变量minFreq,表示当前最小的频次,以方便做删除。在元素满了后,需要做删除时,删除完毕后无需更新minFreq,因为后面肯定会有一个新的元素被插进来,minFreq一定会被更新为1
若某个已存在元素被重复访问,则将其频次+1,并从旧的频次双向链表中移除,添加到新的频次的双向链表中,这个过程可能发生minFreq的更新。

class LFUCache {

    Map<Integer, Node> map;

    Map<Integer, DoubleList> freqMap;

    int minFreq;

    int capacity;

    int size;

    public LFUCache(int capacity) {
        this.capacity = capacity;
        this.size = 0;
        this.map = new HashMap<>();
        this.freqMap = new HashMap<>();
        this.minFreq = 0;
    }
    
    public int get(int key) {
        if (!map.containsKey(key)) return -1;
        Node node = map.get(key);
        freqIncr(node); // 这个节点的访问频率 + 1
        return node.value;
    }
    
    public void put(int key, int value) {
        if (map.containsKey(key)) {
            Node node = map.get(key);
            node.value = value;
            freqIncr(node);
            return ;
        }

        if (size == capacity) removeStaleNode();

        if (size < capacity) {
            Node node = new Node(key, value);
            node.freq = 1;
            DoubleList list = freqMap.get(1);
            if (list == null) {
                list = new DoubleList();
                freqMap.put(1, list);
            }
            list.add(node);
            minFreq = 1;
            map.put(key, node);
            size++;
        }
    }

    private void removeStaleNode() {
        DoubleList list = freqMap.get(minFreq);   
        if (list == null || list.size == 0) return ;
        // 从map中移除头节点
        map.remove(list.head.next.key);
        list.removeStale();
        size--;
    }

    private void freqIncr(Node node) {
        int oldFreq = node.freq;
        node.freq++;
        DoubleList oldList = freqMap.get(oldFreq);
        oldList.remove(node); //从旧的频率的链表中移除
        if (minFreq == oldFreq && oldList.size == 0) minFreq++; // 更新minFreq

        DoubleList newList = freqMap.get(oldFreq + 1);
        if (newList == null) {
            newList = new DoubleList();
            freqMap.put(oldFreq + 1, newList);
        }
        newList.add(node);
    }

    class Node {
        
        private int key;

        private int value;

        private int freq;

        private Node next;

        private Node prev;

        Node (int key, int value) {
            this.key = key;
            this.value = value;
        }
    }

    class DoubleList {
        
        // 2个虚拟节点
        private Node head;

        private Node tail;

        private int size;

        DoubleList() {
            head = new Node(0, 0);
            tail = new Node(0, 0);
            head.next = tail;
            tail.prev = head;
            size = 0;
        }

		// 尾插法
        void add(Node node) {
            Node trueTail = tail.prev;
            trueTail.next = node;
            node.prev = trueTail;
            node.next = tail;
            tail.prev = node;
            size++;
        }

        void remove(Node node) {
            node.prev.next = node.next;
            node.next.prev = node.prev;
            node.next = node.prev = null;
            size--;
        }

        // 头节点是最早插入的
        void removeStale() {
            if (size == 0) return ;
            Node trueHead = head.next;
            head.next = trueHead.next;
            trueHead.next.prev = head;
            trueHead.next = trueHead.prev = null;
            size--;
        }
    }
}

扩展

:LFU/LRU来自于OS的页面置换算法,下面对OS的页面置换算法进行一个说明

FIFO:先进先出。会产生Belady现象(随着页面数量增大,缺页率反而上升的现象)。现已很少使用。参考cnblog这篇文章这篇论文

LRU和LFU,都能够保证,随着可分配页数的增加,能够保证页面更少时的集合是页面更大时的子集,这样就能保证,增大页面数量,缺页率一定不会上升(只可能下降),这样的算法称为stack algorithm

If the pages in the frames of a memory are also in the frames of a larger memory, the algorithm is said to be a stack algorithm. Because a stack algorithm by definition prevents the discrepancy above, no stack algorithm can suffer from Belady’s anomaly

至于LRU,是根据最近最久未被使用的,进行置换,强调的是访问时间的早晚。

LFU,则是根据访问频率,移除访问频率最低的,强调的是过去一段时间内的访问次数的多少。

LRU和LFU的对比:

LRU的问题在于:对于偶发性,周期性的批量查询(冷数据),会淘汰掉大量热点数据,导致命中率急剧下降

LFU的问题在于:最近新加入的数据(由于访问次数很少)容易被淘汰(缓存末端抖动),无法对最初拥有高访问频率之后长时间未访问的数据负责。

对于LRU和LFU的对比,参考思否的这篇文章

LFU在OS中的实现,实际没有用次数累加的方式,而是采用移位+定期衰减的方式。参考百度百科

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值