设计LFU缓存结构

LFU缓存结构实现

前言

LRU 和LFU是两种著名的缓存淘汰算法。LRU 算法的淘汰策略是 Least Recently Used,也就是每次淘汰那些最久没被使用的数据;LFU 算法的淘汰策略是 Least Frequently Used,也就是每次淘汰那些使用次数最少的数据。

LRU 算法的核心数据结构是使用哈希链表 LinkedHashMap,首先借助链表的有序性使得链表元素维持插入顺序,同时借助哈希映射的快速访问能力使得我们可以在 O(1) 时间访问链表的任意元素。

从实现难度上来说,LFU 算法的难度大于 LRU 算法,因为 LRU 算法相当于把数据按照时间排序,这个需求借助链表很自然就能实现,一直从链表头部加入元素的话,越靠近头部的元素就是新的数据,越靠近尾部的元素就是旧的数据,进行缓存淘汰的时候只要简单地将尾部的元素淘汰掉即可。

而 LFU 算法相当于是把数据按照访问频次进行排序,这个需求恐怕没有那么简单,而且还有一种情况,如果多个数据拥有相同的访问频次,则需删除最早插入的那个数据。也就是说 LFU 算法是淘汰访问频次最低的数据,如果访问频次最低的数据有多条,需要淘汰最旧的数据。

LRU缓存结构

  • LRU缓存结构是LFU缓存结构的初级版,我们可以先实现一下LRU缓存,熟悉它的结构。
  • 如上所述,LRU 算法的淘汰策略是 Least Recently Used,每次淘汰那些最久没被使用的数据。我们可以双向链表+哈希表,在双向链表中维护每一个插入的键值对,当插入新结点时始终采用头插,如果插入或查询已有结点,则将该结点移至链表头部。

双向链表+哈希表

public class LRUCache {

    static class DequeNode {
        int key;
        int val;
        DequeNode pre;
        DequeNode next;
        public DequeNode(){}
        public DequeNode(int key, int val) {
            this.key = key;
            this.val = val;
        }
    }

    private int size; // 当前容量
    private final int capacity; // 限制大小
    private final Map<Integer, DequeNode> map; // 数据和链表中节点的映射
    private final DequeNode head; // 头结点 避免null检查
    private final DequeNode tail; // 尾结点 避免null检查


    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.map = new HashMap<>();
        this.head = new DequeNode();
        this.tail = new DequeNode();
        this.head.next = tail;
        this.tail.pre = head;
    }

    // 取key对应的值,如果不存在返回null
    public Integer get(Integer key) {
        if(!map.containsKey(key)) return null;
        // 数据在链表中,则移至链表头部
        DequeNode node=map.get(key);
        if(head.next!=node){
            remove(node);
            insert(head,node);
        }
        return node.val;
    }

    // 插入(key,value)
    public void set(Integer key, Integer value) {
        if(!map.containsKey(key)){
            eliminate();
            DequeNode newNode = new DequeNode(key, value);
            insert(head, newNode);
            map.put(key, newNode);
        }else{
            DequeNode node=map.get(key);
            node.val=value;
            if(head.next!=node){
                remove(node);
                insert(head,node);
            }
        }
    }

    // 在preNode后插入新结点
    private void insert(DequeNode preNode, DequeNode newNode){
        DequeNode tmp=preNode.next;
        preNode.next=newNode;
        newNode.pre=preNode;
        newNode.next=tmp;
        tmp.pre=newNode;
        size++;
    }

    // 删除结点
    private void remove(DequeNode node){
        DequeNode pre=node.pre, next=node.next;
        pre.next=next;
        next.pre=pre;
        size--;
        node=null;
    }

    // 删除尾部结点直至有空余容量
    private void eliminate() {
        while (size >= capacity){
            // 将链表中最后一个节点去除
            DequeNode last = tail.pre;
            map.remove(last.key);
            remove(last);
        }
    }

    public String toString(){
        StringBuilder str=new StringBuilder("");
        str.append("[");
        DequeNode node=head.next;
        while (node!=tail){
            str.append("[").append(node.key).append(",").append(node.val).append("]");
            if(node!=tail.pre) str.append(",");
            node=node.next;
        }
        str.append("]");
        return str.toString();
    }

    public static void main(String[] args){
        LRUCache lru=new LRUCache(3);
        System.out.println(lru);
        lru.set(1,1);
        System.out.println(lru);
        lru.set(2,2);
        System.out.println(lru);
        lru.set(3,2);
        System.out.println(lru);
        lru.set(2,4);
        System.out.println(lru);
        int val=lru.get(3);
        System.out.println(val);
        System.out.println(lru);
    }
}

