1. 程式人生 > >資料結構與演算法之連結串列篇(上)

資料結構與演算法之連結串列篇(上)

連結串列作為一種基礎的資料結構之一,我們會常常使用到它,接下來就讓我們一起學習吧。


1、連結串列的經典應用場景: LRU快取淘汰演算法。

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

3、快取的大小有限,當快取被用滿時,哪些資料應該被保留,這就需要快取淘汰策略來決定。常見的策略有三種:先進先出策略(FIFO)、最少使用策略(LFU)、最近最少使用策略(LRU)。

4、問題Q:如何使用連結串列來實現LRU快取淘汰策略呢?

5、五花八門的連結串列結構

 (1)、陣列和連結串列的區別

 舉例說明:

如果我們申請一個100MB的陣列,當記憶體中沒有連續的、足夠大的儲存空間時,即便記憶體的剩餘總可用空間大於100MB,仍然會申請失敗。

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

 6、三種常見的連結串列結構:單鏈表、雙向連結串列和迴圈連結串列。

(1)、單鏈表

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

 

從上述圖中,你應該可以發現,其中兩個節點比較特殊,他們分別是第一個結點和最後一個結點。我們習慣性地把第一個結點叫做頭結點,把最後一個結點叫做尾結點。其中,頭結點用來記錄連結串列的基地址。有了它,我們就可以遍歷得到整條連結串列。而尾結點特殊的地方是:指標不是指向下一個結點,而是指向一個空地址NULL,表示這是連結串列上最後一個結點。

b、連結串列的查詢、插入和刪除操作

在連結串列中插入或者刪除一個數據,我們並不需要為了保持記憶體的連續性而搬移結點,因為連結串列的儲存空間本身就不是連續的。所以,在連結串列中插入和刪除一個數據是非常快速的。

如下圖,從圖中我們可以看出,針對連結串列的插入和刪除操作,我們只需要考慮相鄰結點的指標改變,所以對應的時間複雜度是O(1)。

但是,連結串列想要隨機訪問第K個元素,就沒有陣列那麼高效了。因為連結串列中的資料並不是連續儲存的,所以無法像陣列那樣,根據首地址和下標,通過定址公式就能直接計算出對應的記憶體地址,而是要根據指標一個結點一個結點地依次遍歷,直到找到相應的結點,需要的時間複雜度為O(n)。

 (2)、迴圈連結串列

迴圈連結串列是一種特殊的單鏈表,跟單鏈表的區別在於尾結點。單鏈表的尾結點指向空地址,表示這就是最後的結點了。而迴圈連結串列的尾結點指標是指向連結串列的頭結點。如下圖

 

和單鏈表相比,迴圈連結串列的優點是從鏈尾到鏈頭比較方便,當要處理的資料具有環形結構特點時,就特別適合採用迴圈連結串列。比如:約瑟夫問題。

(3)、雙向連結串列

雙向連結串列:它支援兩個方向,每個結點不止有一個後繼指標next指向後面的結點,還有一個前驅指標prev指向前面的結點。

 

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

在實際的軟體開發中,從連結串列中刪除一個數據無外乎兩種情況:

      >>刪除節點中"直等於某個給定值"的結點;

       >>刪除給定指標指向的結點;

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

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

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

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

 實際上,這裡有一個更加重要的知識點需要你掌握,那就是用空間換時間的設計思想。當記憶體空間充足的時候,如果我們更加追求程式碼的執行速度,我們就可以選擇空間複雜度相對較高,但是時間複雜度相對低的演算法或者資料結構。相反如果記憶體比較緊缺,比如程式碼跑在手機或微控制器上,這個時候,就要反過來用時間換空間的設計思路。

總結一下,對執行較慢的程式,可以通過消耗更多的記憶體(空間換時間)來進行優化;而消耗過多的記憶體程式,可以通過消耗更多的時間(時間換空間)來降低記憶體的消耗。

(4)、迴圈雙向連結串列

 

7、連結串列VS陣列效能大比拼

陣列和連結串列是兩種截然不同的記憶體組織方式。正是因為記憶體儲存的區別,它們插入、刪除、隨機訪問操作的時間複雜度正好相反。

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

8、解答問題Q:如何基於連結串列實現LRU快取淘汰演算法?

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

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

2、如果此資料沒有在快取連結串列中,又可以分為兩種情況:

i、如果此時快取未滿,則將此結點直接插入到連結串列的頭部;

ii、如果此時快取已滿,則連結串列尾結點刪除,將新的資料結點插入連結串列的頭部。

 


歡迎大家掃碼關注微信公眾號,其中含有有大量免費的人工智慧、影象處理、IT資料:

                                                                              Change,There is no better way!