1. 程式人生 > >演算法 - 06 | 連結串列(上):如何實現LRU快取淘汰演算法?

演算法 - 06 | 連結串列(上):如何實現LRU快取淘汰演算法?

連結串列的一個景點應用場景 --- LRU快取淘汰演算法

1. 快取

  • 什麼是快取
    快取是一種提高資料讀取效能的技術,在硬體設計、軟體開發中都有著非常廣泛的應用,比如常見的CPU快取、資料庫快取、瀏覽器快取等等。

  • 快取淘汰策略
    快取大小有限,當快取被用滿是,那些資料應該被清理出去,那些資料被保留,這就是快取淘汰策略來決定。
    常見的策略有三種:
    先進先出策略 FIFO (First In, First Out)
    最少使用策略 LFU (Least Frequently Used)
    最近最少使用策略 LRU(Least Recently Used)

2. 連結串列結構

  • 資料與連結串列區別

    從圖中可以看到,陣列需要一塊連續的記憶體空間來儲存,對記憶體的要求比較高。比如我們申請了一個100MB大小的陣列,當記憶體中沒有連續的、足夠大的儲存空間時,即便記憶體的剩餘總可用空間大於100MB,仍然會申請失敗。
    而連結串列恰恰相反,它並不需要一塊連續的記憶體空間,它通過"指標"將一組零散的記憶體塊串聯起來使用,所以如果我們申請的是100MB大小的連結串列,根本不會有問題。

  • 常見的連結串列結構
    單鏈表
    雙向連結串列
    迴圈連結串列

3. 單鏈表

  • 優點:插入、刪除
    陣列:時間複雜度是O(n)
    連結串列:時間複雜度是O(1)

  • 缺點:不支援隨機訪問
    陣列:時間複雜度O(1),根據首地址和下標,通過定址公式就能直接計算出對應的記憶體地址。
    連結串列:時間複雜度O(n),資料並非連續儲存,需要根據指標依次遍歷節點,直到找到。

4. 迴圈連結串列

迴圈連結串列是一種特殊的單鏈表。與單鏈表唯一的區別就是在尾節點指標指向連結串列的頭結點。

優點:從連結串列尾到連結串列頭比較方便。當要處理的資料具有環型結構特點時,就特別適合採用迴圈連結串列。比如著名的約瑟夫問題。

5. 雙向連結串列

  • 與單向連結串列相比
    缺點:如果儲存同樣多的資料,佔用更過的記憶體空間。
    優點:支援雙向遍歷,操作更靈活。

  • 適用場景
    在某些情況下的插入、刪除等操作都要比單鏈表簡單、高效。
    刪除操作有兩種情況:
    a. 刪除節點中"值等於某個給定值"的節點
    b. 刪除給定指標指向的節點

    對於a:
    刪除操作時間複雜度O(1),但不管是單鏈表還是雙向連結串列遍歷查詢的時間複雜度為O(n),刪除值等於給定值的節點對應的連結串列操作的總時間複雜度為O(n)

    對於b:
    單鏈表:已經找到了要刪除的節點,但是刪除某個節點 q 需要知道其前驅節點,兒而單鏈表並不支援直接獲取其前驅節點,所以,為了找到前驅節點,還要從頭節點開始遍歷連結串列,知道 p->next=q,說明 p 是 q 的前驅節點。所以時間複雜度為O(n)
    雙向連結串列:因為雙向連結串列中的節點已經儲存了前驅節點的指標,不需要像單鏈表那樣遍歷。所以時間複雜度為O(1)

    插入同理。

    除了插入、刪除操作有優勢之外,對於一個有序連結串列,雙向連結串列的查詢效率也比單鏈表高一些。因為,可以記錄上次查詢的位置,每次查詢時,要根據要查詢的值與 p 的大小關係,決定是往前還是往後查詢,所以平均只需要查詢一半的資料。

    所以,雙向連結串列儘管比較耗費記憶體,但還是比單鏈表更加高效。如果深入研究LinkedHashMap的實現原理,就會發現其中就用到了雙向連結串列這種資料結構。

  • 空間換時間設計思想
    對於執行較慢的程式,可以通過空間換時間來進行優化;
    而消耗過多記憶體的程式,可以通過時間換控空間來降低記憶體消耗。

