鍊錶(上) 如何實現LRU快取淘汰演算法

2021-10-08 12:37:18 字數 3695 閱讀 2062

鍊錶是一種最基礎的資料結構,學習鍊錶有什麼用?為了回答這個問題,先來討論乙個經典的鍊錶應用場景,那就是 lru 快取淘汰演算法。

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

快取的大小有限,當快取被用滿時,哪些資料應該被清理出去,哪些資料應該被保留?這就需要快取淘汰策略來決定。常見的策略有三種:先進先出策略 fifo(first in,first out)、最少使用策略 lfu(least frequently used)、最近最少使用策略 lru(least recently used)。

打個比方,假如你買了很多本技術書,但有一天你發現,這些書太多了,太佔書房空間了,你要做個大掃除,扔掉一些書籍。那這個時候,你會選擇扔掉哪些書呢?對應一下,你的選擇標準是不是和上面的三種策略神似呢?

如何用鍊錶來實現 lru 快取淘汰策略呢?

相比陣列,鍊錶是一種稍微複雜一點的資料結構。這兩個非常基礎、非常常用的資料結構,常常會放到一塊兒來比較。所以先來看,這兩者有什麼區別。

先從底層的儲存結構上來看一看

從圖中可以看到,陣列需要一塊連續的記憶體空間來儲存,對記憶體的要求比較高。如果我們申請乙個 100mb 大小的陣列,當記憶體中沒有連續的、足夠大的儲存空間時,即便記憶體的剩餘總可用空間大於 100mb,仍然會申請失敗。

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

最常見的鍊錶結構有三種,分別是單鏈表、雙向鍊錶和迴圈鍊錶。先來看單鏈表。

鍊錶通過指標將一組零散的記憶體塊串聯在一起。其中,我們把記憶體塊稱為鍊錶的「結點」。為了將所有的結點串起來,每個鍊錶的結點除了儲存資料之外,還需要記錄鏈上的下乙個結點的位址。如圖所示,我們把這個記錄下個結點位址的指標叫作後繼指標 next。

其中有兩個結點是比較特殊的,它們分別是第乙個結點和最後乙個結點。我們習慣性地把第乙個結點叫作頭結點,把最後乙個結點叫作尾結點。其中,頭結點用來記錄鍊錶的基位址。有了它,我們就可以遍歷得到整條鍊錶。而尾結點特殊的地方是:指標不是指向下乙個結點,而是指向乙個空位址 null,表示這是鍊錶上最後乙個結點。

與陣列一樣,鍊錶也支援資料的查詢、插入和刪除操作。

在進行陣列的插入、刪除操作時,為了保持記憶體資料的連續性,需要做大量的資料搬移,所以時間複雜度是 o(n)。而在鍊錶中插入或者刪除乙個資料,我們並不需要為了保持記憶體的連續性而搬移結點,因為鍊錶的儲存空間本身就不是連續的。所以,在鍊錶中插入和刪除乙個資料是非常快速的。

有利就有弊,鍊錶要想隨機訪問第 k 個元素,就沒有陣列那麼高效了。因為鍊錶中的資料並非連續儲存的,所以無法像陣列那樣,根據首位址和下標,通過定址公式就能直接計算出對應的記憶體位址,而是需要根據指標乙個結點乙個結點地依次遍歷,直到找到相應的結點。

接著來看另外兩個複雜的公升級版,迴圈鍊錶和雙向鍊錶。

迴圈鍊錶是一種特殊的單鏈表。實際上,迴圈鍊錶也很簡單。它跟單鏈表唯一的區別就在尾結點。我們知道,單鏈表的尾結點指標指向空位址,表示這就是最後的結點了。而迴圈鍊錶的尾結點指標是指向鍊錶的頭結點。

和單鏈表相比,迴圈鍊錶的優點是從鏈尾到鏈頭比較方便。當要處理的資料具有環型結構特點時,就特別適合採用迴圈鍊錶。

再來看雙向鍊錶。單向鍊錶只有乙個方向,結點只有乙個後繼指標 next 指向後面的結點。而雙向鍊錶,顧名思義,它支援兩個方向,每個結點不止有乙個後繼指標 next 指向後面的結點,還有乙個前驅指標 prev 指向前面的結點。

雙向鍊錶需要額外的兩個空間來儲存後繼結點和前驅結點的位址。所以,如果儲存同樣多的資料,雙向鍊錶要比單鏈表占用更多的記憶體空間。雖然兩個指標比較浪費儲存空間,但可以支援雙向遍歷,這樣也帶來了雙向鍊錶操作的靈活性。那相比單鏈表,雙向鍊錶適合解決哪種問題呢?

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

來看一下刪除操作

在實際的軟體開發中,從鍊錶中刪除乙個資料無外乎這兩種情況:

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

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

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

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

同理,如果我們希望在鍊錶的某個指定結點前面插入乙個結點,雙向鍊錶比單鏈表有很大的優勢。雙向鍊錶可以在 o(1) 時間複雜度搞定,而單向鍊錶需要 o(n) 的時間複雜度。

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

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

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

回過頭來看下開篇的題目。如何基於鍊錶實現 lru 快取淘汰演算法?

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

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

如果此資料沒有在快取鍊錶中,又可以分為兩種情況:

06 鍊錶(上) 如何實現LRU快取淘汰演算法

我們先來討論乙個經典的鍊錶應用場景,那就是 lru 快取淘汰演算法。快取的大小有限,當快取被用滿時,哪些資料應該被清理出去,哪些資料應該被保留?這就需要快取淘汰策略來決定。常見的策略有三種 先進先出策略 fifo first in,first out 最少使用策略 lfu least frequen...

鍊錶 如何實現LRU快取淘汰演算法

乙個經典的鍊錶應用場景,就是lru快取淘汰演算法 快取是一種提高資料讀取效能的技術,比如cpu快取 資料庫快取 瀏覽器快取等等 快取的大小有限,當快取被用滿的時候哪些資料該被清理出去?哪些資料該被保留?需要快取淘汰策略來決定 先進先出策略fifo,最少使用策略lfu,最近最少使用策略lru 陣列需要...

鍊錶 如何實現LRU快取淘汰演算法

一 什麼是鍊錶?和陣列一樣,鍊錶也是一種線性表。從記憶體結構來看,鍊錶的記憶體結構是不連續的記憶體空間,是將一組零散的記憶體塊串聯起來,從而進行資料儲存的資料結構。二 為什麼使用鍊錶?即鍊錶的特點 三 常用鍊錶 單鏈表 迴圈鍊錶和雙向鍊錶 四 選擇陣列還是鍊錶?五 應用 如何分別用鍊錶和陣列實現lr...