内卷大厂系列《LFU 缓存淘汰策略》

758 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情

大厂高频算法面试题:《LFU 缓存淘汰算法》,您将学到 LFU 是什么?解决什么问题?现在的大厂必问的高频算法面试题,不要求能现场手撸出来,可以说明下 LFU 实现的具体思路和用到的数据结构模型。

一、LFU 是什么

LFU (least frequently used),即最少频率使用策略,在内存不够时,淘汰掉使用频率低的数据。

二、LFU 功能

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

实现 LFUCache 类:

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

为了确定最不常使用的键,可以为缓存中的每个键维护一个 使用计数器 。使用计数最小的键是最久未使用的键。

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

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

示例

输入:
["LFUCache", "put", "put", "get", "put", "get", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [3], [4, 4], [1], [3], [4]]
输出:
[null, null, null, 1, null, -1, 3, null, -1, 3, 4]

解释:
// cnt(x) = 键 x 的使用计数
// cache=[] 将显示最后一次使用的顺序(最左边的元素是最近的)
LFUCache lfu = new LFUCache(2);
lfu.put(1, 1);   // cache=[1,_], cnt(1)=1
lfu.put(2, 2);   // cache=[2,1], cnt(2)=1, cnt(1)=1
lfu.get(1);      // 返回 1
                 // cache=[1,2], cnt(2)=1, cnt(1)=2
lfu.put(3, 3);   // 去除键 2 ,因为 cnt(2)=1 ,使用计数最小
                 // cache=[3,1], cnt(3)=1, cnt(1)=2
lfu.get(2);      // 返回 -1(未找到)
lfu.get(3);      // 返回 3
                 // cache=[3,1], cnt(3)=2, cnt(1)=2
lfu.put(4, 4);   // 去除键 1 ,1 和 3 的 cnt 相同,但 1 最久未使用
                 // cache=[4,3], cnt(4)=1, cnt(3)=2
lfu.get(1);      // 返回 -1(未找到)
lfu.get(3);      // 返回 3
                 // cache=[3,4], cnt(4)=1, cnt(3)=3
lfu.get(4);      // 返回 4
                 // cache=[3,4], cnt(4)=2, cnt(3)=3

leetcode

三、LFU 实现

LFU 难度不在于算法上的设计,在于算法上的coding

在内存满时,淘汰掉频度低的数据,如果频度相同,则淘汰掉最早的数据(一直未被使用)

LFU 内存淘汰策略:

  • 频度不一样,淘汰掉最低频度的数据
  • 频度一样,淘汰掉最早的数据

二维双向链表

最左侧的桶次数是最少的

最右侧的桶次数是最多的

每个桶中存储的都是次数相同的元素

空桶要删除,避免内存泄漏,比如某个元素操作了100万次,如果不删除空桶,那会出现99万个空桶

为什么要用二维桶?就是为了删除记录时,要删谁,删除最左侧桶的第一条数据

至于怎么删除第一条数据,看具体链表实现,比如删除链表的头部数据(桶内频次相同,删除最早数据),那就要求添加数据从链表尾部添加,如果删除链表的尾部数据(桶内频次相同,删除最早数据),那就要求添加数据从链表头部添加

public class LFUCache {

    MyLFUCache<Integer, Integer> myLFUCache;

    public LFUCache(int capacity) {
        this.myLFUCache = new MyLFUCache<>(capacity);
    }
    
    public int get(int key) {
        Integer result = myLFUCache.get(key);
        return result == null ? -1 : result;
    }
    
    public void put(int key, int value) {
        myLFUCache.put(key, value);
    }

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

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

    // 桶结构
    public static class NodeList<K, V> { // 支持泛型
        public Node<K, V> head; // 桶的头节点
        public Node<K, V> tail; // 桶的尾节点
        public NodeList<K, V> pre; // 桶之间是双向链表所以有前一个桶
        public NodeList<K, V> next; // 桶之间是双向链表所以有后一个桶

        public NodeList(Node<K, V> node) {
            this.head = node;
            this.tail = node;
        }

        // 把一个新的节点加入这个桶,新的节点都放在顶端变成新的头部
        public void addNodeFromHead(Node<K, V> newHead) {
            newHead.down = head;
            head.up = newHead;
            head = newHead;
        }

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

        // 删除node节点并保证node的上下环境重新连接
        public void deleteNode(Node<K, V> 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;
        }
    }

