LFU的多種實現方式,從簡單到複雜,新手必看

雖然,力扣要求是用時間複雜度 O(1) 來解,但是其它方式我感覺也有必要了解,畢竟是一個由淺到深的過程,自己實現一遍總歸是好的。因此,我就把五種求解方式,從簡單到複雜,都講一遍。

LFU實現

力扣原題描述如下:

請你爲 最不經常使用(LFU)緩存算法設計並實現數據結構。它應該支持以下操作:get 和 put。

get(key) - 如果鍵存在於緩存中,則獲取鍵的值(總是正數),否則返回 -1。
put(key, value) - 如果鍵不存在,請設置或插入值。當緩存達到其容量時,則應該在插入新項之前,使最不經常使用的項無效。在此問題中,當存在平局(即兩個或更多個鍵具有相同使用頻率)時,應該去除 最近 最少使用的鍵。
「項的使用次數」就是自插入該項以來對其調用 get 和 put 函數的次數之和。使用次數會在對應項被移除後置爲 0 。

示例:

LFUCache cache = new LFUCache( 2 /* capacity (緩存容量) */ );

cache.put(1, 1);
cache.put(2, 2);
cache.get(1);       // 返回 1
cache.put(3, 3);    // 去除 key 2
cache.get(2);       // 返回 -1 (未找到key 2)
cache.get(3);       // 返回 3
cache.put(4, 4);    // 去除 key 1
cache.get(1);       // 返回 -1 (未找到 key 1)
cache.get(3);       // 返回 3
cache.get(4);       // 返回 4

來源:力扣(LeetCode)
鏈接:https://leetcode-cn.com/problems/lfu-cache

就是要求我們設計一個 LFU 算法,根據訪問次數(訪問頻次)大小來判斷應該刪除哪個元素,get和put操作都會增加訪問頻次。當訪問頻次相等時,就判斷哪個元素是最久未使用過的,把它刪除。

因此,這道題需要考慮兩個方面,一個是訪問頻次,一個是訪問時間的先後順序。

方案一:使用優先隊列

思路:

我們可以使用JDK提供的優先隊列 PriorityQueue 來實現 。 因爲優先隊列內部維護了一個二叉堆,即可以保證每次 poll 元素的時候,都可以根據我們的要求,取出當前所有元素的最大值或是最小值。只需要我們的實體類實現 Comparable 接口就可以了。

當 cache 容量不足時,根據訪問頻次 freq 的大小來刪除最小的 freq 。若相等,則刪除 index 最小的,因爲index是自增的,越大說明越是最近訪問過的,越小說明越是很長時間沒訪問過的元素。

因本質是用二叉堆實現,故時間複雜度爲O(logn)。

public class LFUCache4 {

    public static void main(String[] args) {
        LFUCache4 cache = new LFUCache4(2);
        cache.put(1, 1);
        cache.put(2, 2);
        // 返回 1
        System.out.println(cache.get(1));
        cache.put(3, 3);    // 去除 key 2
        // 返回 -1 (未找到key 2)
        System.out.println(cache.get(2));
        // 返回 3
        System.out.println(cache.get(3));
        cache.put(4, 4);    // 去除 key 1
        // 返回 -1 (未找到 key 1)
        System.out.println(cache.get(1));
        // 返回 3
        System.out.println(cache.get(3));
        // 返回 4
        System.out.println(cache.get(4));
    }

    //緩存了所有元素的node
    Map<Integer,Node> cache;
    //優先隊列
    Queue<Node> queue;
    //緩存cache 的容量
    int capacity;
    //當前緩存的元素個數
    int size;
    //全局自增
    int index = 0;

    //初始化
    public LFUCache4(int capacity){
        this.capacity = capacity;
        if(capacity > 0){
            queue = new PriorityQueue<>(capacity);
        }
        cache = new HashMap<>();
    }

    public int get(int key){
        Node node = cache.get(key);
        // node不存在,則返回 -1
        if(node == null) return -1;
        //每訪問一次,頻次和全局index都自增 1
        node.freq++;
        node.index = index++;
        // 每次都重新remove,再offer是爲了讓優先隊列能夠對當前Node重排序
        //不然的話,比較的 freq 和 index 就是不準確的
        queue.remove(node);
        queue.offer(node);
        return node.value;
    }

    public void put(int key, int value){
        //容量0,則直接返回
        if(capacity == 0) return;
        Node node = cache.get(key);
        //如果node存在,則更新它的value值
        if(node != null){
            node.value = value;
            node.freq++;
            node.index = index++;
            queue.remove(node);
            queue.offer(node);
        }else {
            //如果cache滿了,則從優先隊列中取出一個元素,這個元素一定是頻次最小,最久未訪問過的元素
            if(size == capacity){
                cache.remove(queue.poll().key);
                //取出元素後,size減 1
                size--;
            }
            //否則,說明可以添加元素,於是創建一個新的node,添加到優先隊列中
            Node newNode = new Node(key, value, index++);
            queue.offer(newNode);
            cache.put(key,newNode);
            //同時,size加 1
            size++;
        }
    }


