演算法-00| Cache快取淘汰演算法
Cache快取
①.記憶
②.錢包-儲物櫃
③.程式碼模組
一個經典的連結串列應用場景,那就是 LRU 快取淘汰演算法。
快取是一種提高資料讀取效能的技術,在硬體設計、軟體開發中都有著非常廣泛的應用,比如常見的 CPU 快取、資料庫快取、瀏覽器快取等等。
快取的大小有限,當快取被用滿時,哪些資料應該被清理出去,哪些資料應該被保留?這就需要快取淘汰策略來決定。
常見的策略有三種:
- 先進先出策略 FIFO(First In,First Out)
- 少使 用策略 LFU(Least Frequently Used)
- 近少使用策略 LRU(Least Recently Used)
打個比方說,你買了很多本技術書,但發現這些書太多,太佔書房空間,需要個大掃除,扔掉一些書籍。你會選擇扔掉哪些書呢?對應一下,你的選擇標準是不是和上面的三種策略神似。
1. CPUSocket
Understanding the Meltdown exploit – in my own simple words
上圖為四核CPU,三級快取(L1 Cache,L2 Cache ,L3 Cache);
每一個核裡邊就有L1 D-Cache,L1 l-Cache,L2 Cache,L3 Cache;最常用的資料馬上要給CPU的計算模組進行計算處理的就放在L1裡邊,次之不常用的就放在L1 l-Cache裡邊,再次之放在L2Cache裡邊,最後放在L3 Cache裡邊。外邊即記憶體。他們的速度 L1 D-Cache >L1 l-Cache >L2-Cache >L3-Cache
體積(能存的資料多少)即L1 D-Cache <L1 l-Cache < L2-Cache < L3-Cache
2. LRUCache
兩個要素:
大小
替換策略(least recent use -- 最近最少使用)
實現機制:
HashTable+DoubleLinkedList
複雜度分析:
O(1)查詢
O(1)修改、 更新
LRU Cache工作示例:
更新原則least recent use
替換策略:
LFU - least frequently used
LRU - least recently used
如何基於連結串列實現 LRU 快取淘汰演算法?
維護一個有序單鏈表,越靠近連結串列尾部的結點是越早之前訪問的。當有一個新的資料被訪問時,從連結串列頭開始順序遍歷連結串列。
①. 如果此資料之前已經被快取在連結串列中了,遍歷得到這個資料對應的結點,並將其從原來的位置刪除,然後再插入到連結串列的頭部。
②. 如果此資料沒有在快取連結串列中,又可以分為兩種情況:
- 如果此時快取未滿,則將此結點直接插入到連結串列的頭部;
- 如果此時快取已滿,則連結串列尾結點刪除,將新的資料結點插入連結串列的頭部。
這樣我們就用連結串列實現了一個 LRU 快取。
m 快取訪問的時間複雜度是多少。因為不管快取有沒有滿,都需要遍歷一遍連結串列,所以這種基於連結串列的實現思路,快取訪問的時間複雜度為 O(n)。
可以繼續優化這個實現思路,比如引入散列表(Hash table)來記錄每個資料的位置,將快取訪問的時間複雜度降到 O(1)。
LRUCache Python
class LRUCache(object):
def __init__(self, capacity):
self.dic = collections.OrderedDict()
self.remain = capacity
def get(self, key):
if key not in self.dic:
return -1
v = self.dic.pop(key)
self.dic[key] = v # key as the newest one
return v
def put(self, key, value):
if key in
self.dic: self.dic.pop(key)
else:
if self.remain > 0:
self.remain -= 1
else: # self.dic is full
self.dic.popitem(last=False)
self.dic[key] = value
LRUCache Java
private Map<Integer, Integer> map;
public LRUCache(int capacity) {
map = new LinkedCappedHashMap<>(capacity);
}
public int get(int key) {
if (!map.containsKey(key)) {
return -1;
}
return map.get(key);
}
public void put(int key, int value) {
map.put(key, value);
}
private static class LinkedCappedHashMap<K, V> extends LinkedHashMap<K, V> {
int maximumCapacity;
LinkedCappedHashMap(int maximumCapacity) {
// initialCapacity代表map的容量, loadFactor代表載入因子, accessOrder預設false,如果要按讀取順序排序需要將其設為true
super(16, 0.75f, true);//default initial capacity (16) and load factor (0.75) and accessOrder (false)
this.maximumCapacity = maximumCapacity;
}
/* 重寫 removeEldestEntry()函式,就能擁有我們自己的快取策略 */
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > maximumCapacity;
}
}
3.連結串列和散列表的組合使用
藉助散列表,我們可以把 LRU 快取淘汰演算法的時間複雜度降低為 O(1)
如何通過連結串列實現 LRU 快取淘汰演算法的。
我們需要維護一個按照訪問時間從大到小有序排列的連結串列結構。因為快取大小有限,當快取空間 不夠,需要淘汰一個數據的時候,我們就直接將連結串列頭部的結點刪除。
當要快取某個資料的時候,先在連結串列中查詢這個資料。如果沒有找到,則直接將資料放到連結串列的 尾部;如果找到了,我們就把它移動到連結串列的尾部。因為查詢資料需要遍歷連結串列,所以單純用鏈 表實現的 LRU 緩
存淘汰演算法的時間複雜很高,是 O(n)。
實際上,我總結一下,一個快取(cache)系統主要包含下面這幾個操作:
- 往快取中新增一個數據;
- 從快取中刪除一個數據;
- 在快取中查詢一個數據。
這三個操作都要涉及“查詢”操作,如果單純地採用連結串列的話,時間複雜度只能是 O(n)。如果將散列表和連結串列兩種資料結構組合使用,可以將這三個操作的時間複雜度都降低到 O(1)。
具體結構如下:
使用雙向連結串列儲存資料,連結串列中的每個結點處理儲存資料(data)、前驅指標(prev)、 後繼指標(next)之外,還新增了一個特殊的欄位 hnext。這個 hnext 有什麼作用呢?
因為我們的散列表是通過連結串列法解決雜湊衝突的,所以每個結點會在兩條鏈中。一個鏈是剛剛我 們提到的雙向連結串列,另一個鏈是散列表中的拉鍊。前驅和後繼指標是為了將結點串在雙向連結串列 中,hnext 指標是
為了將結點串在散列表的拉鍊中。