1. 程式人生 > 實用技巧 >演算法-00| Cache快取淘汰演算法

演算法-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 指標是

為了將結點串在散列表的拉鍊中。