精衛填海系列——鏈表

鏈表的定義

相比數組,鏈表是一種稍微複雜一點的數據結構。

 

數組(Array)是一種線性表數據結構。它用連續的內存空間,來存儲一組具有相同類型的數據。

鏈表(Linked list)也是一種線性表數據結構。但是它並不需要連續的內存空間,它通過“指針”用零散的內存塊串聯起來使用。

鏈表的分類

常見的有單鏈表、循環鏈表和雙向鏈表。

單鏈表

我們剛剛講到,鏈表通過指針將一組零散的內存塊串聯在一起。其中,我們把內存塊稱爲鏈表的“結點”。爲了將所有的結點串起來,每個鏈表的結點除了存儲數據之外,還需要記錄鏈表上的下一個結點的地址。

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

循環鏈表

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

 

和單鏈表比,循環鏈表的優點是從鏈尾到鏈頭比較方便。當要處理的數據具有環形結構特點時,就特別適合採用循環鏈表。比如著名的約瑟夫問題。儘管用單鏈表也可以實現,但是用循環鏈表實現的話,代碼就會簡潔很多。

雙向鏈表

單鏈表和循環鏈表還是比較好理解的,接下來我們再來看看一個稍微複雜的,在實際開發中也更加常用的鏈表結構:雙向鏈表。

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

 

雙向鏈表需要額外的兩個空間來存儲後繼結點和前驅結點的地址。所以,存儲同樣多的數據,雙向鏈表要比單鏈表佔用更多的內存空間。雖然兩個指針比較浪費存儲空間,但可以支持雙向遍歷,這樣也帶來了雙向鏈表操作的靈活性。那相比單鏈表,雙向鏈表適合解決哪種問題呢?

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

鏈表的插入與刪除

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

我們知道,在進行數組的插入、刪除操作時,爲了保持內存數據的連續性,需要做大量的數據搬移,所以時間複雜度是O(n)。而鏈表中插入或者刪除一個數據,我們並不需要爲了保持內存數據的連續性而搬移結點,因爲鏈表的存儲快捷鍵本身就不是連續的。所以,在鏈表中插入和刪除一個數據是非常快速的。

我們只需要考慮相鄰結點的指針變化,所以對應的時間複雜度是O(1)。

 

但是,有利就有弊。鏈表要想隨機訪問第k個元素,就沒有數組那麼高效了。因爲鏈表中的數據並非連續存儲的,所以無法像數組那樣,根據首地址和下標,通過尋址公式就能直接計算出對應的內存地址,而是需要根據指針一個結點一個結點地依次遍歷,直到找到相應的結點,需要O(n)的時間複雜度。

單鏈表的插入、刪除操作的時間複雜度已經是O(1)了,雙向鏈表還能再怎麼高效呢?

我們先來看看刪除操作

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

  • 刪除結點中“值等於某個給定值”的結點
  • 刪除給定指針指向的結點

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

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

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

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

插入操作也是同樣的道理。

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

所以說,前面講到的單鏈表的插入、刪除操作的時間複雜度是O(1)。這種說法實際上是不準確的,或者說是有先決條件的。

時間換空間

當內存空間充足的時候,如果我們更加追求代碼的執行速度,我們可以選擇空間複雜度相對較高、但時間複雜度相對較低的算法或者數據結構。反之亦然。

所以在實際的軟件開發中,雙鏈表儘管比較費內存,但還是比單鏈表的應用更加廣泛。如果你熟悉Java語言,並研究過LinkedList的底層源碼,就會發現其中有用到雙向鏈表這種數據構。

數組VS鏈表性能大比拼

通過這兩節內容的學習,我們已經知道,數組和鏈表是兩種截然不同的內存組織方式。正是因爲內存存儲的區別,他們插入、刪除、隨機訪問操作的時間複雜度正好相反。

 

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

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

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

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

所以,在我們實際開發中,針對不同類型的項目,要根據具體情況,權衡究竟是選擇數組還是鏈表。

學習了王爭老師的《數據結構與算法之美》,根據課程內容整理的筆記

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章