04 | 鏈表(上):如何實現LRU緩存淘汰算法?

今天咱們來聊聊「鏈表(Linked list)」這個數據結構。學習鏈表有什麼用呢?爲了回答這個問題,咱們先來討論一個經典的鏈表應用場景,那就是+LRU+緩存淘汰算法。算法

 

緩存是一種提升數據讀取性能的技術,在硬件設計、軟件開發中都有着很是普遍的應用,好比常見的+CPU+緩存、數據庫緩存、瀏覽器緩存等等。數據庫

 

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

 

這些策略你不用死記,我打個比方你很容易就明白了。假如說,你買了不少本技術書,但有一天你發現,這些書太多了,太佔書房空間了,你要作個大掃除,扔掉一些書籍。那這個時候,你會選擇扔掉哪些書呢?對應一下,你的選擇標準是否是和上面的三種策略神似呢? 好了,回到正題,咱們今天的開篇問題就是:如何用鏈表來實現 LRU 緩存淘汰策略呢?瀏覽器

 

帶着這個問題,咱們開始今天的內容吧!緩存

 

五花八門的鏈表結構

 

相比數組,鏈表是一種稍微複雜一點的數據結構。對於初學者來講,掌握起來也要比數組稍難一些。這兩個很是基礎、很是經常使用的數據結構,咱們經常將會放到一起來比較。因此咱們先來看,這二者有什麼區別。數據結構

 

咱們先從底層的存儲結構上來看一看數據結構和算法

 

爲了直觀地對比,我畫了一張圖。從圖中咱們看到,數組須要一塊連續的內存空間來存儲,對內存的要求比較高。若是咱們申請一個+100MB+大小的數組,當內存中沒有連續的、足夠大的存儲空間時,即使內存的剩餘總可用空間大於+100MB,仍然會申請失敗。+而鏈表偏偏相反,它並不須要一塊連續的內存空間,它經過「指針」將一組零散的內存塊串聯起來使用,因此若是咱們申請的是+100MB+大小的鏈表,根本不會有問題。性能

 

 

鏈表結構五花八門,今天我重點給你介紹三種最多見的鏈表結構,它們分別是:單鏈表、雙向鏈表和循環鏈表。咱們首先來看最簡單、最經常使用的單鏈表。學習

 

咱們剛剛講到,鏈表經過指針將一組零散的內存塊串聯在一塊兒。其中,咱們把內存塊稱爲鏈表的「結點」。爲了將全部的結點串起來,每一個鏈表的結點除了存儲數據以外,還須要記錄鏈上的下一個結點的地址。如圖所示,咱們把這個記錄下個結點地址的指針叫做後繼指針+next。優化

 

 

從我畫的單鏈表圖中,你應該能夠發現,其中有兩個結點是比較特殊的,它們分別是第一個結點和最後一個結點。咱們習慣性地把第一個結點叫做頭結點,把最後一個結點叫做尾結點。其中,頭結點用來記錄鏈表的基地址。有了它,咱們就能夠遍歷獲得整條鏈表。而尾結點特殊的地方是:指針不是指向下一個結點,而是指向一個空地址 NULL,表示這是鏈表上最後一個結點。

 

與數組同樣,鏈表也支持數據的查找、插入和刪除操做。

 

咱們知道,在進行數組的插入、刪除操做時,爲了保持內存數據的連續性,須要作大量的數據搬移,因此時間複雜度是 O(n)。而在鏈表中插入或者刪除一個數據,咱們並不須要爲了保持內存的連續性而搬移結點,由於鏈表的存儲空間自己就不是連續的。因此,在鏈表中插入和刪除一個數據是很是快速的。

 

爲了方便你理解,我畫了一張圖,從圖中咱們能夠看出,針對鏈表的插入和刪除操做,咱們只須要考慮相鄰結點的指針改變,因此對應的時間複雜度是 O(1)。

 

 

 

