1. 程式人生 > 實用技巧 >資料結構與演算法——連結串列

資料結構與演算法——連結串列

原文連結:https://jiang-hao.com/articles/2020/algorithms-data-struct-linkedlist.html

目錄

定義

相比陣列,連結串列是一種稍微複雜一點的資料結構。對於兩者,我們常常將會放到一塊兒來比較。

從圖中我們看到,陣列需要一塊連續的記憶體空間來儲存,對記憶體的要求比較高。如果我們申請一個 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->next=q,說明 p 是 q 的前驅結點。

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

同理,如果我們希望在連結串列的某個指定結點前面插入一個結點,雙向連結串列比單鏈表有很大的優勢。雙向連結串列可以在 O(1) 時間複雜度搞定,而單向連結串列需要 O(n) 的時間複雜度。

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

現在,你有沒有覺得雙向連結串列要比單鏈表更加高效呢?這就是為什麼在實際的軟體開發中,雙向連結串列儘管比較費記憶體,但還是比單鏈表的應用更加廣泛的原因。如果你熟悉 Java 語言,你肯定用過 LinkedHashMap 這個容器。如果你深入研究 LinkedHashMap 的實現原理,就會發現其中就用到了雙向連結串列這種資料結構。

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

快取實際上就是利用了空間換時間的設計思想。如果我們把資料儲存在硬碟上,會比較節省記憶體,但每次查詢資料都要詢問一次硬碟,會比較慢。但如果我們通過快取技術,事先將資料載入在記憶體中,雖然會比較耗費記憶體空間,但是每次資料查詢的速度就大大提高了。

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

雙向迴圈連結串列

瞭解了迴圈連結串列和雙向連結串列,如果把這兩種連結串列整合在一起就是一個新的版本:雙向迴圈連結串列。

連結串列VS陣列

通過前面內容的學習,你應該已經知道,陣列和連結串列是兩種截然不同的記憶體組織方式。正是因為記憶體儲存的區別,它們插入、刪除、隨機訪問操作的時間複雜度正好相反。

不過,陣列和連結串列的對比,並不能侷限於時間複雜度。而且,在實際的軟體開發中,不能僅僅利用複雜度分析就決定使用哪個資料結構來儲存資料。

陣列簡單易用,在實現上使用的是連續的記憶體空間,可以藉助 CPU 的快取機制,預讀陣列中的資料,所以訪問效率更高。而連結串列在記憶體中並不是連續儲存,所以對 CPU 快取不友好,沒辦法有效預讀。

CPU在從記憶體讀取資料的時候,會先把讀取到的資料載入到CPU的快取中。而CPU每次從記憶體讀取資料並不是只讀取那個特定要訪問的地址,而是讀取一個數據塊並儲存到CPU快取中,然後下次訪問記憶體資料的時候就會先從CPU快取開始查詢,如果找到就不需要再從記憶體中取。這樣就實現了比記憶體訪問速度更快的機制,也就是CPU快取存在的意義:為了彌補記憶體訪問速度過慢與CPU執行速度快之間的差異而引入。

比如為了充分利用CPU的快取記憶體的高速訪問,Java的開源專案Disruptor(Disruptor 可謂是將硬體用到了極致,執行緒和CPU 核心之間的親和性都考慮到了)就是通過快取行填充這個策略來保持資料保持在CPU的快取記憶體中。得益於CPU快取從記憶體中載入資料是按塊載入(也可以說一整個快取行,看作業系統位數決定),陣列的連續地址空間會一併載入到CPU的快取記憶體中,訪問速度和記憶體不是一個量級。

對於陣列來說,儲存空間是連續的,所以在載入某個下標的時候可以把以後的幾個下標元素也載入到CPU快取這樣執行速度會快於儲存空間不連續的連結串列儲存。

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

你可能會說,我們 Java 中的 ArrayList 容器,也可以支援動態擴容啊?我們上一節課講過,當我們往支援動態擴容的陣列中插入一個數據時,如果陣列中沒有空閒空間了,就會申請一個更大的空間,將資料拷貝過去,而資料拷貝的操作是非常耗時的。

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

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

