1. 程式人生 > 其它 >設計資料結構-LRU快取演算法

設計資料結構-LRU快取演算法

LRU快取演算法

LRU快取演算法

力扣第 146 題「LRU快取機制」就是讓你設計資料結構:

首先要接收一個 capacity 引數作為快取的最大容量,然後實現兩個 API,一個是 put(key, val) 方法存入鍵值對,另一個是 get(key) 方法獲取 key 對應的 val,如果 key 不存在則返回 -1。

getput 方法必須都是 O(1) 的時間複雜度

要讓 putget 方法的時間複雜度為 O(1),我們可以總結出 cache 這個資料結構必要的條件:

  • cache 中的元素必須有時序,以區分最近使用的和久未使用的資料,當容量滿了之後要刪除最久未使用的那個元素騰位置。
  • 我們要在 cache
    中快速找某個 key 是否已存在並得到對應的 val
  • 每次訪問 cache 中的某個 key,需要將這個元素變為最近使用的,也就是說 cache 要支援在任意位置快速插入和刪除元素。

雜湊表查詢快,但是資料無固定順序;連結串列有順序之分,插入刪除快,但是查詢慢。所以結合一下,形成一種新的資料結構:雜湊連結串列 LinkedHashMap

LRU 快取演算法的核心資料結構就是雜湊連結串列,雙向連結串列和雜湊表的結合體。這個資料結構長這樣:

來逐一分析上面的 3 個條件:

  • 如果我們每次預設從連結串列尾部新增元素,那麼顯然越靠尾部的元素就是最近使用的,越靠頭部的元素就是最久未使用的。
  • 對於某一個 key
    ,我們可以通過雜湊錶快速定位到連結串列中的節點,從而取得對應 val
  • 連結串列顯然是支援在任意位置快速插入和刪除的,改改指標就行。只不過傳統的連結串列無法按照索引快速訪問某一個位置的元素,而這裡藉助雜湊表,可以通過 key 快速對映到任意一個連結串列節點,然後進行插入和刪除。

這裡可以通過hashmap的key來快速找到對應的node節點,所以就能快速的插入刪除了。

先自己造輪子實現一遍 LRU 演算法

首先,把雙鏈表的節點類寫出來:

class Node {

    public int key, val;

    public Node next, prev;

    public Node(int k, int v) {

    this.key = k;

    this.val = v;

    }

}

然後依靠我們的 Node 型別構建一個雙鏈表,實現幾個 LRU 演算法必須的 API:

class DoubleList {

    // 頭尾虛節點

    private Node head, tail;

    // 連結串列元素數

    private int size;



    public DoubleList() {

    // 初始化雙向連結串列的資料

    head = new Node(0, 0);

    tail = new Node(0, 0);

    head.next = tail;

    tail.prev = head;

    size = 0;

    }



    // 在連結串列尾部新增節點 x,時間 O(1)

    public void addLast(Node x) {

    x.prev = tail.prev;

    x.next = tail;

    tail.prev.next = x;

    tail.prev = x;

    size++;

    }



    // 刪除連結串列中的 x 節點(x 一定存在)

    // 由於是雙鏈表且給的是目標 Node 節點,時間 O(1)

    public void remove(Node x) {

    x.prev.next = x.next;

    x.next.prev = x.prev;

    size--;

    }



    // 刪除連結串列中第一個節點,並返回該節點,時間 O(1)

    public Node removeFirst() {

    if (head.next == tail)

      return null;

    Node first = head.next;

    remove(first);

    return first;

    }



    // 返回連結串列長度,時間 O(1)

    public int size() { return size; }



}

到這裡就能回答剛才「為什麼必須要用雙向連結串列」的問題了,因為我們需要刪除操作。刪除一個節點不光要得到該節點本身的指標,也需要操作其前驅節點的指標,而雙向連結串列才能支援直接查詢前驅,保證操作的時間複雜度 O(1)。

有了雙向連結串列的實現,我們只需要在 LRU 演算法中把它和雜湊表結合起來即可,先搭出程式碼框架:

