Hard | LeetCode 460. LFU 缓存 | 设计(HashMap+双向链表)(HashMap+TreeSet)

LeetCode 460. LFU 缓存

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

实现 LFUCache 类:

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

注意「项的使用次数」就是自插入该项以来对其调用 getput 函数的次数之和。使用次数会在对应项被移除后置为 0 。

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

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

示例:

输入:
["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 lFUCache = new LFUCache(2);
lFUCache.put(1, 1);   // cache=[1,_], cnt(1)=1
lFUCache.put(2, 2);   // cache=[2,1], cnt(2)=1, cnt(1)=1
lFUCache.get(1);      // 返回 1
                      // cache=[1,2], cnt(2)=1, cnt(1)=2
lFUCache.put(3, 3);   // 去除键 2 ,因为 cnt(2)=1 ,使用计数最小
                      // cache=[3,1], cnt(3)=1, cnt(1)=2
lFUCache.get(2);      // 返回 -1(未找到)
lFUCache.get(3);      // 返回 3
                      // cache=[3,1], cnt(3)=2, cnt(1)=2
lFUCache.put(4, 4);   // 去除键 1 ,1 和 3 的 cnt 相同,但 1 最久未使用
                      // cache=[4,3], cnt(4)=1, cnt(3)=2
lFUCache.get(1);      // 返回 -1(未找到)
lFUCache.get(3);      // 返回 3
                      // cache=[3,4], cnt(4)=1, cnt(3)=3
lFUCache.get(4);      // 返回 4
                      // cache=[3,4], cnt(4)=2, cnt(3)=3

提示:

  • 0 <= capacity, key, value <= 104
  • 最多调用 105getput 方法

解题思路

对比 Medium | LeetCode 146. LRU 缓存机制 | HashMap+双向链表 | LinkedHasp 有一些共同点。但是还有有很大的不同。

首先借鉴前题的思路, 为了保证get的复杂度是O(1), 这个时候需要使用一个HashMap去保存key和value的值。

这道题的关键是要动态的更新访问次数, 然后在缓存满需要替换时, 换掉访问次数最少的。所以对此问题展开了如下的分析。

思路一

为了找到访问次数最少的, 第一反应是使用优先队列的方式。因为优先队列可以在O(1)时间弹出访问最少的节点。

但是存在以下两个问题:

1、优先队列可以找到访问最少的节点, 但是弹出过后, 还有做一个堆调整的过程。这个复杂度是O(logn)的

2、Put一个新key的时候, 访问次数是1, 添加到优先队列时, 需要从堆底逐渐将这个节点调整到堆顶, 这个复杂度是O(logn)

3、当访问某个非堆顶节点, 需要修改这个节点的访问次数, 然后重新调整它在优先队列的位置时, 是没有操作办法的。优先队列既不会自动调整, 我们也没有办法将这个点删除掉再插入调整。

上面第3个问题是致命的, 不仅仅是复杂度不达标, 更是最基本的实现都无法满足。

所以此题不能使用优先队列, 应当使用的是TreeSet方法。这是一个有序的Set, 底层采用红黑树的存储结构。删除, 添加的操作均是O(logn)。所以上述的第三个问题可以使用TreeSet解决。1, 2只是复杂度没有达到要求。

同时此题要求 在相同次数下, 依据LRU算法进行淘汰。所以这个时候可以在节点中添加一个time字段, 并且在缓存当中维持一个全局的time。每个访问某个节点时, 将当前访问的节点的time依据全局的time进行调整。

public class LFUCache {

    static class LFUNode implements Comparable {

        int visitCount;
        int key;
        int value;

        int time; // 最近的访问时间

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

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            LFUNode lfuNode = (LFUNode) o;
            return key == lfuNode.key;
        }

        @Override
        public int hashCode() {
            return Objects.hash(key);
        }

        @Override
        public int compareTo(Object o) {
            if (this == o) return 0;
            if (o == null || getClass() != o.getClass()) return 0;
            LFUNode node = (LFUNode) o;
            if (this.visitCount != node.visitCount) {
                return this.visitCount - node.visitCount;
            } else {
                return this.time - node.time;
            }
        }
    }

    private final TreeSet<LFUNode> queue = new TreeSet<>(LFUNode::compareTo);

    private final HashMap<Integer, LFUNode> map = new HashMap<>();

    private int curCapacity = 0;
    private final int MAX_CAPACITY;

    private int time; // 全局的时间字段, 每次get put 此字段自增

    public LFUCache(int maxCapacity) {
        this.MAX_CAPACITY = maxCapacity;
        this.time = 0;
    }

    public void put(int key, int value) {
        if (MAX_CAPACITY == 0) {
            return;
        }
        int curTime = refreshCurrentTime();
        LFUNode node = map.get(key);
        if (node != null) {
            // Cache命中
            // 先更新值
            queue.remove(node);
            node.value = value;
            node.time = curTime;
            // 更新访问次数, 由于红黑树当中修改节点值并不会修改顺序
            // 所以需要将此节点从红黑树中删除再次插入
            node.visitCount++;
            queue.add(node);
            return;
        }
        // Cache没有命中
        if (curCapacity == MAX_CAPACITY) {
            LFUNode popNode = queue.pollFirst();
            map.remove(popNode.key);
            curCapacity--;
        }
        LFUNode newNode = new LFUNode(key, value, curTime);
        newNode.visitCount = 1;
        queue.add(newNode);
        this.curCapacity++;
        map.put(key, newNode);
    }

    public int get(int key) {
        if (MAX_CAPACITY == 0) {
            return -1;
        }
        int curTime = refreshCurrentTime();
        LFUNode node = map.get(key);
        if (node != null) {
            // Cache命中
            // 更新访问次数, 由于红黑树当中修改节点值并不会修改顺序
            // 所以需要将此节点从红黑树中删除再次插入
            queue.remove(node);
            // 必须先移除才能修改访问次数, 如果先修改访问次数再移除会移除失败
            node.visitCount++;
            node.time = curTime;
            // 这里也是, 在add前必须将visitCount和time修改完, 然后add。add之后修改是不会改变顺序的
            queue.add(node);
            return node.value;
        }
        return -1;
    }

    public int refreshCurrentTime() {
        return ++this.time;
    }
}

