面試掛在了 LRU 快取演算法設計上

帥地發表於2019-05-29

好吧,有人可能覺得我標題黨了,但我想告訴你們的是,前陣子面試確實掛在了 RLU 快取演算法的設計上了。當時做題的時候,自己想的太多了,感覺設計一個 LRU(Least recently used) 快取演算法,不會這麼簡單啊,於是理解錯了題意(我也是服了,還能理解成這樣,,,,),自己一波操作寫了好多程式碼,後來卡住了,再去仔細看題,發現自己應該是理解錯了,就是這麼簡單,設計一個 LRU 快取演算法。

不過這時時間就很緊了,按道理如果你真的對這個演算法很熟,十分鐘就能寫出來了,但是,自己雖然理解 LRU 快取演算法的思想,也知道具體步驟,但之前卻從來沒有去動手寫過,導致在寫的時候,非常不熟練,也就是說,你感覺自己會 和你能夠用程式碼完美著寫出來是完全不是一回事,所以在此提醒各位,如果可以,一定要自己用程式碼實現一遍自己自以為會的東西。千萬不要覺得自己理解了思想,就不用去寫程式碼了,獨自擼一遍程式碼,才是真的理解了。

今天我帶大家用程式碼來實現一遍 LRU 快取演算法,以後你在遇到這型別的題,保證你完美秒殺它。

題目描述

設計並實現最不經常使用(LFU)快取的資料結構。它應該支援以下操作:get 和 put。

get(key) - 如果鍵存在於快取中,則獲取鍵的值(總是正數),否則返回 -1。

put(key, value) - 如果鍵不存在,請設定或插入值。當快取達到其容量時,它應該在插入新專案之前, 使最不經常使用的專案無效。在此問題中,當存在平局(即兩個或更多個鍵具有相同使用頻率)時, 最近最少使用的鍵將被去除。

進階:

你是否可以在 O(1) 時間複雜度內執行兩項操作?

示例:

LFUCache cache = new LFUCache( 2 /* capacity (快取容量) */ );



cache.put(1, 1);

cache.put(2, 2);

cache.get(1);       // 返回 1

cache.put(3, 3);    // 去除 key 2

cache.get(2);       // 返回 -1 (未找到key 2)

cache.get(3);       // 返回 3

cache.put(4, 4);    // 去除 key 1

cache.get(1);       // 返回 -1 (未找到 key 1)

cache.get(3);       // 返回 3

cache.get(4);       // 返回 4
複製程式碼

基礎版:單連結串列來解決

我們要刪的是最近最少使用的節點,一種比較容易想到的方法就是使用單連結串列這種資料結構來儲存了。當我們進行 put 操作的時候,會出現以下幾種情況:

1、如果要 put(key,value) 已經存在於連結串列之中了(根據key來判斷),那麼我們需要把連結串列中久的資料刪除,然後把新的資料插入到連結串列的頭部。、

2、如果要 put(key,value) 的資料沒有存在於連結串列之後,我們我們需要判斷下快取區是否已滿,如果滿的話,則把連結串列尾部的節點刪除,之後把新的資料插入到連結串列頭部。如果沒有滿的話,直接把資料插入連結串列頭部即可。

對於 get 操作,則會出現以下情況

1、如果要 get(key) 的資料存在於連結串列中,則把 value 返回,並且把該節點刪除,刪除之後把它插入到連結串列的頭部。

2、如果要 get(key) 的資料不存在於連結串列之後,則直接返回 -1 即可。

大概的思路就是這樣,不要覺得很簡單,讓你手寫的話,十分鐘你不一定手寫的出來。具體的程式碼,為了不影響閱讀,我在文章的最後面在放出來。

時間、空間複雜度分析

對於這種方法,put 和 get 都需要遍歷連結串列查詢資料是否存在,所以時間複雜度為 O(n)。空間複雜度為 O(1)。

空間換時間

在實際的應用中,當我們要去讀取一個資料的時候,會先判斷該資料是否存在於快取器中,如果存在,則返回,如果不存在,則去別的地方查詢該資料(例如磁碟),找到後在把該資料存放於快取器中,在返回。

所以在實際的應用中,put 操作一般伴隨著 get 操作,也就是說,get 操作的次數是比較多的,而且命中率也是相對比較高的,進而 put 操作的次數是比較少的,我們我們是可以考慮採用空間換時間的方式來加快我們的 get 的操作的。

例如我們可以用一個額外雜湊表(例如HashMap)來存放 key-value,這樣的話,我們的 get 操作就可以在 O(1) 的時間內尋找到目標節點,並且把 value 返回了。

然而,大家想一下,用了雜湊表之後,get 操作真的能夠在 O(1) 時間內完成嗎?

用了雜湊表之後,雖然我們能夠在 O(1) 時間內找到目標元素,可以,我們還需要刪除該元素,並且把該元素插入到連結串列頭部啊,刪除一個元素,我們是需要定位到這個元素的前驅的,然後定位到這個元素的前驅,是需要 O(n) 時間複雜度的。

最後的結果是,用了雜湊表時候,最壞時間複雜度還是 O(1),而空間複雜度也變為了 O(n)。

雙向連結串列+雜湊表

我們都已經能夠在 O(1) 時間複雜度找到要刪除的節點了,之所以還得花 O(n) 時間複雜度才能刪除,主要是時間是花在了節點前驅的查詢上,為了解決這個問題,其實,我們可以把單連結串列換成雙連結串列,這樣的話,我們就可以很好著解決這個問題了,而且,換成雙連結串列之後,你會發現,它要比單連結串列的操作簡單多了。