可是,有利就有弊。鏈表要想隨機訪問第 k 個元素,就沒有數組那麼高效了。由於鏈表中的數據並不是連續存儲的,因此沒法像數組那樣,根據首地址和下標,經過尋址公式就能直接計算出對應的內存地址,而是須要根據指針一個結點一個結點地依次遍歷,直到找到相應的結點。

 

你能夠把鏈表想象成一個隊伍,隊伍中的每一個人都只知道本身後面的人是誰,因此當咱們但願知道排在第 k 位的人是誰的時候,咱們就須要從第一我的開始,一個一個地往下數。因此,鏈表隨機訪問的性能沒有數組好,須要 O(n) 的時間複雜度。

 

好了,單鏈表咱們就簡單介紹完了,接着來看另外兩個複雜的升級版,循環鏈表和雙向鏈表。+循環鏈表是一種特殊的單鏈表。實際上,循環鏈表也很簡單。它跟單鏈表惟一的區別就在尾結點。咱們知道,單鏈表的尾結點指針指向空地址,表示這就是最後的結點了。而循環鏈表的尾結點指針是指向鏈表的頭結點。從我畫的循環鏈表圖中,你應該能夠看出來,它像一個環同樣首尾相連,因此叫做「循環」鏈表。

 

 

 

和單鏈表相比,循環鏈表的優勢是從鏈尾到鏈頭比較方便。當要處理的數據具備環型結構特色時,就特別適合採用循環鏈表。好比著名的約瑟夫問題。儘管用單鏈表也能夠實現,可是用循環鏈表實現的話,代碼就會簡潔不少。

 

單鏈表和循環鏈表是否是都不難?接下來咱們再來看一個稍微複雜的,在實際的軟件開發中,也更加經常使用的鏈表結構:雙向鏈表。

 

單向鏈表只有一個方向,結點只有一個後繼指針 next 指向後面的結點。而雙向鏈表,顧名思義,它支持兩個方向,每一個結點不止有一個後繼指針 next 指向後面的結點,還有一個前驅指針 prev 指向前面的結點。

 

 

 

從我畫的圖中能夠看出來,雙向鏈表須要額外的兩個空間來存儲後繼結點和前驅結點的地址。因此,若是存儲一樣多的數據,雙向鏈表要比單鏈表佔用更多的內存空間。雖然兩個指針比較浪費存儲空間,但能夠支持雙向遍歷,這樣也帶來了雙向鏈表操做的靈活性。那相比單鏈表,雙向鏈表適合解決哪一種問題呢?

 

從結構上來看,雙向鏈表能夠支持 O(1) 時間複雜度的狀況下找到前驅結點,正是這樣的特色,也使雙向鏈表在某些狀況下的插入、刪除等操做都要比單鏈表簡單、高效。

 

你可能會說,我剛講到單鏈表的插入、刪除操做的時間複雜度已是 O(1) 了,雙向鏈表還能再怎麼高效呢?彆着急,剛剛的分析比較偏理論,不少數據結構和算法書籍中都會這麼講,可是這種說法其實是不許確的,或者說是有先決條件的。我再來帶你分析一下鏈表的兩個操做。

 

咱們先來看刪除操做

 

在實際的軟件開發中,從鏈表中刪除一個數據無外乎這兩種狀況:

  刪除結點中「值等於某個給定值」的結點;

  刪除給定指針指向的結點。

 

對於第一種狀況,無論是單鏈表仍是雙向鏈表,爲了查找到值等於給定值的結點,都須要從頭結點開始一個一個依次遍歷對比,直到找到值等於給定值的結點,而後再經過我前面講的指針操做將其刪除。

 

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

 

對於第二種狀況,咱們已經找到了要刪除的結點,可是刪除某個結點 q 須要知道其前驅結點,而單鏈表並不支持直接獲取前驅結點,因此,爲了找到前驅結點,咱們仍是要從頭結點開始遍歷鏈表,直到 p->==q,說明 p 是 q 的前驅結點。

 

