雖然,力扣要求是用時間複雜度 O(1) 來解,但是其它方式我感覺也有必要了解,畢竟是一個由淺到深的過程,自己實現一遍總歸是好的。因此,我就把五種求解方式,從簡單到複雜,都講一遍。
LFU實現
力扣原題描述如下:
請你爲 最不經常使用(LFU)緩存算法設計並實現數據結構。它應該支持以下操作:get 和 put。
get(key) - 如果鍵存在於緩存中,則獲取鍵的值(總是正數),否則返回 -1。
put(key, value) - 如果鍵不存在,請設置或插入值。當緩存達到其容量時,則應該在插入新項之前,使最不經常使用的項無效。在此問題中,當存在平局(即兩個或更多個鍵具有相同使用頻率)時,應該去除 最近 最少使用的鍵。
「項的使用次數」就是自插入該項以來對其調用 get 和 put 函數的次數之和。使用次數會在對應項被移除後置爲 0 。
示例:
LFUCache cache = new LFUCache( 2 /* capacity (緩存容量) */ );
cache.put(1, 1);
cache.put(2, 2);
cache.get(1); // 返回 1
cache.put(3, 3); // 去除 key 2
cache.get(2); // 返回 -1 (未找到key 2)
cache.get(3); // 返回 3
cache.put(4, 4); // 去除 key 1
cache.get(1); // 返回 -1 (未找到 key 1)
cache.get(3); // 返回 3
cache.get(4); // 返回 4
來源:力扣(LeetCode)
鏈接:https://leetcode-cn.com/problems/lfu-cache
就是要求我們設計一個 LFU 算法,根據訪問次數(訪問頻次)大小來判斷應該刪除哪個元素,get和put操作都會增加訪問頻次。當訪問頻次相等時,就判斷哪個元素是最久未使用過的,把它刪除。
因此,這道題需要考慮兩個方面,一個是訪問頻次,一個是訪問時間的先後順序。
方案一:使用優先隊列
思路:
我們可以使用JDK提供的優先隊列 PriorityQueue 來實現 。 因爲優先隊列內部維護了一個二叉堆,即可以保證每次 poll 元素的時候,都可以根據我們的要求,取出當前所有元素的最大值或是最小值。只需要我們的實體類實現 Comparable 接口就可以了。
當 cache 容量不足時,根據訪問頻次 freq 的大小來刪除最小的 freq 。若相等,則刪除 index 最小的,因爲index是自增的,越大說明越是最近訪問過的,越小說明越是很長時間沒訪問過的元素。
因本質是用二叉堆實現,故時間複雜度爲O(logn)。
public class LFUCache4 {
public static void main(String[] args) {
LFUCache4 cache = new LFUCache4(2);
cache.put(1, 1);
cache.put(2, 2);
// 返回 1
System.out.println(cache.get(1));
cache.put(3, 3); // 去除 key 2
// 返回 -1 (未找到key 2)
System.out.println(cache.get(2));
// 返回 3
System.out.println(cache.get(3));
cache.put(4, 4); // 去除 key 1
// 返回 -1 (未找到 key 1)
System.out.println(cache.get(1));
// 返回 3
System.out.println(cache.get(3));
// 返回 4
System.out.println(cache.get(4));
}
//緩存了所有元素的node
Map<Integer,Node> cache;
//優先隊列
Queue<Node> queue;
//緩存cache 的容量
int capacity;
//當前緩存的元素個數
int size;
//全局自增
int index = 0;
//初始化
public LFUCache4(int capacity){
this.capacity = capacity;
if(capacity > 0){
queue = new PriorityQueue<>(capacity);
}
cache = new HashMap<>();
}
public int get(int key){
Node node = cache.get(key);
// node不存在,則返回 -1
if(node == null) return -1;
//每訪問一次,頻次和全局index都自增 1
node.freq++;
node.index = index++;
// 每次都重新remove,再offer是爲了讓優先隊列能夠對當前Node重排序
//不然的話,比較的 freq 和 index 就是不準確的
queue.remove(node);
queue.offer(node);
return node.value;
}
public void put(int key, int value){
//容量0,則直接返回
if(capacity == 0) return;
Node node = cache.get(key);
//如果node存在,則更新它的value值
if(node != null){
node.value = value;
node.freq++;
node.index = index++;
queue.remove(node);
queue.offer(node);
}else {
//如果cache滿了,則從優先隊列中取出一個元素,這個元素一定是頻次最小,最久未訪問過的元素
if(size == capacity){
cache.remove(queue.poll().key);
//取出元素後,size減 1
size--;
}
//否則,說明可以添加元素,於是創建一個新的node,添加到優先隊列中
Node newNode = new Node(key, value, index++);
queue.offer(newNode);
cache.put(key,newNode);
//同時,size加 1
size++;
}
}
//必須實現 Comparable 接口才可用於排序
private class Node implements Comparable<Node>{
int key;
int value;
int freq = 1;
int index;
public Node(){
}
public Node(int key, int value, int index){
this.key = key;
this.value = value;
this.index = index;
}
@Override
public int compareTo(Node o) {
//優先比較頻次 freq,頻次相同再比較index
int minus = this.freq - o.freq;
return minus == 0? this.index - o.index : minus;
}
}
}
方案二:使用一條雙向鏈表
思路:
只用一條雙向鏈表,來維護頻次和時間先後順序。那麼,可以這樣想。把頻次 freq 小的放前面,頻次大的放後面。如果頻次相等,就從當前節點往後遍歷,直到找到第一個頻次比它大的元素,並插入到它前面。(當然,如果遍歷到了tail,則插入到tail前面)這樣可以保證同頻次的元素,最近訪問的總是在最後邊。
PS:哨兵節點只是爲了佔位,實際並不存儲有效數據,只是爲了鏈表插入和刪除時,不用再判斷當前節點的位置。不然的話,若當前節點佔據了頭結點或尾結點的位置,還需要重新賦值頭尾節點元素,較麻煩。
爲了便於理解新節點如何插入到鏈表中合適的位置,作圖如下:
代碼如下:
public class LFUCache {
public static void main(String[] args) {
LFUCache cache = new LFUCache(2);
cache.put(1, 1);
cache.put(2, 2);
// 返回 1
System.out.println(cache.get(1));
cache.put(3, 3); // 去除 key 2
// 返回 -1 (未找到key 2)
System.out.println(cache.get(2));
// 返回 3
System.out.println(cache.get(3));
cache.put(4, 4); // 去除 key 1
// 返回 -1 (未找到 key 1)
System.out.println(cache.get(1));
// 返回 3
System.out.println(cache.get(3));
// 返回 4
System.out.println(cache.get(4));
}
private Map<Integer,Node> cache;
private Node head;
private Node tail;
private int capacity;
private int size;
public LFUCache(int capacity) {
this.capacity = capacity;
this.cache = new HashMap<>();
/**
* 初始化頭結點和尾結點,並作爲哨兵節點
*/
head = new Node();
tail = new Node();
head.next = tail;
tail.pre = head;
}
public int get(int key) {
Node node = cache.get(key);
if(node == null) return -1;
node.freq++;
moveToPostion(node);
return node.value;
}
public void put(int key, int value) {
if(capacity == 0) return;
Node node = cache.get(key);
if(node != null){
node.value = value;
node.freq++;
moveToPostion(node);
}else{
//如果元素滿了
if(size == capacity){
//直接移除最前面的元素,因爲這個節點就是頻次最小,且最久未訪問的節點
cache.remove(head.next.key);
removeNode(head.next);
size--;
}
Node newNode = new Node(key, value);
//把新元素添加進來
addNode(newNode);
cache.put(key,newNode);
size++;
}
}
//只要當前 node 的頻次大於等於它後邊的節點,就一直向後找,
// 直到找到第一個比當前node頻次大的節點,或者tail節點,然後插入到它前面
private void moveToPostion(Node node){
Node nextNode = node.next;
//先把當前元素刪除
removeNode(node);
//遍歷到符合要求的節點
while (node.freq >= nextNode.freq && nextNode != tail){
nextNode = nextNode.next;
}
//把當前元素插入到nextNode前面
node.pre = nextNode.pre;
node.next = nextNode;
nextNode.pre.next = node;
nextNode.pre = node;
}
//添加元素(頭插法),並移動到合適的位置
private void addNode(Node node){
node.pre = head;
node.next = head.next;
head.next.pre = node;
head.next = node;
moveToPostion(node);
}
//移除元素
private void removeNode(Node node){
node.pre.next = node.next;
node.next.pre = node.pre;
}
class Node {
int key;
int value;
int freq = 1;
//當前節點的前一個節點
Node pre;
//當前節點的後一個節點
Node next;
public Node(){
}
public Node(int key ,int value){
this.key = key;
this.value = value;
}
}
}
可以看到不管是插入元素還是刪除元素時,都不需要額外的判斷,這就是設置哨兵節點的好處。
由於每次訪問元素的時候,都需要按一定的規則把元素放置到合適的位置,因此,元素需要從前往後一直遍歷。所以,時間複雜度 O(n)。