(二)链表
文章目錄
- 分類
- 1.1 單向鏈表
- 1.2 循環鏈表
- 1.3 雙向鏈表
- 鏈表VS數組
- 如何基于鏈表實現 LRU 緩存淘汰算法?
- 手寫鏈表的注意事項
鏈表和數組一樣,是非常常用和基礎的數據結構,都屬于線性表結構。
? ? ? ?數組需要一塊連續的內存空間來存儲,對內存的要求比較高。如果我們申請一個 100MB 大小的數組,當內存中沒有連續的、足夠大的存儲空間時,即便內存的剩余總可用空間大于 100MB,仍然會申請失敗。
? ? ? ?而鏈表恰恰相反,它并不需要一塊連續的內存空間,它通過“指針”將一組零散的內存塊串聯起來使用,所以如果我們申請的是 100MB 大小的鏈表,根本不會有問題。
分類
1.1 單向鏈表
? ? ? ?鏈表通過指針將一組零散的內存塊串聯在一起。其中,我們把內存塊稱為鏈表的“結點”。為了將所有的結點串起來,每個鏈表的結點除了存儲數據之外,還需要記錄鏈上的下一個結點的地址。如圖所示,我們把這個記錄下個結點地址的指針叫作后繼指針 next。
? ? ? ?其中有兩個結點是比較特殊的,它們分別是第一個結點和最后一個結點。我們習慣性地把第一個結點叫作頭結點,把最后一個結點叫作尾結點。其中,頭結點用來記錄鏈表的基地址。有了它,我們就可以遍歷得到整條鏈表。而尾結點特殊的地方是:指針不是指向下一個結點,而是指向一個空地址 NULL,表示這是鏈表上最后一個結點。
? ? ? ?鏈表的插入和刪除是非常快的。在進行數組的插入、刪除操作時,為了保持內存數據的連續性,需要做大量的數據搬移,所以時間復雜度是 O(n)。而在鏈表中插入或者刪除一個數據,我們并不需要為了保持內存的連續性而搬移結點,因為鏈表的存儲空間本身就不是連續的。所以,在鏈表中插入和刪除一個數據是非常快速的,時間復雜度是O(1)(這里指的是單純的插入和刪除操作,如果插入和刪除之前需要查找肯定就不是了)。
? ? ? ?鏈表要想隨機訪問第 k 個元素,就沒有數組那么高效了。因為鏈表中的數據并非連續存儲的,所以無法像數組那樣,根據首地址和下標,通過尋址公式就能直接計算出對應的內存地址,而是需要根據指針一個結點一個結點地依次遍歷,直到找到相應的結點。時間復雜度是O(n) 。
1.2 循環鏈表
? ? ? ?循環鏈表跟單鏈表唯一的區別就在尾結點。我們知道,單鏈表的尾結點指針指向空地址,表示這就是最后的結點了。而循環鏈表的尾結點指針是指向鏈表的頭結點。
? ? ? ?和單鏈表相比,循環鏈表的優點是從鏈尾到鏈頭比較方便。當要處理的數據具有環型結構特點時,就特別適合采用循環鏈表。比如著名的約瑟夫問題。
1.3 雙向鏈表
? ? ? ?實際開發應用中最多的還是雙向鏈表,它支持兩個方向,每個結點不止有一個后繼指針 next 指向后面的結點,還有一個前驅指針 prev 指向前面的結點。
? ? ? ? 雙向鏈表需要額外的兩個空間來存儲后繼結點和前驅結點的地址。所以如果存儲同樣多的數據,雙向鏈表要比單鏈表占用更多的內存空間。雖然兩個指針比較浪費存儲空間,但可以支持雙向遍歷,這樣也帶來了雙向鏈表操作的靈活性。
? ? ? ? 從結構上來看,雙向鏈表可以支持 O(1) 時間復雜度的情況下找到前驅結點,正是這樣的特點,也使雙向鏈表在某些情況下的插入、刪除等操作都要比單鏈表簡單、高效。為什么這么說呢?
在實際的軟件開發中,從鏈表中刪除一個數據無外乎這兩種情況:
- 刪除結點中“值等于某個給定值”的結點;
- 刪除給定指針指向的結點。
? ? ? ? 對于第一種情況,不管是單鏈表還是雙向鏈表,為了查找到值等于給定值的結點,都需要從頭結點開始一個一個依次遍歷對比,直到找到值等于給定值的結點,然后再通過我前面講的指針操作將其刪除。盡管單純的刪除操作時間復雜度是 O(1),但遍歷查找的時間是主要的耗時點,對應的時間復雜度為 O(n)。刪除值等于給定值的結點對應的鏈表操作的總時間復雜度為 O(n)。
? ? ? ? 對于第二種情況,我們已經找到了要刪除的結點,但是刪除某個結點 q 需要知道其前驅結點,而單鏈表并不支持直接獲取前驅結點,所以,為了找到前驅結點,我們還是要從頭結點開始遍歷鏈表,直到 p->next=q,說明 p 是 q 的前驅結點。但是對于雙向鏈表來說,這種情況就比較有優勢了。因為雙向鏈表中的結點已經保存了前驅結點的指針,不需要像單鏈表那樣遍歷。所以,針對第二種情況,單鏈表刪除操作需要 O(n) 的時間復雜度,而雙向鏈表只需要在 O(1) 的時間復雜度內就搞定了。
? ? ? ?對于一個有序鏈表,雙向鏈表的按值查詢的效率也要比單鏈表高一些。因為,我們可以記錄上次查找的位置 p,每次查詢時,根據要查找的值與 p 的大小關系,決定是往前還是往后查找,所以平均只需要查找一半的數據。
? ? ? ?現在,你有沒有覺得雙向鏈表要比單鏈表更加高效呢?這就是為什么在實際的軟件開發中,雙向鏈表盡管比較費內存,但還是比單鏈表的應用更加廣泛的原因。Java 語言中的LinkedList和LinkedHashMap 容器實現原理就用到了雙向鏈表這種數據結構。
? ? ? ?實際上,這里有一個空間換時間的設計思想。當內存空間充足的時候,如果我們更加追求代碼的執行速度,我們就可以選擇空間復雜度相對較高、但時間復雜度相對很低的算法或者數據結構。相反,如果內存比較緊缺,比如代碼跑在手機或者單片機上,這個時候,就要反過來用時間換空間的設計思路。還是開篇緩存的例子。緩存實際上就是利用了空間換時間的設計思想。如果我們把數據存儲在硬盤上,會比較節省內存,但每次查找數據都要詢問一次硬盤,會比較慢。但如果我們通過緩存技術,事先將數據加載在內存中,雖然會比較耗費內存空間,但是每次數據查詢的速度就大大提高了。
鏈表VS數組
數組和鏈表是兩種截然不同的內存組織方式。正是因為內存存儲的區別,它們插入、刪除、隨機訪問操作的時間復雜度正好相反。
? ? ? ?數組簡單易用,在實現上使用的是連續的內存空間,可以借助 CPU 的緩存機制,預讀數組中的數據,所以訪問效率更高。而鏈表在內存中并不是連續存儲,所以對 CPU 緩存不友好,沒辦法有效預讀。
? ? ? ?數組的缺點是大小固定,一經聲明就要占用整塊連續內存空間。如果聲明的數組過大,系統可能沒有足夠的連續內存空間分配給它,導致“內存不足(out of memory)”。如果聲明的數組過小,則可能出現不夠用的情況。這時只能再申請一個更大的內存空間,把原數組拷貝進去,非常費時。
? ? ? ? 鏈表本身沒有大小的限制,天然地支持動態擴容,我覺得這也是它與數組最大的區別。
? ? ? ? 如果你的代碼對內存的使用非常苛刻,那數組就更適合你。因為鏈表中的每個結點都需要消耗額外的存儲空間去存儲一份指向下一個結點的指針,所以內存消耗會翻倍。而且對鏈表進行頻繁的插入、刪除操作,還會導致頻繁的內存申請和釋放,容易造成內存碎片,就有可能會導致頻繁的 GC(Garbage Collection,垃圾回收)。
? ? ? ? 和數組相比,鏈表更適合插入、刪除操作頻繁的場景,查詢的時間復雜度較高。在具體軟件開發中,要對數組和鏈表的各種性能進行對比,綜合來選擇使用兩者中的哪一個。
如何基于鏈表實現 LRU 緩存淘汰算法?
? ? ? ? 緩存是一種提高數據讀取性能的技術,比如常見的 CPU 緩存、數據庫緩存、瀏覽器緩存等等。緩存的大小有限,當緩存被用滿時,哪些數據應該被清理出去,哪些數據應該被保留?這就需要緩存淘汰策略來決定。常見的策略有三種:先進先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。
如何基于鏈表實現 LRU 緩存淘汰算法?
? ? ? ? 我們維護一個有序單鏈表,越靠近鏈表尾部的結點是越早之前訪問的。當有一個新的數據被訪問時,我們從鏈表頭開始順序遍歷鏈表。
? ? ? ? 因為不管緩存有沒有滿,我們都需要遍歷一遍鏈表,所以這種基于鏈表的實現思路,緩存訪問的時間復雜度為 O(n)。實際上,我們可以繼續優化這個實現思路,比如引入散列表(Hash table)來記錄每個數據的位置,將緩存訪問的時間復雜度降到 O(1)。
手寫鏈表的注意事項
? ? ? ? 寫鏈表代碼是最考驗邏輯思維能力的。因為鏈表代碼到處都是指針的操作、邊界條件的處理,稍有不慎就容易產生 Bug。鏈表代碼寫得好壞,可以看出一個人寫代碼是否夠細心,考慮問題是否全面,思維是否縝密。
- 利用哨兵簡化實現難度
- 注意邊界值的處理,要把正常情況和以下特殊情況都分析檢驗一下:鏈表為空,鏈表只包含一個結點,鏈表只包含兩個結點等等。
- 多畫圖舉例 。把它畫在紙上,釋放一些腦容量,留更多的給邏輯思考,這樣就會感覺到思路清晰很多。
- 多寫多練。寫個十幾遍,背也能背下來了,當然并不提倡背,重要的還是記住算法思想
總結
- 上一篇: 数据结构和算法之时间复杂度
- 下一篇: synchronized的底层原理