思路二

为了找到次数最少的, 还有一种办法是将访问次数通过HashMap保存起来。以<Freq, List<Node>>的形式保存。因为有多个节点具有相同的访问次数。并且要求相同次数下, 需要使用LRU进行替换。 所以借鉴前一题当中的LRU的算法, 这里需要使用一个双向链表来保存相同次数的节点

在进行get和put时, 访问次数会发生变化, 需要将原节点将其所在的双向链表当中删除, 并且更改访问次数, 添加到新的双向链表的头部。

在访问空间满是需要淘汰时, 可以记录一个全局的minFreq来表示<Freq, List<Node>>的map里双向链表非空的最小的Freq。这个minFreq的维护策略是, 当put新的key(添加)时, minFreq = 1。当put更新已有的key, 或者get命中时, 将此节点删除的时候, 判断删除后双向链表是否为空。如果为空了并且minFreq和当前的访问次数相同, 则将minFreq自增。然后在缓存淘汰时, 由于要添加新节点, 所以minFreq必须变成1, 所以在淘汰缓存时, 不需要对minFreq做调整。

public class LFUCache {

    private final TreeSet<LFUNode> queue = new TreeSet<>(LFUNode::compareTo);

    // keyMap 用来保存key和value的映射
    private final HashMap<Integer, LFUNode> keyMap = new HashMap<>();

    // countMap 用来保存访问次数 的 节点, 主要用来做淘汰策略
    private final HashMap<Integer, Deque<LFUNode>> countMap = new HashMap<>();

    private int curCapacity = 0;
    private final int MAX_CAPACITY;
    private int minFreq;

    public LFUCache(int maxCapacity) {
        this.MAX_CAPACITY = maxCapacity;
    }

    // incVisitCount 访问次数自增, 并且调整此节点在双向链表的位置
    public void incVisitCount(LFUNode node) {
        int visitCount = node.visitCount;
        Deque<LFUNode> nodes = countMap.get(visitCount);
        nodes.remove(node);
        // 更新访问次数时, 需要更新最小的访问次数
        if (nodes.size() == 0 && minFreq == visitCount) {
            minFreq++;
        }
        node.visitCount = ++visitCount;
        nodes = countMap.get(visitCount);
        if (nodes == null) {
            nodes = new LinkedList<>();
            countMap.put(visitCount, nodes);
        }
        nodes.addFirst(node);
    }

    public void put(int key, int value) {
        if (MAX_CAPACITY == 0) {
            return;
        }
        LFUNode node = keyMap.get(key);
        if (node != null) {
            // Cache命中
            node.value = value;
            incVisitCount(node);
            return;
        }
        // Cache未命中
        if (curCapacity == MAX_CAPACITY) {
            // Cache已满, 淘汰访问次数最少的, 并且最久未访问的
            LFUNode popNode = countMap.get(minFreq).removeLast();
            keyMap.remove(popNode.key);
            curCapacity--;
        }
		// 添加新的节点
        LFUNode newNode = new LFUNode(key, value);
        minFreq = 1;
        keyMap.put(key, newNode);
        Deque<LFUNode> sameCntNodes = countMap.get(1);
        if (sameCntNodes == null) {
            sameCntNodes = new LinkedList<>();
            countMap.put(1, sameCntNodes);
        }
        sameCntNodes.addFirst(newNode);
        curCapacity++;
    }

    public int get(int key) {
        if (MAX_CAPACITY == 0) {
            return -1;
        }
        LFUNode node = keyMap.get(key);
        if (node != null) {
            incVisitCount(node);
            return node.value;
        }
        return -1;
    }
}