class LRUCache {

    // key -> Node(key, val)

    private HashMap<Integer, Node> map;

    // Node(k1, v1) <-> Node(k2, v2)...

    private DoubleList cache;

    // 最大容量

    private int cap;



    public LRUCache(int capacity) {

    this.cap = capacity;

    map = new HashMap<>();

    cache = new DoubleList();

    }

由於我們要同時維護一個雙鏈表 cache 和一個雜湊表 map,很容易漏掉一些操作,比如說刪除某個 key 時,在 cache 中刪除了對應的 Node,但是卻忘記在 map 中刪除 key

解決這種問題的有效方法是:在這兩種資料結構之上提供一層抽象 API

儘量讓 LRU 的主方法 getput 避免直接操作 mapcache 的細節。我們可以先實現下面幾個函式:

/* 將某個 key 提升為最近使用的 */

private void makeRecently(int key) {

    Node x = map.get(key);

    // 先從連結串列中刪除這個節點

    cache.remove(x);

    // 重新插到隊尾

    cache.addLast(x);

}



/* 新增最近使用的元素 */

private void addRecently(int key, int val) {

    Node x = new Node(key, val);

    // 連結串列尾部就是最近使用的元素

    cache.addLast(x);

    // 別忘了在 map 中新增 key 的對映

    map.put(key, x);

}



/* 刪除某一個 key */

private void deleteKey(int key) {

    Node x = map.get(key);

    // 從連結串列中刪除

    cache.remove(x);

    // 從 map 中刪除

    map.remove(key);

}



/* 刪除最久未使用的元素 */

private void removeLeastRecently() {

    // 連結串列頭部的第一個元素就是最久未使用的

    Node deletedNode = cache.removeFirst();

    // 同時別忘了從 map 中刪除它的 key

    int deletedKey = deletedNode.key;

    map.remove(deletedKey);

}

這裡就能回答之前的問答題「為什麼要在連結串列中同時儲存 key 和 val,而不是隻儲存 val」,注意 removeLeastRecently 函式中,我們需要用 deletedNode 得到 deletedKey

先來實現 LRU 演算法的 get 方法:

    public int get(int key) {

        if (!map.containsKey(key)) {

    return -1;

        }

        // 將該資料提升為最近使用的

        makeRecently(key);

        return map.get(key).val;

    }

put 方法稍微複雜一些,我們先來畫個圖搞清楚它的邏輯:

寫出 put 方法的程式碼:

    public void put(int key, int val) {

        if (map.containsKey(key)) {

    // 刪除舊的資料

    deleteKey(key);

    // 新插入的資料為最近使用的資料

    addRecently(key, val);

    return;

        }



        if (cap == cache.size()) {

    // 刪除最久未使用的元素

    removeLeastRecently();

        }

        // 新增為最近使用的元素

        addRecently(key, val);

    }

    最後用 Java 的內建型別 `LinkedHashMap` 來實現 LRU 演算法,邏輯和之前完全一致:

class LRUCache {

    int cap;

    LinkedHashMap<Integer, Integer> cache = new LinkedHashMap<>();

    public LRUCache(int capacity) { 

    this.cap = capacity;

    }



    public int get(int key) {

    if (!cache.containsKey(key)) {

      return -1;

    }

    // 將 key 變為最近使用

    makeRecently(key);

    return cache.get(key);

    }



    public void put(int key, int val) {

    if (cache.containsKey(key)) {

      // 修改 key 的值

      cache.put(key, val);

      // 將 key 變為最近使用

      makeRecently(key);

      return;

    }



    if (cache.size() >= this.cap) {

      // 連結串列頭部就是最久未使用的 key

      int oldestKey = cache.keySet().iterator().next();

      cache.remove(oldestKey);

    }

    // 將新的 key 新增連結串列尾部

    cache.put(key, val);

    }



    private void makeRecently(int key) {

    int val = cache.get(key);

    // 刪除 key,重新插入到隊尾

    cache.remove(key);

    cache.put(key, val);

    }

}