LeetCode算法题解:LFU Cache

原题:https://leetcode.com/problems/lfu-cache/?tab=Description

题目要求

设计并实现一个数据结构,满足LFU (Least Frequently Used) Cache特性,实现两种操作:get和put

  • get(key):返回指定key对应的value,如果指定key在cache中不存在,返回-1
  • put(key, value):设置指定key的value,如果key不存在,则插入该key-value对。如果cache空间已满,则将最少使用的key-value对移除,如果存在多个key-value对的使用次数相同,则将上次访问时间最早的key-value对移除。
public class LFUCache {

    public LFUCache(int capacity) {
        
    }
    
    public int get(int key) {
        
    }
    
    public void put(int key, int value) {
        
    }
}

进阶要求

以O(1)时间复杂度实现get和put操作

思路解析

这道题的要求可以分解成两部分

  1. 实现一个key-value存储结构,能够通过key快速找到对应的value
  2. 在cache已满时,快速定位到访问次数最低且上次访问时间最早的key,将其移除

上述两种计算都应以O(1)的时间复杂度实现。

对于第1点要求,毫无争议地应使用哈希表来实现,哈希表的插入时间复杂度永远是O(1),在不发生哈希冲突的前提下,查找的时间复杂度也是O(1)。这里我们可以偷个懒,直接把HashMap拿来用。

对于第2点要求,最基本思路是记录每个key的访问次数和上次访问时间,在cache已满时找到访问次数最少的key,如果有多个key访问次数一样,再去找这些key里上次访问时间最早的一个进行移除。

很显然,通过遍历去找访问次数最少的key是不行的,这样时间复杂度就是O(n)了。我们可以考虑构建一个链表,确保访问次数最少的key位于链表的尾部:

LeetCode算法题解:LFU Cache_第1张图片
图片.png

设定每次新插入的key的访问次数为0,那么插入时只需要将key置于链表的尾部,同时在移除key时只要移除链表尾部的key就行了,这样插入和移除的时间复杂度都是O(1)了。

然而,可能存在多个key的访问次数一样的情况,这种情况下不得不去遍历这些key,找到上次访问时间最早的一个。这样一来,时间复杂度就超过了O(1)。

对此,我们可以考虑把结构改成两层链表:

LeetCode算法题解:LFU Cache_第2张图片
图片.png

外层链表的每个节点代表一组拥有同样访问次数的key,每个节点自身也是一个链表,内层链表确保上次访问时间最早的key位于内层链表的尾部。

在这一数据结构下,我们在插入key时判断外层链表尾部元素的freq是否为0,如果是,将key插入该内层链表的头部,如果否,生成一个只包含key的外层链表,插入到外层链表的尾部。在访问key时,将该key移动到外层链表的下一个节点的头部。这样一来,在移除key时,只需要移除外层链表尾部元素的尾部元素即可,插入、访问、移除的时间复杂度都是O(1)。

代码解析

定义内层链表的元素对象Node:

private static class Node {
    int key;
    int value;
    int frequency = 0; //访问次数
    Node next; //下一元素
    Node prev; //前一元素
    NodeQueue nq;  //所属的外层链表元素
    
    Node(int key, int value) {
        this.key = key;
        this.value = value;
    }
}

定义外层链表的元素对象NodeQueue:

private static class NodeQueue {
    NodeQueue next; //下一元素
    NodeQueue prev;  //前一元素
    Node tail;  //尾部Node
    Node head;  //头部Node
    
    public NodeQueue(NodeQueue next, NodeQueue prev, Node tail, Node head) {
        this.next = next;
        this.prev = prev;
        this.tail = tail;
        this.head = head;
    }
}

定义LFU Cache:

import java.util.HashMap;

public class LFUCache {
    private NodeQueue tail;  //链表尾部的NodeQueue
    private int capacity;  //容量
    private HashMap map;  //存储key-value对的HashMap

    //构造方法
    public LFUCache(int capacity) {
        this.capacity = capacity;
        map = new HashMap(capacity);
    }
}

接下来,实现整个数据结构中最关键的算法:将Node右移

