1. 程式人生 > >算法系列-連結串列:如何實現LRU快取淘汰演算法

算法系列-連結串列:如何實現LRU快取淘汰演算法

整理自極客時間-資料結構與演算法之美。原文內容更完整具體,且有音訊。購買地址:

開篇語

今天我們來聊聊“連結串列(Linked list)經典的應用場景,那就是 LRU 快取淘汰演算法。

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

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

連結串列結構

連結串列和陣列的區別
為了直觀地對比,我畫了一張圖。從圖中我們看到,陣列需要一塊連續的記憶體空間來儲存,對記憶體的要求比較高。如果我們申請一個 100MB 大小的陣列,當記憶體中沒有連續的、足夠大的儲存空間時,即便記憶體的剩餘總可用空間大於 100MB,仍然會申請失敗。

而連結串列恰恰相反,它並不需要一塊連續的記憶體空間,它通過“指標”將一組零散的記憶體塊串聯起來使用,所以如果我們申請的是 100MB 大小的連結串列,根本不會有問題。

三種最常見的連結串列結構:

  1. 單鏈表
    連結串列通過指標將一組零散的記憶體塊串聯在一起。其中,我們把記憶體塊稱為連結串列的“結點”。為了將所有的結點串起來,每個連結串列的結點除了儲存資料之外,還需要記錄鏈上的下一個結點的地址。如圖所示,我們把這個記錄下個結點地址的指標叫作後繼指標 next


     
  2. 迴圈連結串列
    迴圈連結串列是一種特殊的單鏈表。它跟單鏈表唯一的區別就在尾結點。單鏈表的尾結點指標指向空地址,表示這就是最後的結點了。而迴圈連結串列的尾結點指標是指向連結串列的頭結點。從迴圈連結串列圖中,可以看出來,它像一個環一樣首尾相連,所以叫作“迴圈”連結串列。

    連結串列相比,迴圈連結串列的優點是從鏈尾到鏈頭比較方便。當要處理的資料具有環型結構特點時,就特別適合採用迴圈連結串列。比如著名的約瑟夫問題
  3. 雙向連結串列
    單向連結串列只有一個方向,結點只有一個後繼指標 next 指向後面的結點。而雙向連結串列,顧名思義,它支援兩個方向,每個結點不止有一個後繼指標 next 指向後面的結點,還有一個前驅指標 prev 指向前面的結點。

    從我畫的圖中可以看出來,雙向連結串列需要額外的兩個空間來儲存後繼結點和前驅結點的地址。所以,如果儲存同樣多的資料,雙向連結串列要比單鏈表佔用更多的記憶體空間。雖然兩個指標比較浪費儲存空間,但可以支援雙向遍歷,這樣也帶來了雙向連結串列操作的靈活性。

    從結構上來看,雙向連結串列可以支援 O(1) 時間複雜度的情況下找到前驅結點,正是這樣的特點,也使雙向連結串列在某些情況下的插入、刪除等操作都要比單鏈表簡單、高效。

    刪除操作:在實際的軟體開發中,從連結串列中刪除一個數據無外乎這兩種情況:1.刪除結點中“值等於某個給定值”的結點;2.刪除給定指標指向的結點。

    對於第一種情況,不管是單鏈表還是雙向連結串列,為了查詢到值等於給定值的結點,都需要從頭結點開始一個一個依次遍歷對比,直到找到值等於給定值的結點,然後再通過我前面講的指標操作將其刪除。

    儘管單純的刪除操作時間複雜度是 O(1),但遍歷查詢的時間是主要的耗時點,對應的時間複雜度為 O(n)。根據時間複雜度分析中的加法法則,刪除值等於給定值的結點對應的連結串列操作的總時間複雜度為 O(n)。

    對於第二種情況,我們已經找到了要刪除的結點,但是刪除某個結點 q 需要知道其前驅結點,而單鏈表並不支援直接獲取前驅結點,所以,為了找到前驅結點,我們還是要從頭結點開始遍歷連結串列,直到 p->next=q,說明 p 是 q 的前驅結點。

    但是對於雙向連結串列來說,這種情況就比較有優勢了。因為雙向連結串列中的結點已經儲存了前驅結點的指標,不需要像單鏈表那樣遍歷。所以,針對第二種情況,單鏈表刪除操作需要 O(n) 的時間複雜度,而雙向連結串列只需要在 O(1) 的時間複雜度內就搞定了

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

連結串列與陣列效能對比

陣列和連結串列是兩種截然不同的記憶體組織方式。正是因為記憶體儲存的區別,它們插入、刪除、隨機訪問操作的時間複雜度正好相反。

 

不過,陣列和連結串列的對比,並不能侷限於時間複雜度。而且,在實際的軟體開發中,不能僅僅利用複雜度分析就決定使用哪個資料結構來儲存資料。

陣列簡單易用,在實現上使用的是連續的記憶體空間,可以藉助 CPU 的快取機制,預讀陣列中的資料,所以訪問效率更高
連結串列在記憶體中並不是連續儲存,所以對 CPU 快取不友好,沒辦法有效預讀。

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

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

所以,在我們實際的開發中,針對不同型別的專案,要根據具體情況,權衡究竟是選擇陣列還是連結串列。

連結串列實現LRU淘汰策略

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

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

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

2. 如果此資料沒有在快取連結串列中,又可以分為兩種情況:

  • 如果此時快取未滿,則將此結點直接插入到連結串列的頭部;

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

這樣我們就用連結串列實現了一個 LRU 快取。

現在我們來看下 m 快取訪問的時間複雜度是多少。因為不管快取有沒有滿,我們都需要遍歷一遍連結串列,所以這種基於連結串列的實現思路,快取訪問的時間複雜度為 O(n)。

實際上,我們可以繼續優化這個實現思路,比如引入散列表(Hash table)來記錄每個資料的位置,將快取訪問的時間複雜度降到 O(1)。因為要涉及我們還沒有講到的資料結構,所以這個優化方案,我現在就不詳細說了,等講到散列表的時候,我會再拿出來講。

除了基於連結串列的實現思路,實際上還可以用陣列來實現 LRU 快取淘汰策略。如何利用陣列實現 LRU 快取淘汰策略呢?我把這個問題留給你思考。