    // 总的缓存结构
    public static class MyLFUCache<K, V> { // 支持泛型
        private int capacity; // 缓存的大小限制,即K
        private int size; // 缓存目前有多少个节点
        private HashMap<K, Node<K, V>> records;// 表示key(Integer)由哪个节点(Node)代表
        private HashMap<Node<K, V>, NodeList<K, V>> heads; // 表示节点(Node)在哪个桶(NodeList)里
        private NodeList<K, V> headList; // 整个结构中位于最左的桶

        public MyLFUCache(int K) {
            this.capacity = K;
            this.size = 0;
            this.records = new HashMap<>();
            this.heads = new HashMap<>();
            headList = null;
        }

        // removeNodeList:刚刚减少了一个节点的桶
        // 这个函数的功能是,判断刚刚减少了一个节点的桶是不是已经空了。
        // 1)如果不空,什么也不做
        //
        // 2)如果空了,removeNodeList还是整个缓存结构最左的桶(headList)。
        // 删掉这个桶的同时也要让最左的桶变成removeNodeList的下一个。
        //
        // 3)如果空了,removeNodeList不是整个缓存结构最左的桶(headList)。
        // 把这个桶删除,并保证上一个的桶和下一个桶之间还是双向链表的连接方式
        //
        // 函数的返回值表示刚刚减少了一个节点的桶是不是已经空了,空了返回true;不空返回false
        private boolean modifyHeadList(NodeList<K, V> removeNodeList) {
            if (removeNodeList.isEmpty()) {
                if (headList == removeNodeList) {
                    headList = removeNodeList.next;
                    if (headList != null) {
                        headList.pre = null;
                    }
                } else {
                    removeNodeList.pre.next = removeNodeList.next;
                    if (removeNodeList.next != null) {
                        removeNodeList.next.pre = removeNodeList.pre;
                    }
                }
                return true;
            }
            return false;
        }

        // 函数的功能
        // node这个节点的次数+1了,这个节点原来在oldNodeList里。
        // 把node从oldNodeList删掉,然后放到次数+1的桶中
        // 整个过程既要保证桶之间仍然是双向链表,也要保证节点之间仍然是双向链表
        private void move(Node<K, V> node, NodeList<K, V> oldNodeList) {
            oldNodeList.deleteNode(node);
            // preList表示次数+1的桶的前一个桶是谁
            // 如果oldNodeList删掉node之后还有节点,oldNodeList就是次数+1的桶的前一个桶
            // 如果oldNodeList删掉node之后空了,oldNodeList是需要删除的,所以次数+1的桶的前一个桶,是oldNodeList的前一个
            NodeList<K, V> preList = modifyHeadList(oldNodeList) ? oldNodeList.pre
                    : oldNodeList;
            // nextList表示次数+1的桶的后一个桶是谁
            NodeList<K, V> nextList = oldNodeList.next;
            if (nextList == null) {
                NodeList<K, V> newList = new NodeList(node);
                if (preList != null) {
                    preList.next = newList;
                }
                newList.pre = preList;
                if (headList == null) {
                    headList = newList;
                }
                heads.put(node, newList);
            } else {
                if (nextList.head.times.equals(node.times)) {
                    nextList.addNodeFromHead(node);
                    heads.put(node, nextList);
                } else {
                    NodeList<K, V> newList = new NodeList(node);
                    if (preList != null) {
                        preList.next = newList;
                    }
                    newList.pre = preList;
                    newList.next = nextList;
                    nextList.pre = newList;
                    if (headList == nextList) {
                        headList = newList;
                    }
                    heads.put(node, newList);
                }
            }
        }

        public void put(K key, V value) {
            if (capacity == 0) {
                return;
            }
            if (records.containsKey(key)) {
                Node<K, V> node = records.get(key);
                node.value = value;
                node.times++;
                NodeList<K, V> curNodeList = heads.get(node);
                move(node, curNodeList);
            } else {
                if (size == capacity) {
                    Node<K, V> node = headList.tail;
                    headList.deleteNode(node);
                    modifyHeadList(headList);
                    records.remove(node.key);
                    heads.remove(node);
                    size--;
                }
                Node<K, V> node = new Node(key, value, 1);
                if (headList == null) {
                    headList = new NodeList(node);
                } else {
                    if (headList.head.times.equals(node.times)) {
                        headList.addNodeFromHead(node);
                    } else {
                        NodeList newList = new NodeList(node);
                        newList.next = headList;
                        headList.pre = newList;
                        headList = newList;
                    }
                }
                records.put(key, node);
                heads.put(node, headList);
                size++;
            }
        }

        public V get(K key) {
            if (!records.containsKey(key)) {
                return null;
            }
            Node<K, V> node = records.get(key);
            node.times++;
            NodeList<K, V> curNodeList = heads.get(node);
            move(node, curNodeList);
            return node.value;
        }

    }
}