所以,在我們實際的開發中,針對不同型別的專案,要根據具體情況,權衡究竟是選擇陣列還是連結串列。

基於連結串列實現 LRU 快取淘汰演算法

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

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

如何基於連結串列實現 LRU 快取淘汰演算法?思路是這樣的:我們維護一個有序單鏈表,越靠近連結串列尾部的結點是越早之前訪問的。當有一個新的資料被訪問時,我們從連結串列頭開始順序遍歷連結串列。

  1. 如果此資料之前已經被快取在連結串列中了,我們遍歷得到這個資料對應的結點,並將其從原來的位置刪除,然後再插入到連結串列的頭部。
  2. 如果此資料沒有在快取連結串列中,又可以分為兩種情況:
    • 如果此時快取未滿,則將此結點直接插入到連結串列的頭部;
    • 如果此時快取已滿,則連結串列尾結點刪除,將新的資料結點插入連結串列的頭部。

這樣我們就用連結串列實現了一個 LRU 快取。

現在我們來看下快取訪問的時間複雜度是多少。因為不管快取有沒有滿,我們都需要遍歷一遍連結串列,所以這種基於連結串列的實現思路,快取訪問的時間複雜度為 O(n)。

實際上,我們可以繼續優化這個實現思路,比如引入散列表(Hash table)來記錄每個資料的位置,將快取訪問的時間複雜度降到 O(1)。

單鏈表的迴文字串判斷

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

我們可以使用快慢兩個指標找到連結串列中點,慢指標每次前進一步,快指標每次前進兩步。在慢指標前進的過程中,同時修改其 next 指標,使得連結串列前半部分反序。最後比較中點兩側的連結串列是否相等。

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) { val = x; }
 * }
 */
class Solution {
  public boolean isPalindrome(ListNode head) {
    if (head == null || head.next == null) {
      return true;
    }

    ListNode prev = null;
    ListNode slow = head;
    ListNode fast = head;

    while (fast != null && fast.next != null) {
      fast = fast.next.next;
      ListNode next = slow.next;
      slow.next = prev;
      prev = slow;
      slow = next;
    }

    if (fast != null) {
      slow = slow.next;
    }

    while (slow != null) {
      if (slow.val != prev.val) {
        return false;
      }
      slow = slow.next;
      prev = prev.next;
    }

    return true;
  }
}

時間複雜度:O(n)
空間複雜度:O(1)

關於空間複雜度是O(1)而不是O(n)的說明:要看為解決問題而開支的額外的記憶體消耗,不是看連結串列本身儲存需要多少空間。

連結串列配合棧也可以完成,不過空間複雜度就O(n)了。

如何寫好連結串列程式碼

想要寫好連結串列程式碼並不是容易的事兒,尤其是那些複雜的連結串列操作,比如連結串列反轉、有序連結串列合併等,寫的時候非常容易出錯。一般情況下,往往能把“連結串列反轉”這幾行程式碼寫對的人不足 10%。

為什麼連結串列程式碼這麼難寫?究竟怎樣才能比較輕鬆地寫出正確的連結串列程式碼呢?

只要願意投入時間,大多數人都是可以學會的。比如說,如果你真的能花上一個週末或者一整天的時間,就去寫連結串列反轉這一個程式碼,多寫幾遍,一直練到能毫不費力地寫出 Bug free 的程式碼。這個坎還會很難跨嗎?

當然,自己有決心並且付出精力是成功的先決條件,除此之外,我們還需要一些方法和技巧。下面總結了幾個寫連結串列程式碼技巧。如果你能熟練掌握這幾個技巧,加上你的主動和堅持,輕鬆拿下連結串列程式碼完全沒有問題。

技巧一:理解指標或引用的含義