可是對於雙向鏈表來講,這種狀況就比較有優點了。由於雙向鏈表中的結點已經保存了前驅結點的指針,不須要像單鏈表那樣遍歷。因此,針對第二種狀況,單鏈表刪除操做須要 O(n) 的時間複雜度,而雙向鏈表只須要在 O(1) 的時間複雜度內就搞定了!

 

同理,若是咱們但願在鏈表的某個指定結點前面插入一個結點,雙向鏈表比單鏈表有很大的優點。雙向鏈表能夠在 O(1) 時間複雜度搞定,而單向鏈表須要 O(n) 的時間複雜度。你能夠參照我剛剛講過的刪除操做本身分析一下。

 

除了插入、刪除操做有優點以外,對於一個有序鏈表,雙向鏈表的按值查詢的效率也要比單鏈表高一些。由於,咱們能夠記錄上次查找的位置 p,每次查詢時,根據要查找的值與 p 的大小關係,決定是往前仍是日後查找,因此平均只須要查找一半的數據。

 

如今,你有沒有以爲雙向鏈表要比單鏈表更加高效呢?這就是爲何在實際的軟件開發中,雙向鏈表儘管比較費內存,但仍是比單鏈表的應用更加普遍的緣由。若是你熟悉 Java 語言,你確定用過 LinkedHashMap 這個容器。若是你深刻研究

 

LinkedHashMap 的實現原理,就會發現其中就用到了雙向鏈表這種數據結構。

 

實際上,這裏有一個更加劇要的知識點須要你掌握,那就是用空間換時間的設計思想。當內存空間充足的時候,若是咱們更加追求代碼的執行速度,咱們就能夠選擇空間複雜度相對較高、但時間複雜度相對很低的算法或者數據結構。相反,若是內存比較緊缺,好比代碼跑在手機或者單片機上,這個時候,就要反過來用時間換空間的設計思路。

 

仍是開篇緩存的例子。緩存實際上就是利用了空間換時間的設計思想。若是咱們把數據存儲在硬盤上,會比較節省內存,但每次查找數據都要詢問一次硬盤,會比較慢。但若是咱們經過緩存技術,事先將數據加載在內存中,雖然會比較耗費內存空間,可是每次數據查詢的速度就大大提升了。

 

因此我總結一下,對於執行較慢的程序,能夠經過消耗更多的內存(空間換時間)來進行優化;而消耗過多內存的程序,能夠經過消耗更多的時間(時間換空間)來下降內存的消耗。你還能想到其餘時間換空間或者空間換時間的例子嗎?

 

瞭解了循環鏈表和雙向鏈表,若是把這兩種鏈表整合在一塊兒就是一個新的版本:雙向循環鏈表。我想不用我多講,你應該知道雙向循環鏈表長什麼樣子了吧?你能夠本身試着在紙上畫一畫。

 

 

鏈表 VS 數組性能大比拼

 

經過前面內容的學習,你應該已經知道,數組和鏈表是兩種大相徑庭的內存組織方式。正是由於內存存儲的區別,它們插入、刪除、隨機訪問操做的時間複雜度正好相反。

 

 

不過,數組和鏈表的對比,並不能侷限於時間複雜度。並且,在實際的軟件開發中,不能僅僅利用複雜度分析就決定使用哪一個數據結構來存儲數據。

 

數組簡單易用,在實現上使用的是連續的內存空間,能夠藉助 CPU 的緩存機制,預讀數組中的數據,因此訪問效率更高。而鏈表在內存中並非連續存儲,因此對 CPU 緩存不友好,沒辦法有效預讀。

 

數組的缺點是大小固定,一經聲明就要佔用整塊連續內存空間。若是聲明的數組過大,系統可能沒有足夠的連續內存空間分配給它,致使「內存不足(out of memory)」。若是聲明的數組太小,則可能出現不夠用的狀況。這時只能再申請一個更大的內存空間,把原數組拷貝進去,很是費時。鏈表自己沒有大小的限制,自然地支持動態擴容,我以爲這也是它與數組最大的區別。

 