6. 雙向迴圈連結串列

迴圈連結串列與雙向連結串列的整合

7. 連結串列 VS 陣列效能大比拼

  • 時間複雜度

  • 有效快取
    陣列在實現上使用的是連續的記憶體空間,可以藉助CPU的快取機制,預讀陣列中的資料,所以訪問效率更高。而連結串列在記憶體中並不是連續儲存,所以對CPU快取不好(CPU每次從記憶體讀取資料並不是只讀取那個特定要訪問的地址,而是讀取一個數據塊),沒辦法有效預讀。

  • 動態擴容
    陣列的缺點是大小固定,一經宣告就要佔用整塊連續記憶體空間。如果宣告的陣列過大,系統可能沒有足夠的連續記憶體空間分配給它,導致"記憶體不足(out of memory)"。如果宣告的陣列過小,則可能出現不夠用的情況。這時只能再申請一個更大的記憶體空間,把原陣列拷貝進去,非常耗時。而連結串列本身沒有大小限制,天然地支援動態擴容。

    總結
    如果對記憶體的使用非常苛刻,那陣列更適合你。因為連結串列中的每個節點都需要消耗額外的儲存空間去儲存一份指向下一個節點的指標,所以記憶體消耗會翻倍。而且,對連結串列進行頻繁的插入、刪除操作,還會導致頻繁的記憶體申請和釋放,容易造成記憶體碎片,如果是Java語言,就有可能會導致頻繁的GC(Garbage Collection,垃圾回收)。

8. 解答開篇

如何基本連結串列實現LRU快取淘汰演算法?

思路:
維護一個有序單鏈表,越靠近連結串列尾部的節點是越早之前訪問的,當有一個新資料被訪問時,從連結串列頭開始順序遍歷連結串列。

  1. 如果此資料之前已經被快取在連結串列中了,我們遍歷得到這個資料對應的節點,並將其從原來的位置刪除,然後再插入到連結串列的頭部。

  2. 如果此資料沒有在快取列表中,分為以下兩種情況:

    • 如果此時快取未滿,則將此節點直接插入到連結串列的頭部;
    • 如果此時快取已滿,則連結串列尾節點刪除,將新的資料節點輸入連結串列的頭部。

快取訪問的時間複雜度為O(n):因為不管快取有沒有滿,我們都需要遍歷一遍連結串列。

程式碼請戳:基本連結串列實現LRU快取淘汰演算法

實際上,可以繼續優化這個實現思路,比如引入散列表(Hash table)來記錄每個資料的位置,將快取訪問的時間複雜度降到O(1)

思考

如果字串是通過單鏈表來儲存的,如何判斷一個字串是否為會問字串(比如 上海自來水來自海上),時間複雜度是多少?
方案一
1)前提:字串以單個字元的形式儲存在單鏈表中。
2)遍歷連結串列,判斷字元個數是否為奇數,若為偶數,則不是。
3)將連結串列中的字元倒序儲存一份在另一個連結串列中。
4)同步遍歷2個連結串列,比較對應的字元是否相等,若相等,則是水仙花字串,否則,不是。
方案二
1)遍歷連結串列將元素放入陣列A中
2)利用陣列隨機訪問特性,分別從頭和尾往中間遍歷,並將元素進行比較,直到有元素不相等或者陣列A的中點結束。
此方案的空間複雜度為O(n)。
方案三
1)快慢兩個指標定位連結串列中點,同時逆序前半部分連結串列
2)已逆序的前半部分與後半部分進行比較
3)再次逆序前半部分連結串列
此方案的空間複雜度為O(1)。