事實上,看懂連結串列的結構並不是很難,但是一旦把它和指標混在一起,就很容易讓人摸不著頭腦。所以,要想寫對連結串列程式碼,首先就要理解好指標。我們知道,有些語言有“指標”的概念,比如 C 語言;有些語言沒有指標,取而代之的是“引用”,比如 Java、Python。不管是“指標”還是“引用”,實際上,它們的意思都是一樣的,都是儲存所指物件的記憶體地址。接下來,我會拿 C 語言中的“指標”來講解,如果你用的是 Java 或者其他沒有指標的語言也沒關係,你把它理解成“引用”就可以了。實際上,對於指標的理解,你只需要記住下面這句話就可以了:

將某個變數賦值給指標,實際上就是將這個變數的地址賦值給指標,或者反過來說,指標中儲存了這個變數的記憶體地址,指向了這個變數,通過指標就能找到這個變數。

這句話聽起來還挺拗口的,你可以先記住。我們回到連結串列程式碼的編寫過程中,我來慢慢給你解釋。在編寫連結串列程式碼的時候,我們經常會有這樣的程式碼:p->next=q。這行程式碼是說,p 結點中的 next 指標儲存了 q 結點的記憶體地址。還有一個更復雜的,也是我們寫連結串列程式碼經常會用到的:p->next=p->next->next。這行程式碼表示,p 結點的 next 指標儲存了 p 結點的下下一個結點的記憶體地址。

掌握了指標或引用的概念,你應該可以很輕鬆地看懂連結串列程式碼。

技巧二:警惕指標丟失和記憶體洩漏

不知道你有沒有這樣的感覺,寫連結串列程式碼的時候,指標指來指去,一會兒就不知道指到哪裡了。所以,我們在寫的時候,一定注意不要弄丟了指標。

指標往往都是怎麼弄丟的呢?我們拿上圖表中的單鏈表的插入操作為例來分析一下。

假設我們希望在結點 a 和相鄰的結點 b 之間插入結點 x,假設當前指標 p 指向結點 a。如果我們將程式碼實現變成下面這個樣子,就會發生指標丟失和記憶體洩露。

p->next = x;  // 將p的next指標指向x結點;
x->next = p->next;  // 將x的結點的next指標指向b結點;

初學者經常會在這兒犯錯。p->next 指標在完成第一步操作之後,已經不再指向結點 b 了,而是指向結點 x。第 2 行程式碼相當於將 x 賦值給 x->next,自己指向自己。因此,整個連結串列也就斷成了兩半,從結點 b 往後的所有結點都無法訪問到了。

對於有些語言來說,比如 C 語言,記憶體管理是由程式設計師負責的,如果沒有手動釋放結點對應的記憶體空間,就會產生記憶體洩露。所以,我們插入結點時,一定要注意操作的順序,要先將結點 x 的 next 指標指向結點 b,再把結點 a 的 next 指標指向結點 x,這樣才不會丟失指標,導致記憶體洩漏。所以,對於剛剛的插入程式碼,我們只需要把第 1 行和第 2 行程式碼的順序顛倒一下就可以了。

同理,刪除連結串列結點時,也一定要記得手動釋放記憶體空間,否則,也會出現記憶體洩漏的問題。當然,對於像 Java 這種虛擬機器自動管理記憶體的程式語言來說,就不需要考慮這麼多了。

技巧三:利用哨兵簡化實現難度

首先,我們先來回顧一下單鏈表的插入和刪除操作。如果我們在結點 p 後面插入一個新的結點,只需要下面兩行程式碼就可以搞定。

new_node->next = p->next;
p->next = new_node;

但是,當我們要向一個空連結串列中插入第一個結點,剛剛的邏輯就不能用了。我們需要進行下面這樣的特殊處理,其中 head 表示連結串列的頭結點。所以,從這段程式碼,我們可以發現,對於單鏈表的插入操作,第一個結點和其他結點的插入邏輯是不一樣的。

if (head == null) {
  head = new_node;
}

我們再來看單鏈表結點刪除操作。如果要刪除結點 p 的後繼結點,我們只需要一行程式碼就可以搞定。

p->next = p->next->next;

但是,如果我們要刪除連結串列中的最後一個結點,前面的刪除程式碼就不 work 了。跟插入類似,我們也需要對於這種情況特殊處理。寫成程式碼是這樣子的:

if (head->next == null) {
   head = null;
}

