LRU 快取 | Medium | LeetCode#146. LRU Cache
題目敘述

- 題目難度:
Medium - 題目描述: 題目要求設計一套結構能夠滿足 [LRU(Least Recently Used) Cache])(https://en.wikipedia.org/wiki/Cache_replacement_policies#LRU)
題目要求實踐 LRUCache class:
LRUCache(capacity): 使用capacity初始化 LRUCache 大小int get(int key): 如果key存在,則返回對應的值,若不存在則返回 -1void put(int key, int value): 如果key存在,則更新它的值,否則添加key-valuepair 到 cache。若 key 的數量車超出capacity大小,則逐出最近最少使用的Key
解法
一開始的想法
這題因為很偏系統設計,所以剛接觸的時候反而沒太多想法,可以知道的是大多做法都是使用 doubly linked list 搭配 Hash Table 去進行實作 ,而為什麼要是這樣做,主要是因為這個結構在搜尋特定的 key 是否存在這樣可以達到 $O(1)$ 的時間複雜度,而如果用 Queue 或是Stack 這種只能單向進出的結構,會讓搜尋時的複雜度提高到 $O(n)$

我的解法
1 |
|
這裡先定義用於作爲 Cache 結構的 doubly-linked list,需要先宣告 Node 的結構,這裡定義了一個 Node class,首先需要定義用於保存資料本身的 key 與 value ,接著定義指標,每個節點都需要指向其前一個節點與後一個節點,因此需要 *prev 和 *next。 然後就用 constructor 去定義當物件被初始化後的行為。
1 | class Node { |
接著就需要定義 LRUCache class 內部的實作,需要先定義出 Cache 的雛形,以下是變數說明
int capacity 為 Cache 的容量大小
Node *front, *tail, *currentPtr 宣告指標用來指向 doubly-linked list 當中的頭部,尾端,以及當前節點 *currentPtr
unordered_map<int, Node*> cacheMap 用來儲存 key-node pair,可以用來判斷資料是否有存取過
結著需要定義 Doublylinked list 的行為, LRU Cache 的特性是最常使用的的資料會被移動到 Front(Head) 而最少被使用的資料會移動到 Tail,當資料數量超出快取容量時,Tail會被逐出 ,所以對應這個特性我們定義出兩個函數 moveToFront 以及 removeTail ,這裡介紹這兩個函數:
1 | void moveToFront(Node* node){ |
首先排除了單一節點的狀況,之後如果要將一個節點從一個 doubly-linked list 移動到他的頭部,需要將他上一個節點的 next 接到當前節點的下一個節點 (node->prev->next = node->next),另外當前節點的下一個節點的 prev 需要接道當前節點的上一個節點 (node->next->prev = node->prev)。而如果當前節點正好就是 tail 那就將 tail 指標 update 成前一個節點。

接著要去將當前節點移動到頭部,這時需要將當前節點的 prev指向 null 並且當前節點的 next 要先指到當前 front 節點,而 front 節點的 prev 會是我們新節點,最後再更當前節點為新的 front
1 | void removeTail(){ |
removeTail 的部分,一樣如果當前沒有 tail 則返回,而移除步驟要先將 Hash Table 中對應的 TailEntry 移除,可以透過 unordered_map 中的 erase 方法來實現。而當接著要處理鏈結的部分,如果並非單一節點,則 tail-prev 存在,這樣就需要將當前的 tail 節點的前一個節點更新為新的 tail (tail = tail->prev) 而新的 tail節點的 next 就會是 nullptr 而如果是單一節點的狀況,那 front 與 tail 都會被移除,因此直接指定為 nullptr
定義好 Cache 本身的更新行為後,可以來定義客戶端呼叫Cache 可以用的方法 get 和 put
1 | int get(int key) { |
get 行為會先去從 Cache 中查看對應的值是否存在,這就是查找Hash Table cacheMap 如果有找到值,就透過一個指標 *node 指向該值在Table 中保存的記憶體位址 (Node *node =cacheMap[key]),由於每次存取,節點都需要移動到 front,因此呼叫 moveToFront 方法,並且返回該 node 的 value 而若是沒在 cache 中找到為應資料就回傳 -1。
1 | void put(int key, int value) { |
put 的部分,如果要insert資料,但是對應的資料已經存在於 Cache 中,則一樣需要將該值進行更新,這裡一樣宣告一個指標來存取該節點 (Node *node = cacheMap[key]) 並且更新該節點的值 (node->value = value)然後因為有存取行為,所以還是要將該節點移動到 front (moveToFront(node))。
然而如果要插入的Key-value pair 不存在於 cache中,就需要去建立新的節點 (Node *newData = new Node(key, value)) 然而再插入節點前需要先檢查Cache 容量是否足夠,一旦到達容量上限就需要去騰出空間,需要將最少使用的資料逐出 (removeTail();),另一種狀況是今天Cache 是空的,要被插入的資料會是第一筆資料,這時候就需要將 front 跟 tail 更新為 newData 其餘多節點的狀況就比較單純了,需要將新資料差入到 front ,所以新資料的 next 需要指向當前得 front 然後當前 front 的前一個節點要指向新插入資料 ( front->prev = newData;) 最後再將新資料更新為新的 front 。此外也需要將將 Hash Table 添加新資料 cacheMap[key] = newData;。
這樣就完成了一套基本的 LRU 系統
執行結果

複雜度
| 方法 | 時間複雜度 | 空間複雜度 | 複雜度分析說明 |
|---|---|---|---|
get |
$O(1)$ | $O(1)$ | 使用Hash Table(unordered_map)透過鍵直接存取對應節點,實現常數時間的操作 |
put |
$O(1)$ | $O(1)$ | 新增與刪除操作均透過哈希表與雙向鏈表完成,每次插入或刪除節點的操作時間為常數級別 |
| 總體 | $O(1)$ | $O(n)$ | Hash Table儲存鍵值對,雙向鏈表用於維護最近使用順序,空間複雜度與緩存容量成正比 |