    //必須實現 Comparable 接口才可用於排序
    private class Node implements Comparable<Node>{
        int key;
        int value;
        int freq = 1;
        int index;

        public Node(){

        }

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

        @Override
        public int compareTo(Node o) {
            //優先比較頻次 freq,頻次相同再比較index
            int minus = this.freq - o.freq;
            return minus == 0? this.index - o.index : minus;
        }
    }
}

方案二:使用一條雙向鏈表

思路:

只用一條雙向鏈表,來維護頻次和時間先後順序。那麼,可以這樣想。把頻次 freq 小的放前面,頻次大的放後面。如果頻次相等,就從當前節點往後遍歷,直到找到第一個頻次比它大的元素,並插入到它前面。(當然,如果遍歷到了tail,則插入到tail前面)這樣可以保證同頻次的元素,最近訪問的總是在最後邊。

PS:哨兵節點只是爲了佔位,實際並不存儲有效數據,只是爲了鏈表插入和刪除時,不用再判斷當前節點的位置。不然的話,若當前節點佔據了頭結點或尾結點的位置,還需要重新賦值頭尾節點元素,較麻煩。

爲了便於理解新節點如何插入到鏈表中合適的位置,作圖如下:

代碼如下:

public class LFUCache {

    public static void main(String[] args) {
        LFUCache cache = new LFUCache(2);
        cache.put(1, 1);
        cache.put(2, 2);
        // 返回 1
        System.out.println(cache.get(1));
        cache.put(3, 3);    // 去除 key 2
        // 返回 -1 (未找到key 2)
        System.out.println(cache.get(2));
        // 返回 3
        System.out.println(cache.get(3));
        cache.put(4, 4);    // 去除 key 1
        // 返回 -1 (未找到 key 1)
        System.out.println(cache.get(1));
        // 返回 3
        System.out.println(cache.get(3));
        // 返回 4
        System.out.println(cache.get(4));

    }

    private Map<Integer,Node> cache;
    private Node head;
    private Node tail;
    private int capacity;
    private int size;

    public LFUCache(int capacity) {
        this.capacity = capacity;
        this.cache = new HashMap<>();
        /**
         * 初始化頭結點和尾結點,並作爲哨兵節點
         */
        head = new Node();
        tail = new Node();
        head.next = tail;
        tail.pre = head;
    }

    public int get(int key) {
        Node node = cache.get(key);
        if(node == null) return -1;
        node.freq++;
        moveToPostion(node);
        return node.value;
    }

    public void put(int key, int value) {
        if(capacity == 0) return;
        Node node = cache.get(key);
        if(node != null){
            node.value = value;
            node.freq++;
            moveToPostion(node);
        }else{
            //如果元素滿了
            if(size == capacity){
                //直接移除最前面的元素,因爲這個節點就是頻次最小,且最久未訪問的節點
                cache.remove(head.next.key);
                removeNode(head.next);
                size--;
            }
            Node newNode = new Node(key, value);
            //把新元素添加進來
            addNode(newNode);
            cache.put(key,newNode);
            size++;
        }
    }

    //只要當前 node 的頻次大於等於它後邊的節點,就一直向後找,
    // 直到找到第一個比當前node頻次大的節點,或者tail節點,然後插入到它前面
    private void moveToPostion(Node node){
        Node nextNode = node.next;
        //先把當前元素刪除
        removeNode(node);
        //遍歷到符合要求的節點
        while (node.freq >= nextNode.freq && nextNode != tail){
            nextNode = nextNode.next;
        }
        //把當前元素插入到nextNode前面
        node.pre = nextNode.pre;
        node.next = nextNode;
        nextNode.pre.next = node;
        nextNode.pre = node;

    }

    //添加元素(頭插法),並移動到合適的位置
    private void addNode(Node node){
        node.pre = head;
        node.next = head.next;
        head.next.pre = node;
        head.next = node;
        moveToPostion(node);
    }

    //移除元素
    private void removeNode(Node node){
        node.pre.next = node.next;
        node.next.pre = node.pre;
    }

    class Node {
        int key;
        int value;
        int freq = 1;
        //當前節點的前一個節點
        Node pre;
        //當前節點的後一個節點
        Node next;

        public Node(){

        }

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

可以看到不管是插入元素還是刪除元素時,都不需要額外的判斷,這就是設置哨兵節點的好處。

由於每次訪問元素的時候,都需要按一定的規則把元素放置到合適的位置,因此,元素需要從前往後一直遍歷。所以,時間複雜度 O(n)。

 

更多幹貨 請點擊查看

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章