class LFUNode implements Comparable {

    int visitCount;
    int key;
    int value;

    public LFUNode(int key, int value) {
        this.key = key;
        this.value = value;
        this.visitCount = 1;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        LFUNode lfuNode = (LFUNode) o;
        return key == lfuNode.key;
    }

    @Override
    public int hashCode() {
        return Objects.hash(key);
    }

    @Override
    public int compareTo(Object o) {
        if (this == o) return 0;
        if (o == null || getClass() != o.getClass()) return 0;
        LFUNode node = (LFUNode) o;
        if (this.visitCount != node.visitCount) {
            return this.visitCount - node.visitCount;
        }
        return 0;
    }
}

上述答案没有问题, 但是LeetCode提交代码时, 显示在时间上只打败了5%的对手, 检查代码才发现LinkedList的remove(Object o)方法是通过遍历的方式找到key相等的节点然后删除的。所以时间复杂度很高。所以要想取得一个高效的结果, 需要自己写一个双向链表。

public class LFUCache {

    static class LFUNode implements Comparable {

        int visitCount;
        int key;
        int value;

        LFUNode prev;
        LFUNode next;

        public LFUNode() {
        }

        public LFUNode(int key, int value) {
            this.key = key;
            this.value = value;
            this.visitCount = 1;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            LFUNode lfuNode = (LFUNode) o;
            return key == lfuNode.key;
        }

        @Override
        public int hashCode() {
            return Objects.hash(key);
        }

        @Override
        public int compareTo(Object o) {
            if (this == o) return 0;
            if (o == null || getClass() != o.getClass()) return 0;
            LFUNode node = (LFUNode) o;
            if (this.visitCount != node.visitCount) {
                return this.visitCount - node.visitCount;
            }
            return 0;
        }
    }

    static class DoubleLinkedList {
        private LFUNode head, tail;

        public DoubleLinkedList() {
            head = new LFUNode();
            tail = new LFUNode();
            head.next = tail;
        }

        public void unlink(LFUNode node) {
            node.prev.next = node.next;
            node.next.prev = node.prev;
        }

        public void addToHead(LFUNode node) {
            node.prev = head;
            node.next = head.next;
            head.next = node;
            node.next.prev = node;
        }

        public boolean isEmpty() {
            return tail == head.next;
        }

        public LFUNode removeLast() {
            LFUNode toDeletedNode = tail.prev;
            unlink(toDeletedNode);
            return toDeletedNode;
        }
    }

    private final TreeSet<LFUNode> queue = new TreeSet<>(LFUNode::compareTo);

    // keyMap 用来保存key和value的映射
    private final HashMap<Integer, LFUNode> keyMap = new HashMap<>();

    // countMap 用来保存访问次数 的 节点, 主要用来做淘汰策略
    private final HashMap<Integer, DoubleLinkedList> countMap = new HashMap<>();

    private int curCapacity = 0;
    private final int MAX_CAPACITY;
    private int minFreq;

    public LFUCache(int maxCapacity) {
        this.MAX_CAPACITY = maxCapacity;
    }

    public void incVisitCount(LFUNode node) {
        int visitCount = node.visitCount;
        DoubleLinkedList nodes = countMap.get(visitCount);
        nodes.unlink(node);
        // 更新访问次数时, 需要更新最小的访问次数
        if (nodes.isEmpty() && minFreq == visitCount) {
            minFreq++;
        }
        node.visitCount = ++visitCount;
        nodes = countMap.get(visitCount);
        if (nodes == null) {
            nodes = new DoubleLinkedList();
            countMap.put(visitCount, nodes);
        }
        nodes.addToHead(node);
    }

    public void put(int key, int value) {
        if (MAX_CAPACITY == 0) {
            return;
        }
        LFUNode node = keyMap.get(key);
        if (node != null) {
            // Cache命中
            node.value = value;
            incVisitCount(node);
            return;
        }
        // Cache未命中
        if (curCapacity == MAX_CAPACITY) {
            // Cache已满, 淘汰访问次数最少的, 并且最久未访问的
            LFUNode popNode = countMap.get(minFreq).removeLast();
            keyMap.remove(popNode.key);
            curCapacity--;
        }

        LFUNode newNode = new LFUNode(key, value);
        minFreq = 1;
        keyMap.put(key, newNode);
        DoubleLinkedList sameCntNodes = countMap.get(1);
        if (sameCntNodes == null) {
            sameCntNodes = new DoubleLinkedList();
            countMap.put(1, sameCntNodes);
        }
        sameCntNodes.addToHead(newNode);
        curCapacity++;
    }

    public int get(int key) {
        if (MAX_CAPACITY == 0) {
            return -1;
        }
        LFUNode node = keyMap.get(key);
        if (node != null) {
            incVisitCount(node);
            return node.value;
        }
        return -1;
    }
}
上一篇:2021/10/19


下一篇:【题解】CF1243B2 Character Swap (Hard Version)