從前面的一步一步分析,我們可以看出,針對連結串列的插入、刪除操作,需要對插入第一個結點和刪除最後一個結點的情況進行特殊處理。這樣程式碼實現起來就會很繁瑣,不簡潔,而且也容易因為考慮不全而出錯。如何來解決這個問題呢?

哨兵就要登場了。哨兵,解決的是國家之間的邊界問題。同理,這裡說的哨兵也是解決“邊界問題”的,不直接參與業務邏輯。

還記得如何表示一個空連結串列嗎?head=null 表示連結串列中沒有結點了。其中 head 表示頭結點指標,指向連結串列中的第一個結點。

如果我們引入哨兵結點,在任何時候,不管連結串列是不是空,head 指標都會一直指向這個哨兵結點。我們也把這種有哨兵結點的連結串列叫帶頭連結串列。相反,沒有哨兵結點的連結串列就叫作不帶頭連結串列。

如下圖畫了一個帶頭連結串列,你可以發現,哨兵結點是不儲存資料的。因為哨兵結點一直存在,所以插入第一個結點和插入其他結點,刪除最後一個結點和刪除其他結點,都可以統一為相同的程式碼實現邏輯了。

實際上,這種利用哨兵簡化程式設計難度的技巧,在很多程式碼實現中都有用到,比如插入排序、歸併排序、動態規劃等。這些內容我們後面才會講,現在為了讓你感受更深,我再舉一個非常簡單的例子。程式碼我是用 C 語言實現的,不涉及語言方面的高階語法,很容易看懂,你可以類比到你熟悉的語言。

程式碼一:

// 在陣列a中,查詢key,返回key所在的位置
// 其中,n表示陣列a的長度
int find(char* a, int n, char key) {
  // 邊界條件處理,如果a為空,或者n<=0,說明陣列中沒有資料,就不用while迴圈比較了
  if(a == null || n <= 0) {
    return -1;
  }
  
  int i = 0;
  // 這裡有兩個比較操作:i<n和a[i]==key.
  while (i < n) {
    if (a[i] == key) {
      return i;
    }
    ++i;
  }
  
  return -1;
}

程式碼二:

// 在陣列a中,查詢key,返回key所在的位置
// 其中,n表示陣列a的長度
// 我舉2個例子,你可以拿例子走一下程式碼
// a = {4, 2, 3, 5, 9, 6}  n=6 key = 7
// a = {4, 2, 3, 5, 9, 6}  n=6 key = 6
int find(char* a, int n, char key) {
  if(a == null || n <= 0) {
    return -1;
  }
  
  // 這裡因為要將a[n-1]的值替換成key,所以要特殊處理這個值
  if (a[n-1] == key) {
    return n-1;
  }
  
  // 把a[n-1]的值臨時儲存在變數tmp中,以便之後恢復。tmp=6。
  // 之所以這樣做的目的是:希望find()程式碼不要改變a陣列中的內容
  char tmp = a[n-1];
  // 把key的值放到a[n-1]中,此時a = {4, 2, 3, 5, 9, 7}
  a[n-1] = key;
  
  int i = 0;
  // while 迴圈比起程式碼一,少了i<n這個比較操作
  while (a[i] != key) {
    ++i;
  }
  
  // 恢復a[n-1]原來的值,此時a= {4, 2, 3, 5, 9, 6}
  a[n-1] = tmp;
  
  if (i == n-1) {
    // 如果i == n-1說明,在0...n-2之間都沒有key,所以返回-1
    return -1;
  } else {
    // 否則,返回i,就是等於key值的元素的下標
    return i;
  }
}

對比兩段程式碼,在字串 a 很長的時候,比如幾萬、幾十萬,你覺得哪段程式碼執行得更快點呢?答案是程式碼二,因為兩段程式碼中執行次數最多就是 while 迴圈那一部分。第二段程式碼中,我們通過一個哨兵 a[n-1] = key,成功省掉了一個比較語句 i<n,不要小看這一條語句,當累積執行萬次、幾十萬次時,累積的時間就很明顯了。

