數據結構與算法四:鏈表 如何基於鏈表實現 LRU緩存淘汰算法呢?

前言

知識讓生活更具能量。希望我們在以後學習的路上攜手同行。您的點贊、評論和打賞都是對我最大的鼓勵。一個人能走多遠要看與誰同行,希望能與優秀的您結交。

鏈表這種數據結構一個經典的應用場景就是LRU緩存淘汰算法。 緩存空間如果滿了的話就需要對空間進行優化,清理一些不要的數據。常見的有三種策略:先進先出策略FIFO(first In,First Out)、最少使用策略(Least Frequently Used)、最近最少使用策略LRU(Least Recently Used)。
我之前用java寫過一個緩存採用的就是第三種LRU策略。 緩存 這是我的項目,還請各位多多關注。

鏈表結構種類介紹

鏈表這種數據結構也分了很多小種類。主要的區別在於指針的多少,差別不是很大,下面我們來一一介紹。

單鏈表

單鏈表中的每一個節點,除了保存自身的數據,還包含了一個指針指向下一個節點。

在這裏插入圖片描述
從上圖我們可以看到單鏈表有兩個特殊的節點。 第一個節點是頭節點,記錄鏈表的基地址,有了它我們就可以遍歷整個鏈表了。 最後一個節點是鏈表的尾節點。它指向的是一個空地址Null。

它的插入和刪除的時間複雜度都是O(1)。因爲鏈表在插入和刪除數據的時候不用管節點的有序性。 只需要更改前一節點的指針就可以了。
在這裏插入圖片描述
在這裏插入圖片描述

但是鏈表的隨機訪問速度就比較低了,他只能通過循環遍歷來查詢值, 沒有辦法像數組那樣通過計算公式來取值。 他的隨機查詢複雜度爲O(n)。

循環鏈表

循環鏈表是一種特殊的單鏈表。它和單鏈表的區別就是尾結點指向頭節點。當要處理的數據具有環形結構特點時,就特別適合採用循環鏈表。比如約瑟夫斯問題
在這裏插入圖片描述

雙向鏈表

雙向鏈表每一個節點都有兩個指向,分別指向前一個節點的地址,和後一個節點的地址。

在這裏插入圖片描述
雙向鏈表支持O(1)複雜度查詢前驅節點。因爲這一點雙向鏈表比單向鏈表的優化就可以體現出來了。 比如: 插入或者刪除給定指針指向的節點
假如我們現在在操作的是單向鏈表,我們想要刪除給定指針指向的節點,那麼還需要找到這個節點的上一個節點纔行。 而去循環上一個節點的時間複雜度爲O(n),根據時間複雜度分析加法法則,雖然刪除這個操作本身的時間複雜度爲O(1),但是查詢複雜度加上刪除複雜度的結果就爲O(n)。
但是我們雙向鏈表本身就包含了前驅節點的指針。 就省去循環遍歷因此刪除給定指針指向的節點的時間複雜度就爲O(1)。 同理插入操作也是一樣的。java語言中的LinkHashMap就運用了雙向鏈表的數據結構。

還有一種雙向循環鏈表和雙向鏈表類似。
在這裏插入圖片描述

如何基於鏈表實現 LRU緩存淘汰算法呢?

LRU只是一種算法,它的設計原則:如果一個數據在最近一段時間沒有被訪問到,那麼在將來它被訪問的可能性也很小。
我們實現思路如下:
當需要插入新的數據項的時候,如果新數據項在鏈表中存在(一般稱爲命中),則把該節點移到鏈表頭部,如果不存在,則新建一個節點,放到鏈表頭部,若緩存滿了,則把鏈表最後一個節點刪除即可。
在訪問數據的時候,如果數據項在鏈表中存在,則把該節點移到鏈表頭部,否則返回-1。這樣一來在鏈表尾部的節點就是最近最久未訪問的數據項。

在我們實戰使用雙向鏈表設計緩存的時候經常使用HashMap加上雙向鏈表這種數據結構來實現。 我文中介紹的我寫的緩存項目就是採用的這種方式。

代碼demo

/**
 * 
 * LRU(Least Recently Used)緩存算法
 * 使用HashMap+雙向鏈表,使get和put的時間複雜度達到O(1)。
 * 讀緩存時從HashMap中查找key,更新緩存時同時更新HashMap和雙向鏈表,雙向鏈表始終按照訪問順序排列。
 *
 */
public class LRUCache {