所以我們最後的方案是:雙連結串列 + 雜湊表,採用這兩種資料結構的組合,我們的 get 操作就可以在 O(1) 時間複雜度內完成了。由於 put 操作我們要刪除的節點一般是尾部節點,所以我們可以用一個變數 tai 時刻記錄尾部節點的位置,這樣的話,我們的 put 操作也可以在 O(1) 時間內完成了。

具體程式碼如下:

// 連結串列節點的定義
class LRUNode{
    String key;
    Object value;
    LRUNode next;
    LRUNode pre;

    public LRUNode(String key, Object value) {
        this.key = key;
        this.value = value;
    }
}
複製程式碼
// LRU
public class LRUCache {
    Map<String, LRUNode> map = new HashMap<>();
    RLUNode head;
    RLUNode tail;
    // 快取最大容量,我們假設最大容量大於 1,
    // 當然,小於等於1的話需要多加一些判斷另行處理
    int capacity;

    public RLUCache(int capacity) {
        this.capacity = capacity;
    }

    public void put(String key, Object value) {
        if (head == null) {
            head = new LRUNode(key, value);
            tail = head;
            map.put(key, head);
        }
        LRUNode node = map.get(key);
        if (node != null) {
            // 更新值
            node.value = value;
            // 把他從連結串列刪除並且插入到頭結點
            removeAndInsert(node);
        } else {
            LRUNode tmp = new LRUNode(key, value);
            // 如果會溢位
            if (map.size() >= capacity) {
                // 先把它從雜湊表中刪除
                map.remove(tail);
                // 刪除尾部節點
                tail = tail.pre;
                tail.next = null;
            }
            map.put(key, tmp);
            // 插入
            tmp.next = head;
            head.pre = tmp;
            head = tmp;
        }
    }

    public Object get(String key) {
        LRUNode node = map.get(key);
        if (node != null) {
            // 把這個節點刪除並插入到頭結點
            removeAndInsert(node);
            return node.value;
        }
        return null;
    }
    private void removeAndInsert(LRUNode node) {
        // 特殊情況先判斷,例如該節點是頭結點或是尾部節點
        if (node == head) {
            return;
        } else if (node == tail) {
            tail = node.pre;
            tail.next = null;
        } else {
            node.pre.next = node.next;
            node.next.pre = node.pre;
        }
        // 插入到頭結點
        node.next = head;
        node.pre = null;
        head.pre = node;
        head = node;
    }
}
複製程式碼

這裡需要提醒的是,對於連結串列這種資料結構,頭結點和尾節點是兩個比較特殊的點,如果要刪除的節點是頭結點或者尾節點,我們一般要先對他們進行處理。

這裡放一下單連結串列版本的吧


// 定義連結串列節點
class RLUNode{
    String key;
    Object value;
    RLUNode next;

    public RLUNode(String key, Object value) {
        this.key = key;
        this.value = value;
    }
}
// 把名字寫錯了,把 LRU寫成了RLU
public class RLUCache {
    RLUNode head;
    int size = 0;// 當前大小
    int capacity = 0; // 最大容量

    public RLUCache(int capacity) {
        this.capacity = capacity;
    }

    public Object get(String key) {
        RLUNode cur = head;
        RLUNode pre = head;// 指向要刪除節點的前驅
        // 找到對應的節點,並把對應的節點放在連結串列頭部
        // 先考慮特殊情況
        if(head == null)
            return null;
        if(cur.key.equals(key))
            return cur.value;
        // 進行查詢
        cur = cur.next;
        while (cur != null) {
            if (cur.key.equals(key)) {
                break;
            }
            pre = cur;
            cur = cur.next;
        }
        // 代表沒找到了節點
        if (cur == null)
            return null;

        // 進行刪除
        pre.next = cur.next;
        // 刪除之後插入頭結點
        cur.next = head;
        head = cur;
        return cur.value;
    }

    public void put(String key, Object value) {
        // 如果最大容量是 1,那就沒辦法了,,,,,
        if (capacity == 1) {
            head = new RLUNode(key, value);
        }
        RLUNode cur = head;
        RLUNode pre = head;
        // 先檢視連結串列是否為空
        if (head == null) {
            head = new RLUNode(key, value);
            return;
        }
        // 先檢視該節點是否存在
        // 第一個節點比較特殊,先進行判斷
        if (head.key.equals(key)) {
            head.value = value;
            return;
        }
        cur = cur.next;
        while (cur != null) {
            if (cur.key.equals(key)) {
                break;
            }
            pre = cur;
            cur = cur.next;
        }
        // 代表要插入的節點的 key 已存在,則進行 value 的更新
        // 以及把它放到第一個節點去
        if (cur != null) {
            cur.value = value;
            pre.next = cur.next;
            cur.next = head;
            head = cur;
        } else {
            // 先建立一個節點
            RLUNode tmp = new RLUNode(key, value);
            // 該節點不存在,需要判斷插入後會不會溢位
            if (size >= capacity) {
                // 直接把最後一個節點移除
                cur = head;
                while (cur.next != null && cur.next.next != null) {
                    cur = cur.next;
                }
                cur.next = null;
                tmp.next = head;
                head = tmp;
            }
        }
    }
}

複製程式碼

如果要時間,強烈建議自己手動實現一波。

如果你覺得這篇內容對你挺有啟發,讓更多的人看到這篇文章不妨:

1、點贊,讓更多的人也能看到這篇內容(收藏不點贊,都是耍流氓 -_-)

2、關注我和專欄,讓我們成為長期關係

3、關注公眾號「苦逼的碼農」,主要寫演算法、計算機基礎之類的文章,裡面已有100多篇原創文章,我也分享了很多視訊、書籍的資源,以及開發工具,歡迎各位的關注,第一時間閱讀我的文章。

相關文章