當然,這只是為了舉例說明哨兵的作用,你寫程式碼的時候千萬不要寫第二段那樣的程式碼,因為可讀性太差了。大部分情況下,我們並不需要如此追求極致的效能。

哨兵可以理解為它可以減少特殊情況的判斷,比如判空,比如判越界,比如減少連結串列插入刪除中對空連結串列的判斷,比如例子中對i越界的判斷。

空與越界可以認為是小概率情況,所以程式碼每一次操作都走一遍判斷,在大部分情況下都會是多餘的。

哨兵的巧妙就是提前將這種情況去除,比如給一個哨兵結點,以及將key賦值給陣列末元素,讓陣列遍歷不用判斷越界也可以因為相等停下來。

使用哨兵的指導思想應該是將小概率需要的判斷先提前扼殺,比如提前給他一個值讓他不為null,或者提前預設值,或者多型的時候提前給個空實現,然後在每一次操作中不必再判斷以增加效率。

技巧四:重點留意邊界條件處理

軟體開發中,程式碼在一些邊界或者異常情況下,最容易產生 Bug。連結串列程式碼也不例外。要實現沒有 Bug 的連結串列程式碼,一定要在編寫的過程中以及編寫完成之後,檢查邊界條件是否考慮全面,以及程式碼在邊界條件下是否能正確執行。

經常用來檢查連結串列程式碼是否正確的邊界條件有這樣幾個:

  • 如果連結串列為空時,程式碼是否能正常工作?

  • 如果連結串列只包含一個結點時,程式碼是否能正常工作?

  • 如果連結串列只包含兩個結點時,程式碼是否能正常工作?

  • 程式碼邏輯在處理頭結點和尾結點的時候,是否能正常工作?

當你寫完連結串列程式碼之後,除了看下你寫的程式碼在正常的情況下能否工作,還要看下在上面我列舉的幾個邊界條件下,程式碼仍然能否正確工作。如果這些邊界條件下都沒有問題,那基本上可以認為沒有問題了。

當然,邊界條件不止我列舉的那些。針對不同的場景,可能還有特定的邊界條件,這個需要你自己去思考,不過套路都是一樣的。

實際上,不光光是寫連結串列程式碼,你在寫任何程式碼時,也千萬不要只是實現業務正常情況下的功能就好了,一定要多想想,你的程式碼在執行的時候,可能會遇到哪些邊界情況或者異常情況。遇到了應該如何應對,這樣寫出來的程式碼才夠健壯!

技巧五:舉例畫圖,輔助思考

對於稍微複雜的連結串列操作,比如前面我們提到的單鏈表反轉,指標一會兒指這,一會兒指那,一會兒就被繞暈了。總感覺腦容量不夠,想不清楚。所以這個時候就要使用大招了,舉例法和畫圖法。

你可以找一個具體的例子,把它畫在紙上,釋放一些腦容量,留更多的給邏輯思考,這樣就會感覺到思路清晰很多。

技巧六:多寫多練,沒有捷徑

如果你已經理解並掌握了我前面所講的方法,但是手寫連結串列程式碼還是會出現各種各樣的錯誤,也不要著急。把常見的連結串列操作都自己多寫幾遍,出問題就一點一點除錯,熟能生巧!下面精選了 5 個常見的連結串列操作。你只要把這幾個操作都能寫熟練,不熟就多寫幾遍,我保證你之後再也不會害怕寫連結串列程式碼。

  • 單鏈表反轉
  • 連結串列中環的檢測
  • 兩個有序的連結串列合併
  • 刪除連結串列倒數第 n 個結點
  • 求連結串列的中間結點

寫連結串列程式碼是最考驗邏輯思維能力的。因為,連結串列程式碼到處都是指標的操作、邊界條件的處理,稍有不慎就容易產生 Bug。連結串列程式碼寫得好壞,可以看出一個人寫程式碼是否夠細心,考慮問題是否全面,思維是否縝密。所以,這也是很多面試官喜歡讓人手寫連結串列程式碼的原因。

連結串列相關練習題LeetCode對應編號:206,141,21,19,876。可以多多練習!