JavaScript實現LRU緩存的三種方式詳解

LRU全稱為Least Recently Used,即最近使用的。針對的是在有限的內存空間內,隻緩存最近使用的數據(即get和set的數據),超過有限內存空間的數據將會被刪除。這個在面試題中也是常會被問到的內容,接下來就看看怎麼來實現。

分析

從定義來看,LRU至少有兩個特性:通過鍵值對讀寫、有序。實現鍵值對讀寫,一般我們會使用哈希表來表示,註意哈希表是一個邏輯結構,實際上我們需要使用objectmap等來實現;數據有序,即最近使用的數據放在前面,已經過時的數據放在後面或被刪除,並且支持數據是可排序的,我們可以想到數組、鏈表、map之類的數據格式。因此,我們有三種方式可以實現LRU緩存:

  • Map
  • Object + Array
  • LinkedList

使用Map實現LRU緩存

Map對象保存的是鍵值對,並且可以記住鍵的原始插入順序。

const map = new Map();
map.set(2, 2);
map.set(1, 2);
console.log(map); // Map(2) {2 => 2, 1 => 2},按照原始的插入順序
const obj = new Object();
obj[2] = 2;
obj[1] = 1
console.log(obj); // {1: 1, 2: 2},不會按照原始的插入順序

那麼我們就可以根據Map的特性實現LRU,下面是原理圖:

實現代碼:

class LRUCache {
    data = new Map(); // 數據map
    constructor(length) {
    if (length < 1) throw new Error('長度不能小於1')
        this.length = length
    }

    set(key, value) {
        const data = this.data;
        // 如果存在該對象,則直接刪除
        if (data.has(key)) {
            data.delete(key);
        }
        // 將數據對象添加到map中
        data.set(key, value);
        if (data.size > this.length) {
            // 如果map長度超過最大值,則取出map中的第一個元素,然後刪除
            const delKey = data.keys().next().value
            data.delete(delKey);
        }
    }
    get(key) {
        const data = this.data;
        // 數據map中沒有key對應的數據,則返回null
        if (!data.has(key)) return null;
        const value = data.get(key);
        // 返回數據前,先刪除原數據,然後在添加,就可以保持在最新
        data.delete(key);
        data.set(key, value);
        return value;
    }
}

測試一下:

const lruCache = new LRUCache(2);
lruCache.set('1', 1); // Map(1) {1 => 1}
lruCache.set('2',2); // Map(2) {1 => 1, 2 => 2}
console.log(lruCache.get('1')); // Map(2) {2 => 2, 1 => 1}
lruCache.set('3',3); // Map(2) {1 => 1, 3 => 3}
console.log(lruCache.get('2')); // null
lruCache.set('4',4); // Map(2) {3 => 3, 4 => 4}
console.log(lruCache.get('1')); // null
console.log(lruCache.get('3')); // Map(2) {4 => 4, 3 => 3}
console.log(lruCache.get('4')); // Map(2) {3 => 3, 4 => 4}

運行結果:

使用Map基本上就可以實現LRU緩存,並且其兼容性還可以,除瞭IE兼容性差點:

如果不使用Map,那麼還可以使用什麼方式實現LRU緩存呢?

使用Object + Array實現LRU緩存

剛剛我們也分析瞭,LRU需要滿足兩點:鍵值對和可排序,這兩點可以分別對應到ObjectArray,那麼我們是不是就可以以此為思路實現呢?答案是可以的,實現代碼:

class LRUCacheObjArr {
    length = 0; // 定義列表最大長度
    dataObj = {}; // 使用對象暫存數據,在可保證get時間復雜度為O(1)
    dataArr = []; // 使用數組解決有序的問題
    constructor(length) {
        if (length < 1) throw new Error('參數非法')
        this.length = length;
    }
    get(key) {
        if (!this.dataObj[key] || !this.dataArr.length) return null;
        // 需要將訪問到的值,重新放在數組的最末尾,表示最新的數據
        const index = this.dataArr.findIndex(item => item.key === key);
        // 先刪除原數據,然後push到數組末尾
        this.dataArr.splice(index, 1);
        this.dataArr.push(this.dataObj[key]);
        // 返回值,對象是無序的,我們隻需要保證裡面的數據是最新的即可
        return this.dataObj[key].value;
    }
    set(key, value) {
        // 定義對象數據
        const obj = {key, value};
        const index = this.dataArr.findIndex(item => item.key === key);
        // 判斷是否為新增還是修改
        if (index !== -1) {
            // 如果已存在數據,則先刪除,然後push到末尾
            this.dataArr.splice(index, 1);
            this.dataArr.push(obj);
        } else {
            // 如果不存在數據,則數組直接push
            this.dataArr.push(obj);
        }
        // 對象新增或者修改同一個對象
        this.dataObj[key] = obj;
        // 判斷新增數據後,是否超過最大長度
        if (this.dataArr.length > this.length) {
            // 數組是有序的,超長後直接從頭部刪除,並且獲取刪除的數據
            const tmp = this.dataArr.shift();
            // 數據對象裡面也應該刪除當前刪除的對象,避免被再次取到
            delete this.dataObj[tmp.key];
        }
    }
}

我們使用同樣的測試案例測試一下:

const lruCache = new LRUCacheObjArr(2);
lruCache.set('1', 1); // dataArr(1) [obj1], dataObj(1) {'1': obj1}
lruCache.set('2',2); // dataArr(2) [obj1,  obj2], dataObj(2) {'1': obj1, '2': obj2}
console.log(lruCache.get('1')); // dataArr(2) [obj2,  obj1], dataObj(2) {'1': obj1, '2': obj2}
lruCache.set('3',3); // dataArr(2) [obj1,  obj3], dataObj(2) {'1': obj1, '3': obj3}
console.log(lruCache.get('2')); // null
lruCache.set('4',4); // dataArr(2) [obj3,  obj4], dataObj(2) {'3': obj3, '4': obj4}
console.log(lruCache.get('1')); // null
console.log(lruCache.get('3')); // dataArr(2) [obj4,  obj3], dataObj(2) {'3': obj3, '4': obj4}
console.log(lruCache.get('4')); // dataArr(2) [obj3,  obj4], dataObj(2) {'3': obj3, '4': obj4}

運行結果:

使用對象+數組的方式,雖然可以實現效果,但是由於會頻繁操作數組,因此會犧牲一些性能,而且實現起來也沒有Map方便。除瞭使用數組+對象,其實我們還可以使用雙向鏈表的方式實現LRU。

使用雙向鏈表實現LRU

雙向鏈表,顧名思義就是兩個方向的鏈表,這有別於單向鏈表。直接看圖可能更直觀一點:

雙向鏈表在移動元素時,會比單向鏈表復雜一點,,例如我們想把B和C節點交換一下位置,其過程如下:

這對應到LRU有什麼意義呢?雙向鏈表是有序的,每一個節點都知道其上一個或者下一個節點;其存值的方式也是使用鍵值對的方式,因此完全可以實現LRU。

實現代碼:

  class LRUCacheLinked {
      data = {}; // 鏈表數據
      dataLength = 0; // 鏈表長度,使用變量保存,可以更快訪問
      listHead = null; // 鏈表頭部
      listTail = null; // 鏈表尾部
      length = 0; // 鏈表最大長度
      // 構造函數
      constructor(length) {
        if (length < 1) throw new Error('參數不合法')
        this.length = length;
      }
      set(key, value) {
        const curNode = this.data[key];
        if (curNode == null) {
          // 新增數據
          const nodeNew = {key, value};
          // 移動到末尾
          this.moveToTail(nodeNew);
          // 將新增的節點保存到數據對象中,其pre或next將在moveToTail中設置
          this.data[key] = nodeNew;
          // 鏈表長度遞增
          this.dataLength++;
          // 初始化鏈表頭部
          if (this.dataLength === 1) this.listHead = nodeNew;
        } else {
          // 修改現有數據
          curNode.value = value;
          // 移動到末尾
          this.moveToTail(curNode);
        }
        // 添加完數據後可能會導致超出長度,因此嘗試著刪除數據
        this.tryClean();
      }

      get(key) {
        // 根據key值取出對應的節點
        const curNode = this.data[key];
        if (curNode == null) return null;
        if (this.listTail === curNode) {
          // 如果被訪問的元素處於鏈表末尾,則直接返回值,且不用修改鏈表
          return curNode.value;
        }
        // 如果是中間元素或者頭部元素,則在獲取前需要將其移動到鏈表尾部,表示最新
        this.moveToTail(curNode);
        return curNode.value;
      }
      // 移動節點到鏈表尾部
      moveToTail(curNode) {
        // 獲取鏈表尾部
        const tail = this.listTail;
        // 如果當前節點就是鏈表尾部,則不做處理,直接返回
        if (tail === curNode) return;
        // 1. preNode和nextNode斷絕與curNode之間的關系
        const preNode = curNode.pre;
        const nextNode = curNode.next;
        if (preNode) {
          if (nextNode) {
            // 當前元素的上一個節點指向其下一個
            preNode.next = nextNode;
          } else {
            // 斷開當前元素與上一個節點的聯系
            delete preNode.next;
          }
        }
        if (nextNode) {
          if (preNode) {
            // 當前元素的下一個節點指向其上一個
            nextNode.pre = preNode;
          } else {
            // 斷開當前元素與下一個節點的關系
            delete nextNode.pre;
          }
          // 如果當前節點是鏈表頭部,則需要重新賦值
          if (this.listHead === curNode) this.listHead = nextNode;
        }

        // 2. curNode斷絕與preNode和nextNode之間的關系
        delete curNode.pre
        delete curNode.next

        // 3. 在list末尾,重新建立curNode的新關系
        if (tail) {
          tail.next = curNode;
          curNode.pre = tail;
        }
        // 重新賦值鏈表尾部,保持最新
        this.listTail = curNode;
      }
      tryClean() {
        while(this.dataLength > this.length) {
          const head = this.listHead;
          if (head == null) throw new Error('鏈表缺少頭部');
          const headNext = head.next;
          if (headNext == null) throw new Error('鏈表頭部缺失下一個節點');
          // 1. 斷絕head和headNext之間的關系
          delete head.next;
          delete headNext.pre;
          // 2. 重新賦值listHead
          this.listHead = headNext;
          // 3. 清理data
          delete this.data[head.key];
          // 4. 重新計數
          this.dataLength = this.dataLength - 1;
        }
      }
    }

這麼一看,雙向鏈表是這三種方式中最復雜的一個。我們使用同樣的案例測試一下:

const lruCache = new LRUCacheLinked(2);
lruCache.set('1', 1);
lruCache.set('2',2);
console.log(lruCache.get('1'));
lruCache.set('3',3);
console.log(lruCache.get('2'));
lruCache.set('4',4);
console.log(lruCache.get('1'));
console.log(lruCache.get('3'));
console.log(lruCache.get('4'));

實現結果:

總結

本文總結瞭三種實現LRU緩存的方法,其中使用Map是最佳的方式。使用其他兩種方式,雖然可以實現效果,但是從效率、可讀性上來看,還是Map更勝一籌。這三種方式你都學會瞭嗎?

到此這篇關於JavaScript實現LRU緩存的三種方式詳解的文章就介紹到這瞭,更多相關JavaScript LRU緩存內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: