面試官: 既然已經有數組了,為什么還要鏈表
本文發布於微信平台: 程序員面試官
超過20w字的「前端面試與進階指南」可以移步github
對於不少開發者而言,鏈表(linked list)這種數據結構既熟悉又陌生,熟悉是因為它確實是非常基礎的數據結構,陌生的原因是我們在業務開發中用到它的幾率的確不大.
在很多情況下,我們用數組就能很好的完成工作,而且不會產生太多的差異,那么鏈表存在的意義是什么?鏈表相比於數組有什么優勢或者不足嗎?
什么是鏈表
鏈表是一種常見的基礎數據結構,是一種線性表,但是並不會按線性的順序存儲數據,而是在每一個節點里存到下一個節點的指針(Pointer).
從本質上來講,鏈表與數組的確有相似之處,他們的相同點是都是線性數據結構,這與樹和圖不同,而它們的不同之處在於數組是一塊連續的內存,而鏈表可以不是連續內存,鏈表的節點與節點之間通過指針來聯系.
當然,鏈表也有不同的形態,主要分為三種:單向鏈表、雙向鏈表、循環鏈表.
單向鏈表
單向鏈表的節點通常由兩個部分構成,一個是節點儲存的值val
,另一個就是節點的指針next
.
鏈表與數組類似,也可以進行查找、插入、刪除、讀取等操作,但是由於鏈表與數組的特性不同,導致不同操作的復雜度也不同.
查找性能
單向鏈表的查找操作通常是這樣的:
- 從頭節點進入,開始比對節點的值,如果不同則通過指針進入下一個節點
- 重復上面的動作,直到找到相同的值,或者節點的指針指向null
鏈表的查找性能與數組一樣,都是時間復雜度為O(n).
插入刪除性能
鏈表與數組最大的不同就在於刪除、插入的性能優勢,由於鏈表是非連續的內存,所以不用像數組一樣在插入、刪除操作的時候需要進行大面積的成員位移,比如在a、b節點之間插入一個新節點c,鏈表只需要:
- a斷開指向b的指針,將指針指向c
- c節點將指針指向b,完畢
這個插入操作僅僅需要移動一下指針即可,插入、刪除的時間復雜度只有O(1).
鏈表的插入操作如下:
鏈表的刪除操作如下:
讀取性能
鏈表相比之下也有劣勢,那就是讀取操作遠不如數組,數組的讀取操作之所以高效,是因為它是一塊連續內存,數組的讀取可以通過尋址公式快速定位,而鏈表由於非連續內存,所以必須通過指針一個一個節點遍歷.
比如,對於一個數組,我們要讀取第三個成員,我們僅需要arr[2]
就能快速獲取成員,而鏈表則需要從頭部節點進入,在通過指針進入后續節點才能讀取.
應用場景
由於雙向鏈表的存在,單向鏈表的應用場景比較少,因為很多場景雙向鏈表可以更出色地完成.
但是單向鏈表並非無用武之地,在以下場景中依然會有單向鏈表的身影:
- 撤銷功能,這種操作最多見於各種文本、圖形編輯器中,撤銷重做在編輯器場景下屬於家常便飯,單向鏈表由於良好的刪除特性在這個場景很適用
- 實現圖、hashMap等一些高級數據結構
雙向鏈表
我們上文已經提到,單向鏈表的應用場景並不多,而真正在生產環境中被廣泛運用的正是雙向鏈表.
雙向鏈表與單向鏈表相比有何特殊之處?
我們看到雙向鏈表多了一個新的指針prev
指向節點的前一個節點,當然由於多了一個指針,所以雙向鏈表要更占內存.
別小看雙向鏈表多了一個前置指針,在很多場景里由於多了這個指針,它的效率更高,也更加實用.
比如編輯器的「undo/redo」操作,雙向鏈表就更加適用,由於擁有前后指針,用戶可以自由得進行前后操作,如果這個是一個單向鏈表,那么用戶需要遍歷鏈表這時的時間復雜度是O(n).
真正生產級應用中的編輯器采用的數據結構和設計模式更加復雜,比如Word就是采用Piece Table數據結構加上Command queue模式實現「undo/redo」的,不過這是后話了.
循環鏈表
循環鏈表,顧名思義,他就是將單向鏈表的尾部指針指向了鏈表頭節點:
循環鏈表一個應用場景就是操作系統的分時問題,比如有一台計算機,但是有多個用戶使用,CPU要處理多個用戶的請求很可能會出現搶占資源的情況,這個時候計算機會采取分時策略來保證每個用戶的使用體驗.
每個用戶都可以看成循環鏈表上的節點,CPU會給每個節點分配一定的處理時間,在一定的處理時間后進入下一個節點,然后無限循環,這樣可以保證每個用戶的體驗,不會出現一個用戶搶占CPU而導致其他用戶無法響應的情況.
當然,約瑟夫環的問題是單向循環鏈表的代表性應用,感興趣的可以深入了解下.
當然如果是雙向鏈表首尾相接呢?這就是雙向循環鏈表.
在Node中有一類場景,沒有查詢,但是卻有大量的插入和刪除,這就是Timer模塊。
幾乎所有的網絡I/O請求,都會提供timeout操作控制socket的超時狀況,這里就會大量使用到setTimeout,並且這些timeout定時器,絕大部分都是用不到的(數據按時正常響應),那么又會有響應的大量clearTimeout操作,因此node采用了雙向循環鏈表來提高Timer模塊的性能,至於其中的細節就不再贅述了.
插入!
TimersList <-----> timer1 <-----> timer2 <-----> timer4 <-----> timer3 <-----> ......
1000ms后執行 1050ms后執行 1100ms后執行 1200ms后執行
小結
至此,我們對鏈表這個數據結構有了一定的認知,由於其非連續內存的特性導致鏈表非常適用於頻繁插入、刪除的場景,而不見長於讀取場景,這跟數組的特性恰好形成互補,所以現在也可以回到題目中的問題了,鏈表的特性與數組互補,各有所長,而且鏈表由於指針的存在可以形成環形鏈表,在特定場景也非常有用,因此鏈表的存在是很有必要的。
那么,現在有一個非常常見的一個面試向的思考題:
我們平時在用的微信小程序會有最近使用的功能,時間最近的在最上面,按照時間順序往后排,當用過的小程序大於一定數量后,最不常用的小程序就不會出現了,你會如何設計這個算法?