LeetCode演算法題解:LFU Cache

weixin_34006468發表於2017-02-21

原題: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位於連結串列的尾部:

4840514-2f140a0f9f96cfe5.png
圖片.png

設定每次新插入的key的訪問次數為0,那麼插入時只需要將key置於連結串列的尾部,同時在移除key時只要移除連結串列尾部的key就行了,這樣插入和移除的時間複雜度都是O(1)了。

然而,可能存在多個key的訪問次數一樣的情況,這種情況下不得不去遍歷這些key,找到上次訪問時間最早的一個。這樣一來,時間複雜度就超過了O(1)。

對此,我們可以考慮把結構改成兩層連結串列:

4840514-d8aec2c9900499b2.png
圖片.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<Integer, Node> map;  //儲存key-value對的HashMap

    //構造方法
    public LFUCache(int capacity) {
        this.capacity = capacity;
        map = new HashMap<Integer, Node>(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);
}

執行結果:

4840514-09285f99bc68adfd.png
圖片.png

還不錯,擊敗了99.81%的選手。不過LeetCode的程式碼執行時間統計不太穩定,這段程式碼跑起來的成績差不多在140~170ms之間。

相關文章