什麼是 LRU 演算法?

Shenfq發表於2022-03-14

快取 是我們寫程式碼過程中常用的一種手段,是一種空間換時間的做法。就拿我們經常使用的 HTTP 協議,其中也存在強快取和協商快取兩種快取方式。當我們開啟一個網站的時候,瀏覽器會查詢該請求的響應頭,通過判斷響應頭中是否有 Cache-ControlLast-ModifiedETag 等欄位,來確定是否直接使用之前下載的資源快取,而不是重新從伺服器進行下載。

下面就是當我們訪問百度時,某些資源命中了協商快取,服務端返回 304 狀態碼,還有一部分資源命中了強快取,直接讀取了本地快取。

但是,快取並不是無限制的,會有大小的限制。無論是我們的 cookie(不同瀏覽器有所區別,一般在 4KB 左右),還是 localStorage(和 cookie 一樣,不同瀏覽器有所區別,有些瀏覽器為 5MB,有些瀏覽器為 10MB),都會有大小限制。

這個時候就需要涉及到一種演算法,需要將超出大小限制的快取進行淘汰,一般的規則是淘汰掉最近沒有被訪問到的快取,也就是今天要介紹的主角:LRULeast recently used:最近最少使用)。當然除了 LRU,常見的快取淘汰還有 FIFO(first-in, first-out:先進先出) 和 LFU(Least frequently used:最少使用)。

什麼是 LRU?

LRULeast recently used:最近最少使用)演算法在快取寫滿的時候,會根據所有資料的訪問記錄,淘汰掉未來被訪問機率最低的資料。也就是說該演算法認為,最近被訪問過的資料,在將來被訪問的機率最大。

為了方便理解 LRU 演算法的全流程,畫了一個簡單的圖:

  1. 假設我們有一塊記憶體,一共能夠儲存 5 資料塊;
  2. 依次向記憶體存入A、B、C、D、E,此時記憶體已經存滿;
  3. 再次插入新的資料時,會將在記憶體存放時間最久的資料A淘汰掉;
  4. 當我們在外部再次讀取資料B時,已經處於末尾的B會被標記為活躍狀態,提到頭部,資料C就變成了存放時間最久的資料;
  5. 再次插入新的資料G,存放時間最久的資料C就會被淘汰掉;

演算法實現

下面通過一段簡單的程式碼來實現這個邏輯。

class LRUCache {
    list = [] // 用於標記先後順序
    cache = {} // 用於快取所有資料
    capacity = 0 // 快取的最大容量
    constructor (capacity) {
    // 儲存 LRU 可快取的最大容量
        this.capacity = capacity
    }
}

基本的結構如上所示,LRU需要實現的就是兩個方法:getput

class LRUCache {
  // 獲取資料
    get (key) { }
  // 儲存資料
    put (key, value) { }
}

我們現在看看如何進行資料的儲存:

class LRUCache {
  // 儲存資料
    put (key, value) {
    // 儲存之前需要先判斷長度是否達到上限
    if (this.list.length >= this.capacity) {
      // 由於每次儲存後,都會將 key 放入 list 最後,
      // 所以,需要取出第一個 key,並刪除cache中的資料。
            const latest = this.list.shift()
            delete this.cache[latest]
        }
    // 寫入快取
        this.cache[key] = value
    // 寫入快取後,需要將 key 放入 list 的最後
        this.list.push(key)
  }
}

然後,在每次獲取資料時,都需要更新 list,將當前獲取的 key 放到 list 的最後。

class LRUCache {
  // 獲取資料
    get (key) {
        if (this.cache[key] !== undefined) {
        // 如果 key 對應的快取存在
      // 在返回快取之前,需要重新啟用 key
            this.active(key)
            return this.cache[key]
        }
        return undefined
  }
  // 重新啟用key,將指定 key 移動到 list 最後
    active (key) {
    // 先將 key 在 list 中刪除
        const idx = this.list.indexOf(key)
        if (idx !== -1) {
            this.list.splice(idx, 1)
    }
    // 然後將 key 放到 list 最後面
        this.list.push(key)
    }
}

這個時候,其實還沒有完全實現,因為除了 get 操作,put 操作也需要將對應的 key 重新啟用。

class LRUCache {
  // 儲存資料
    put (key, value) {
        if (this.cache[key]) {
            // 如果該 key 之前存在,將 key 重新啟用
            this.active(key)
            this.cache[key] = value
      // 而且此時快取的長度不會發生變化
      // 所以不需要進行後續的長度判斷,可以直接返回
            return
        }

    // 儲存之前需要先判斷長度是否達到上限
    if (this.list.length >= this.capacity) {
      // 由於每次儲存後,都會將 key 放入 list 最後,
      // 所以,需要取出第一個 key,並刪除cache中的資料。
            const latest = this.list.shift()
            delete this.cache[latest]
        }
    // 寫入快取
        this.cache[key] = value
    // 寫入快取後,需要將 key 放入 list 的最後
        this.list.push(key)
  }
}

可能會有人覺得這種演算法在前端沒有什麼應用場景,說起來,在 Vue 的內建元件 keep-alive 中就使用到了 LRU 演算法。

後續應該還會繼續介紹一下 LFU 演算法,敬請期待……

相關文章