输出:

[]
[[1,1]]
[[2,2],[1,1]]
[[3,2],[2,2],[1,1]]
[[2,4],[3,2],[1,1]]
2
[[3,2],[2,4],[1,1]]
  • 时空复杂度分析:set()和get()的时间复杂度都是O(1),空间复杂度为O(N)

LinkedHashMap

  • 其实我们可以直接根据JDK给我们提供的LinkedHashMap直接实现LRU。因为LinkedHashMap的底层即为双向链表和哈希表的组合,所以可以直接拿来使用。
class LRUCache{
    private final int capacity;
    private final LinkedHashMap<Integer, Integer> mp;

    public LRUCache(int capacity) {
        // 注意这里将LinkedHashMap的accessOrder设为true
        mp=new LinkedHashMap<>(16,0.75f,true);
        this.capacity = capacity;
    }

    public Integer get(int key){
        return mp.get(key);
    }

    public void set(int key, int val){
        mp.put(key,val);
        eliminate();
    }

    private void eliminate() {
        while (mp.size() > capacity){
            // 将最后一个节点去除
            mp.remove(mp.entrySet().iterator().next().getKey());
        }
    }
}
  • 更方便地,我们可以直接继承LinkedHashMap类
class LRUCache extends LinkedHashMap<Integer, Integer>{
    private final int capacity;

    public LRUCache(int capacity) {
        // 注意这里将LinkedHashMap的accessOrder设为true
        super(16, 0.75f, true);
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        return super.size() > capacity;
    }
}

默认LinkedHashMap并不会淘汰数据,所以我们重写了它的removeEldestEntry()方法,当数据数量达到预设上限后,淘汰数据,accessOrder设为true意为按照访问的顺序排序。整个实现的代码量并不大,主要都是应用LinkedHashMap的特性。

LFU缓存结构

题目大意

一个缓存结构需要实现如下功能。

  • set(key, value):将记录(key, value)插入该结构
  • get(key):返回key对应的value值

但是缓存结构中最多放K条记录,如果新的第K+1条记录要加入,就需要根据策略删掉一条记录,然后才能把新记录加入。这个策略为:在缓存结构的K条记录中,哪一个key从进入缓存结构的时刻开始,被调用set或者get的次数最少,就删掉这个key的记录;
如果调用次数最少的key有多个,上次调用发生最早的key被删除
这就是LFU缓存替换算法。实现这个结构,K作为参数给出

数据范围: 0 < k ≤ 1 0 5 , ∣ v a l ∣ ≤ 2 × 1 0 9 0 < k \le 10^5 ,|val| \le 2 \times 10^9 0<k105val2×109
要求:get和set的时间复杂度都是 O(logn),空间复杂度是 O(n)

若opt=1,接下来两个整数x, y,表示set(x, y)
若opt=2,接下来一个整数x,表示get(x),若x未出现过或已被移除,则返回-1

对于每个操作2,返回一个答案

  • 示例1

输入: [[1,1,1],[1,2,2],[1,3,2],[1,2,4],[1,3,5],[2,2],[1,4,4],[2,1]],3
输出: [4,-1]
说明: 在执行"1 4 4"后,"1 1 1"被删除。因此第二次询问的答案为-1

优先队列、哈希表和LinkedHashSet

具体思路是:

  • 使用一个哈希表维护(key,value)的映射关系
  • 使用一个哈希表维护(key,freq)的映射关系
  • 自定义二叉堆(这里对优先队列和二叉堆不做区分)的结点值LfuNode,使每个结点保存对应频率和该频率下出现的所有key,其中该频率下出现的所有key用LinkedHashSet保存
  • 使用一个哈希表维护(freq,LfuNode)的映射关系
  • 后续的操作就非常简单了。插入(key,value)时,如果key已存在,则在原有频率结点中删除key,在新的频率结点中增加key;如果key不存在,则在频率为1的结点中增加key。取key对应的值时,如果key存在,则在原有频率结点中删除key,在新的频率结点中增加key,并返回对应的value;否则返回-1。如果有删除key的操作,我们需要判断对应结点是否还存有数据,若无数据,则从优先队列中删除该结点,剩余结点会根据优先队列的性质自动排序(我这里重写了二叉堆结点的比较函数);如果有新增key的操作,需要判断当前缓存结构是否会超出容量,如果会超容,则删除优先队列中头部结点的第一个元素(同样需要判断删除后该结点是否还有效)。
  • 值的注意的是,我们使用了三个哈希表、一个LinkedHashSet和一个优先队列,其中哈希表增删改查的时间复杂度都为O(1),LinkedHashSet删除/新增头尾节点的时间复杂度为O(1),优先队列插入和删除的时间复杂度为O(logn)。所有数据结构的空间复杂度均为O(n)。所以满足题目要求:get和set的时间复杂度都是 O(logn),空间复杂度是 O(n)。