    /**
     * @param args
     * 測試程序,訪問順序爲[[1,1],[2,2],[1],[3,3],[2],[4,4],[1],[3],[4]],其中成對的數調用put,單個數調用get。
     * get的結果爲[1],[-1],[-1],[3],[4],-1表示緩存未命中,其它數字表示命中。
     */
    public static void main(String[] args) {
        
        LRUCache cache = new LRUCache(2);
        cache.put(1, 1);
        cache.put(2, 2);
        System.out.println(cache.get(1));
        cache.put(3, 3);
        System.out.println(cache.get(2));
        cache.put(4, 4);
        System.out.println(cache.get(1));
        System.out.println(cache.get(3));
        System.out.println(cache.get(4));

    }
    
    // 緩存容量
    private final int capacity;
    // 用於加速緩存項隨機訪問性能的HashMap
    private HashMap<Integer, Entry> map;
    // 雙向鏈表頭結點,該側的緩存項訪問時間較早
    private Entry head;
    // 雙向鏈表尾結點,該側的緩存項訪問時間較新
    private Entry tail;

    public LRUCache(int capacity) {
        this.capacity = capacity;
        map = new HashMap<Integer, Entry>((int)(capacity / 0.75 + 1), 0.75f);
        head = new Entry(0, 0);
        tail = new Entry(0, 0);
        head.next = tail;
        tail.prev = head;
    }
    
    /**
     * 從緩存中獲取key對應的值,若未命中則返回-1
     * @param key 鍵
     * @return key對應的值,若未命中則返回-1
     */
    public int get(int key) {
        if (map.containsKey(key)) {
            Entry entry = map.get(key);
            popToTail(entry);
            return entry.value;
        }
        return -1;
    }
    
    /**
     * 向緩存中插入或更新值
     * @param key 待更新的鍵
     * @param value 待更新的值
     */
    public void put(int key, int value) {
        if (map.containsKey(key)) {
            Entry entry = map.get(key);
            entry.value = value;
            popToTail(entry);
        }
        else {
            Entry newEntry = new Entry(key, value);
            if (map.size() >= capacity) {
                Entry first = removeFirst();
                map.remove(first.key);
            }
            addToTail(newEntry);
            map.put(key, newEntry);
        }
    }
    
    /**
     * 緩存項的包裝類,包含鍵、值、前驅結點、後繼結點
     * @author wjg
     *
     */
    class Entry {
        int key;
        int value;
        Entry prev;
        Entry next;
        
        Entry(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }
    
    // 將entry結點移動到鏈表末端
    private void popToTail(Entry entry) {
        Entry prev = entry.prev;
        Entry next = entry.next;
        prev.next = next;
        next.prev = prev;
        Entry last = tail.prev;
        last.next = entry;
        tail.prev = entry;
        entry.prev = last;
        entry.next = tail;
    }
    
    // 移除鏈表首端的結點
    private Entry removeFirst() {
        Entry first = head.next;
        Entry second = first.next;
        head.next = second;
        second.prev = head;
        return first;
    }
    
    // 添加entry結點到鏈表末端
    private void addToTail(Entry entry) {
        Entry last = tail.prev;
        last.next = entry;
        tail.prev = entry;
        entry.prev = last;
        entry.next = tail;
    }

}

值得一提的是,Java API中其實已經有數據類型提供了我們需要的功能,就是LinkedHashMap這個類。該類內部也是採用HashMap+雙向鏈表實現的。使用這個類實現LRU就簡練多了。

/**
 * 
 * 一個更簡單實用的LRUCache方案,使用LinkedHashMap即可實現。
 * LinkedHashMap提供了按照訪問順序排序的方案,內部也是使用HashMap+雙向鏈表。
 * 只需要重寫removeEldestEntry方法,當該方法返回true時,LinkedHashMap會刪除最舊的結點。
 * 
 *
 */
public class LRUCacheSimple {

    /**
     * @param args
     */
    public static void main(String[] args) {
        LRUCacheSimple cache = new LRUCacheSimple(2);
        cache.put(1, 1);
        cache.put(2, 2);
        System.out.println(cache.get(1));
        cache.put(3, 3);
        System.out.println(cache.get(2));
        cache.put(4, 4);
        System.out.println(cache.get(1));
        System.out.println(cache.get(3));
        System.out.println(cache.get(4));
    }
    
    private LinkedHashMap<Integer, Integer> map;
    private final int capacity;
    public LRUCacheSimple(int capacity) {
        this.capacity = capacity;
        map = new LinkedHashMap<Integer, Integer>(capacity, 0.75f, true){
            protected boolean removeEldestEntry(Map.Entry eldest) {
                return size() > capacity;
            }
        };
    }
    public int get(int key) {
        return map.getOrDefault(key, -1);
    }
    public void put(int key, int value) {
        map.put(key, value);
    }

}

只需要覆寫LinkedHashMap的removeEldestEntry方法,在緩存已滿的情況下返回true,內部就會自動刪除最老的元素。

以上內容均爲讀書所得, 想看更多內容請關注微信公衆號。
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章