你可能會說,咱們 Java 中的 ArrayList 容器,也能夠支持動態擴容啊?咱們上一節課講過,當咱們往支持動態擴容的數組中插入一個數據時,若是數組中沒有空閒空間了,就會申請一個更大的空間,將數據拷貝過去,而數據拷貝的操做是很是耗時的。

 

我舉一個稍微極端的例子。若是咱們用 ArrayList 存儲了了 1GB 大小的數據,這個時候已經沒有空閒空間了,當咱們再插入數據的時候,ArrayList 會申請一個 1.5GB 大小的存儲空間,而且把原來那 1GB 的數據拷貝到新申請的空間上。聽起來是否是就很耗時?

 

除此以外,若是你的代碼對內存的使用很是苛刻,那數組就更適合你。由於鏈表中的每一個結點都須要消耗額外的存儲空間去存儲一份指向下一個結點的指針,因此內存消耗會翻倍。並且,對鏈表進行頻繁的插入、刪除操做,還會致使頻繁的內存申請和釋放,容易形成內存碎片,若是是 Java 語言,就有可能會致使頻繁的 GC(Garbage Collection,垃圾回收)。

 

因此,在咱們實際的開發中,針對不一樣類型的項目,要根據具體狀況,權衡到底是選擇數組仍是鏈表。

 

解答開篇

 

好了,關於鏈表的知識咱們就講完了。咱們如今回過頭來看下開篇留給你的思考題。如何基於鏈表實現+LRU+緩存淘汰算法?+個人思路是這樣的:咱們維護一個有序單鏈表,越靠近鏈表尾部的結點是越早以前訪問的。當有一個新的數據被訪問時,咱們從鏈表頭開始順序遍歷鏈表。

 

1. 若是此數據以前已經被緩存在鏈表中了,咱們遍歷獲得這個數據對應的結點,並將其從原來的位置刪除,而後再插入到鏈表的頭部。

 

2. 若是此數據沒有在緩存鏈表中,又能夠分爲兩種狀況:

  若是此時緩存未滿,則將此結點直接插入到鏈表的頭部;

  若是此時緩存已滿,則鏈表尾結點刪除,將新的數據結點插入鏈表的頭部。+這樣咱們就用鏈表實現了一個+LRU+緩存,是否是很簡單?

 

如今咱們來看下 m 緩存訪問的時間複雜度是多少。由於無論緩存有沒有滿,咱們都須要遍歷一遍鏈表,因此這種基於鏈表的實現思路,緩存訪問的時間複雜度爲 O(n)。

 

實際上,咱們能夠繼續優化這個實現思路,好比引入散列表(Hash table)來記錄每一個數據的位置,將緩存訪問的時間複雜度降到 O(1)。由於要涉及咱們尚未講到的數據結構,因此這個優化方案,我如今就不詳細說了,等講到散列表的時候,我會再拿出來說。

 

除了基於鏈表的實現思路,實際上還能夠用數組來實現 LRU 緩存淘汰策略。如何利用數組實現 LRU 緩存淘汰策略呢?我把這個問題留給你思考。

 

內容小結

 

今天咱們講了一種跟數組「相反」的數據結構,鏈表。它跟數組同樣,也是很是基礎、很是經常使用的數據結構。不過鏈表要比數組稍微複雜,從普通的單鏈表衍生出來好幾種鏈表結構,好比雙向鏈表、循環鏈表、雙向循環鏈表。

 

和數組相比,鏈表更適合插入、刪除操做頻繁的場景,查詢的時間複雜度較高。不過,在具體軟件開發中,要對數組和鏈表的各類性能進行對比,綜合來選擇使用二者中的哪個。

 

課後思考

 

如何判斷一個字符串是不是迴文字符串的問題,我想你應該聽過,咱們今天的思題目就是基於這個問題的改造版本。若是字符串是經過單鏈表來存儲的,那該如何來判斷是是一個迴文串呢?你有什麼好的解決思路呢?相應的時間空間複雜度又是多少呢?

相關文章
相關標籤/搜索