class LFUCache{

    private static class LfuNode implements Comparable<LfuNode>{
        int fre;
        LinkedHashSet<Integer> kSet =new LinkedHashSet<>();
        public LfuNode(){}
        public LfuNode(int fre){
            this.fre=fre;
        }

        @Override
        public int compareTo(LfuNode o) {
            return Integer.compare(this.fre, o.fre);
        }
    }

    private int size; // 当前容量
    private final int capacity; // 限制大小
    private final Map<Integer,Integer> k2v;
    private final Map<Integer,Integer> k2f;
    private final Map<Integer,LfuNode> f2L;
    private final PriorityQueue<LfuNode> pq;

    public LFUCache(int capacity){
        this.capacity=capacity;
        k2v=new HashMap<>();
        k2f=new HashMap<>();
        f2L=new HashMap<>();
        pq=new PriorityQueue<>();
    }

    public void set(int key, int val){
        if(k2v.containsKey(key)){
            k2v.put(key,val);//更新k2v
            int fre=k2f.get(key);
            k2f.put(key,fre+1);//更新k2f
            LfuNode lNode=f2L.get(fre);
            lNode.kSet.remove(key);//删除原有频率结点中的key
            check(lNode);
            addKey(fre+1,key);//将key插入到fre+1对应的结点中
        }else{
            eliminate();
            k2v.put(key,val);//更新k2v
            k2f.put(key,1);//更新k2f
            addKey(1,key);
            size++;
        }
    }

    public int get(int key){
        int val=-1;
        if(k2v.containsKey(key)){
            val=k2v.get(key);
            int fre=k2f.get(key);
            k2f.put(key,fre+1);//更新k2f
            LfuNode lNode=f2L.get(fre);
            lNode.kSet.remove(key);//删除原有频率结点中的key
            check(lNode);
            addKey(fre+1,key);//将key插入到fre+1对应的结点中
        }
        return val;
    }

    // 向对应的频率结点中插入key
    private void addKey(int fre, int key){
        if(f2L.containsKey(fre)){
            f2L.get(fre).kSet.add(key);
        }else{
            LfuNode ln=new LfuNode(fre);
            ln.kSet.add(key);
            f2L.put(fre,ln);
            pq.offer(ln);
        }
    }

    // 检查结点是否有效,如无效做出对应操作
    private void check(LfuNode ln){
        //如果该频率结点已无key,则从优先队列中删除,并更新f2L
        if(ln.kSet.size()==0){
            pq.remove(ln);
            f2L.remove(ln.fre);
        }
    }

    // 删除优先队列首部结点直至有空余容量
    private void eliminate() {
        while (size >= capacity && !pq.isEmpty()){
            LfuNode ln=pq.peek();
            int key=ln.kSet.iterator().next();
            ln.kSet.remove(key);
            check(ln);
            k2v.remove(key);
            k2f.remove(key);
            size--;
        }
    }

    public String toString(){
        StringBuilder str=new StringBuilder("");
        str.append("[");
        Iterator<LfuNode> iter=pq.iterator();
        while (iter.hasNext()){
            LfuNode ln=iter.next();
            for(int i:ln.kSet){
                str.append("[").append(i).append(",").append(k2v.get(i)).append("]");
            }
            if(iter.hasNext()) str.append(",");
        }
        str.append("]");
        return str.toString();
    }

}

public class Solution {
    /**
     * lfu design
     * @param operators int整型二维数组 ops
     * @param k int整型 the k
     * @return int整型一维数组
     */
    public int[] LFU (int[][] operators, int k) {
        // write code here
        int m=operators.length;
        LFUCache lfu=new LFUCache(k);
        List<Integer> resList=new ArrayList<>();
        for(int[] op:operators){
            if(op[0]==1){
                lfu.set(op[1],op[2]);
            }else{
                resList.add(lfu.get(op[1]));
            }
//             System.out.println(lfu.toString());
        }
        return resList.stream().mapToInt(Integer::intValue).toArray();
    }
    
}

你可能感兴趣的:(算法与数据结构,面试,缓存,算法)