好吧,有人可能覺得我標題黨了,但我想告訴你們的是,前陣子面試確實掛在了 RLU 緩存算法的設計上了。當時做題的時候,自己想的太多了,感覺設計一個 LRU(Least recently used) 緩存算法,不會這么簡單啊,於是理解錯了題意(我也是服了,還能理解成這樣,,,,),自己一波操作寫了好多代碼,后來卡住了,再去仔細看題,發現自己應該是理解錯了,就是這么簡單,設計一個 LRU 緩存算法。
不過這時時間就很緊了,按道理如果你真的對這個算法很熟,十分鍾就能寫出來了,但是,自己雖然理解 LRU 緩存算法的思想,也知道具體步驟,但之前卻從來沒有去動手寫過,導致在寫的時候,非常不熟練,也就是說,你感覺自己會 和你能夠用代碼完美着寫出來是完全不是一回事,所以在此提醒各位,如果可以,一定要自己用代碼實現一遍自己自以為會的東西。千萬不要覺得自己理解了思想,就不用去寫代碼了,獨自擼一遍代碼,才是真的理解了。
今天我帶大家用代碼來實現一遍 LRU 緩存算法,以后你在遇到這類型的題,保證你完美秒殺它。
題目描述
設計並實現最不經常使用(LFU)緩存的數據結構。它應該支持以下操作:get 和 put。
get(key) - 如果鍵存在於緩存中,則獲取鍵的值(總是正數),否則返回 -1。
put(key, value) - 如果鍵不存在,請設置或插入值。當緩存達到其容量時,它應該在插入新項目之前,
使最不經常使用的項目無效。在此問題中,當存在平局(即兩個或更多個鍵具有相同使用頻率)時,
最近最少使用的鍵將被去除。
進階:
你是否可以在 O(1) 時間復雜度內執行兩項操作?
示例:
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
基礎版:單鏈表來解決
我們要刪的是最近最少使用的節點,一種比較容易想到的方法就是使用單鏈表這種數據結構來存儲了。當我們進行 put 操作的時候,會出現以下幾種情況:
1、如果要 put(key,value) 已經存在於鏈表之中了(根據key來判斷),那么我們需要把鏈表中久的數據刪除,然后把新的數據插入到鏈表的頭部。、
2、如果要 put(key,value) 的數據沒有存在於鏈表之后,我們我們需要判斷下緩存區是否已滿,如果滿的話,則把鏈表尾部的節點刪除,之后把新的數據插入到鏈表頭部。如果沒有滿的話,直接把數據插入鏈表頭部即可。
對於 get 操作,則會出現以下情況
1、如果要 get(key) 的數據存在於鏈表中,則把 value 返回,並且把該節點刪除,刪除之后把它插入到鏈表的頭部。
2、如果要 get(key) 的數據不存在於鏈表之后,則直接返回 -1 即可。
大概的思路就是這樣,不要覺得很簡單,讓你手寫的話,十分鍾你不一定手寫的出來。具體的代碼,為了不影響閱讀,我在文章的最后面在放出來。
時間、空間復雜度分析
對於這種方法,put 和 get 都需要遍歷鏈表查找數據是否存在,所以時間復雜度為 O(n)。空間復雜度為 O(1)。
空間換時間
在實際的應用中,當我們要去讀取一個數據的時候,會先判斷該數據是否存在於緩存器中,如果存在,則返回,如果不存在,則去別的地方查找該數據(例如磁盤),找到后在把該數據存放於緩存器中,在返回。
所以在實際的應用中,put 操作一般伴隨着 get 操作,也就是說,get 操作的次數是比較多的,而且命中率也是相對比較高的,進而 put 操作的次數是比較少的,我們我們是可以考慮采用空間換時間的方式來加快我們的 get 的操作的。
例如我們可以用一個額外哈希表(例如HashMap)來存放 key-value,這樣的話,我們的 get 操作就可以在 O(1) 的時間內尋找到目標節點,並且把 value 返回了。
然而,大家想一下,用了哈希表之后,get 操作真的能夠在 O(1) 時間內完成嗎?
用了哈希表之后,雖然我們能夠在 O(1) 時間內找到目標元素,可以,我們還需要刪除該元素,並且把該元素插入到鏈表頭部啊,刪除一個元素,我們是需要定位到這個元素的前驅的,然后定位到這個元素的前驅,是需要 O(n) 時間復雜度的。
最后的結果是,用了哈希表時候,最壞時間復雜度還是 O(1),而空間復雜度也變為了 O(n)。
雙向鏈表+哈希表
我們都已經能夠在 O(1) 時間復雜度找到要刪除的節點了,之所以還得花 O(n) 時間復雜度才能刪除,主要是時間是花在了節點前驅的查找上,為了解決這個問題,其實,我們可以把單鏈表換成雙鏈表,這樣的話,我們就可以很好着解決這個問題了,而且,換成雙鏈表之后,你會發現,它要比單鏈表的操作簡單多了。
所以我們最后的方案是:雙鏈表 + 哈希表,采用這兩種數據結構的組合,我們的 get 操作就可以在 O(1) 時間復雜度內完成了。由於 put 操作我們要刪除的節點一般是尾部節點,所以我們可以用一個變量 tai 時刻記錄尾部節點的位置,這樣的話,我們的 put 操作也可以在 O(1) 時間內完成了。
具體代碼如下:
// 鏈表節點的定義
class LRUNode{
String key;
Object value;
LRUNode next;
LRUNode pre;
public LRUNode(String key, Object value) {
this.key = key;
this.value = value;
}
}
// LRU
public class LRUCache {
Map<String, LRUNode> map = new HashMap<>();
RLUNode head;
RLUNode tail;
// 緩存最大容量,我們假設最大容量大於 1,
// 當然,小於等於1的話需要多加一些判斷另行處理
int capacity;
public RLUCache(int capacity) {
this.capacity = capacity;
}
public void put(String key, Object value) {
if (head == null) {
head = new LRUNode(key, value);
tail = head;
map.put(key, head);
}
LRUNode node = map.get(key);
if (node != null) {
// 更新值
node.value = value;
// 把他從鏈表刪除並且插入到頭結點
removeAndInsert(node);
} else {
LRUNode tmp = new LRUNode(key, value);
// 如果會溢出
if (map.size() >= capacity) {
// 先把它從哈希表中刪除
map.remove(tail);
// 刪除尾部節點
tail = tail.pre;
tail.next = null;
}
map.put(key, tmp);
// 插入
tmp.next = head;
head.pre = tmp;
head = tmp;
}
}
public Object get(String key) {
LRUNode node = map.get(key);
if (node != null) {
// 把這個節點刪除並插入到頭結點
removeAndInsert(node);
return node.value;
}
return null;
}
private void removeAndInsert(LRUNode node) {
// 特殊情況先判斷,例如該節點是頭結點或是尾部節點
if (node == head) {
return;
} else if (node == tail) {
tail = node.pre;
tail.next = null;
} else {
node.pre.next = node.next;
node.next.pre = node.pre;
}
// 插入到頭結點
node.next = head;
node.pre = null;
head.pre = node;
head = node;
}
}
這里需要提醒的是,對於鏈表這種數據結構,頭結點和尾節點是兩個比較特殊的點,如果要刪除的節點是頭結點或者尾節點,我們一般要先對他們進行處理。
這里放一下單鏈表版本的吧
// 定義鏈表節點
class RLUNode{
String key;
Object value;
RLUNode next;
public RLUNode(String key, Object value) {
this.key = key;
this.value = value;
}
}
// 把名字寫錯了,把 LRU寫成了RLU
public class RLUCache {
RLUNode head;
int size = 0;// 當前大小
int capacity = 0; // 最大容量
public RLUCache(int capacity) {
this.capacity = capacity;
}
public Object get(String key) {
RLUNode cur = head;
RLUNode pre = head;// 指向要刪除節點的前驅
// 找到對應的節點,並把對應的節點放在鏈表頭部
// 先考慮特殊情況
if(head == null)
return null;
if(cur.key.equals(key))
return cur.value;
// 進行查找
cur = cur.next;
while (cur != null) {
if (cur.key.equals(key)) {
break;
}
pre = cur;
cur = cur.next;
}
// 代表沒找到了節點
if (cur == null)
return null;
// 進行刪除
pre.next = cur.next;
// 刪除之后插入頭結點
cur.next = head;
head = cur;
return cur.value;
}
public void put(String key, Object value) {
// 如果最大容量是 1,那就沒辦法了,,,,,
if (capacity == 1) {
head = new RLUNode(key, value);
}
RLUNode cur = head;
RLUNode pre = head;
// 先查看鏈表是否為空
if (head == null) {
head = new RLUNode(key, value);
return;
}
// 先查看該節點是否存在
// 第一個節點比較特殊,先進行判斷
if (head.key.equals(key)) {
head.value = value;
return;
}
cur = cur.next;
while (cur != null) {
if (cur.key.equals(key)) {
break;
}
pre = cur;
cur = cur.next;
}
// 代表要插入的節點的 key 已存在,則進行 value 的更新
// 以及把它放到第一個節點去
if (cur != null) {
cur.value = value;
pre.next = cur.next;
cur.next = head;
head = cur;
} else {
// 先創建一個節點
RLUNode tmp = new RLUNode(key, value);
// 該節點不存在,需要判斷插入后會不會溢出
if (size >= capacity) {
// 直接把最后一個節點移除
cur = head;
while (cur.next != null && cur.next.next != null) {
cur = cur.next;
}
cur.next = null;
tmp.next = head;
head = tmp;
}
}
}
}
如果要時間,強烈建議自己手動實現一波。
最后推廣下我的公眾號:苦逼的碼農:戳我即可關注,文章都會首發於我的公眾號,期待各路英雄的關注交流。