private void oneStepUp(Node n) {
    n.frequency++; //访问次数+1
    boolean singleNodeQ = false; //为true时,代表此NodeQueue中只有一个Node元素
    if(n.nq.head == n.nq.tail)
        singleNodeQ = true;  
    if(n.nq.next != null) {
        if(n.nq.next.tail.frequency == n.frequency) {
            //右侧NodeQueue的访问次数与Node当前访问次数一样,将此Node置于右侧NodeQueue的头部
            removeNode(n); //从当前NodeQueue中删除Node
            //把Node插入到右侧NodeQueue的头部
            n.prev = n.nq.next.head;
            n.nq.next.head.next = n;
            n.nq.next.head = n;
            n.nq = n.nq.next;
        } else if(n.nq.next.tail.frequency > n.frequency) {
            //右侧NodeQueue的访问次数大于Node当前访问次数,则需要在两个NodeQueue之间插入一个新的NodeQueue
            if(!singleNodeQ) {
                removeNode(n);
                NodeQueue nnq = new NodeQueue(n.nq.next, n.nq, n, n);
                n.nq.next.prev = nnq;
                n.nq.next = nnq;
                n.nq = nnq;
            }
            //如果当前NodeQueue中只有一个Node,那么其实不需要任何额外操作了
        }
    } else {
        //此NodeQueue的next == null,说明此NodeQueue已经位于外层链表头部了,这时候需要往外侧链表头部插入一个新的NodeQueue
        if(!singleNodeQ) {
            removeNode(n);
            NodeQueue nnq = new NodeQueue(null, n.nq, n, n);
            n.nq.next = nnq;
            n.nq = nnq;
        }
        //同样地,如果当前NodeQueue中只有一个Node,不需要任何额外操作
    }
}

移除Node的方法:

private Node removeNode(Node n) {
    //如果NodeQueue中只有一个Node,那么移除整个NodeQueue
    if(n.nq.head == n.nq.tail) {
        removeNQ(n.nq);
        return n;
    }
    if(n.prev != null)
        n.prev.next = n.next;
    if(n.next != null)
        n.next.prev = n.prev;
    if(n.nq.head == n)
        n.nq.head = n.prev;
    if(n.nq.tail == n)
        n.nq.tail = n.next;
    n.prev = null;
    n.next = null;
    return n;
}

private void removeNQ(NodeQueue nq) {
    if(nq.prev != null)
        nq.prev.next = nq.next;
    if(nq.next != null)
        nq.next.prev = nq.prev;
    if(this.tail == nq)
        this.tail = nq.next;
}

接下来实现get和put方法:

get方法非常简单了,到HashMap中拿到key对应的Node,并且将该Node右移:

public int get(int key) {
    Node n = map.get(key);
    if(n == null)
        return -1;
    oneStepUp(n);
    return n.value;
}

put方法:

public void put(int key, int value) {
    if(capacity == 0)
        return;
    
    Node cn = map.get(key);
    //key已存在的情况下,更新value值,并将Node右移
    if(cn != null) {
        cn.value = value;
        oneStepUp(cn);
        return;
    }
    //cache已满的情况下,把外层链表尾部的内层链表的尾部Node移除
    if(map.size() == capacity) {
        map.remove(removeNode(this.tail.tail).key);
    }
    //插入新的Node
    Node n = new Node(key, value);
    if(this.tail == null) {
        //tail为null说明此时cache中没有元素,直接把Node封装到NodeQueue里加入
        NodeQueue nq = new NodeQueue(null, null, n, n);
        this.tail = nq;
        n.nq = nq;
    } else if(this.tail.tail.frequency == 0) {
        //外层链表尾部元素的访问次数是0,那么将Node加入到外层链表尾部元素的头部
        n.prev = this.tail.head;
        this.tail.head.next = n;
        n.nq = this.tail;
        this.tail.head = n;
    } else {
        //外层链表尾部元素的访问次数不是0,那么实例化一个只包含此Node的NodeQueue,加入外层链表尾部
        NodeQueue nq = new NodeQueue(this.tail, null, n, n);
        this.tail.prev = nq;
        this.tail = nq;
        n.nq = nq;
    }
    //最后把key和Node存入HashMap中
    map.put(key, n);
}

运行结果:

LeetCode算法题解:LFU Cache_第3张图片
图片.png

还不错,击败了99.81%的选手。不过LeetCode的代码运行时间统计不太稳定,这段代码跑起来的成绩差不多在140~170ms之间。

你可能感兴趣的:(LeetCode算法题解:LFU Cache)