05 | 鏈表(下):如何輕鬆寫出正確的鏈表代碼?

上一節我講了鏈表相關的基礎知識。學完以後,我看到有人留言說,基礎知識我都掌握了,可是寫鏈表代碼仍是很費勁。哈哈,的確是這樣的!node

 

想要寫好鏈表代碼並非容易的事兒,尤爲是那些複雜的鏈表操做,好比鏈表反轉、有序鏈表合併等,寫的時候很是容易出錯。,能把「鏈表反轉」這幾行代碼寫對的人不足 10% 。python

 

爲何鏈表代碼這麼難寫?究竟怎樣才能比較輕鬆地寫出正確的鏈表代碼呢?程序員

 

只要願意投入時間,我以爲大多數人都是能夠學會的。好比說,若是你真的能花上一個週末或者一成天的時間,就去寫鏈表反轉這一個代碼,多寫幾遍,一直練到能絕不費力地寫出 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 語言實現的,不涉及語言方面的高級語法,很容易看懂,你能夠類比到你熟悉的語言。

 

代碼一:

int find(char* a, int n, char key) {
  int i = 0;
  while (i < n) {
    if (a[i] == key) {
      return i;
    }
    ++i;
  }
  return -1;
}

  

代碼二:

inf find(char* a, int n, int key) {
  if (a[n-1] == key) {
    return n-1;
  }
  char tmp = a[n-1];
  a[n-1] = key;
  int i = 0;
  while (a[i] != key) {
    ++i;
  }
  a[n-1] = tmp;
  if (i == n-1) return -1;
  return i;
}

  

對比兩段代碼,在字符串 a 很長的時候,好比幾萬、幾十萬,你以爲哪段代碼運行得更快點呢?答案是代碼二,由於兩段代碼中執行次數最多就是 while 循環那一部分。第二段代碼中,咱們經過一個哨兵  a[n-1] = key,成功省掉了一個比較語句 i<n,不要小看這一條語句,當累積執行萬次、幾十萬次時,累積的時間就很明顯了。

 

固然,這只是爲了舉例說明哨兵的做用,你寫代碼的時候千萬不要寫第二段那樣的代碼,由於可讀性太差了。大部分狀況下,咱們並不須要如此追求極致的性能。

 

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

 

軟件開發中,代碼在一些邊界或者異常狀況下,最容易產生 Bug。鏈表代碼也不例外。要實現沒有 Bug 的鏈表代碼,必定要在編寫的過程當中以及編寫完成以後,檢查邊界條件是否考慮全面,以及代碼在邊界條件下是否能正確運行。

 

我常常用來檢查鏈表代碼是否正確的邊界條件有這樣幾個:

  若是鏈表爲空時,代碼是否能正常工做?

  若是鏈表只包含一個結點時,代碼是否能正常工做?

  若是鏈表只包含兩個結點時,代碼是否能正常工做?

  代碼邏輯在處理頭結點和尾結點的時候,是否能正常工做?

 

當你寫完鏈表代碼以後,除了看下你寫的代碼在正常的狀況下可否工做,還要看下在上面我列舉的幾個邊界條件下,代碼仍然可否正確工做。若是這些邊界條件下都沒有問題,那基本上能夠認爲沒有問題了。

 

固然,邊界條件不止我列舉的那些。針對不一樣的場景,可能還有特定的邊界條件,這個須要你本身去思考,不過套路都是同樣的。+實際上,不光光是寫鏈表代碼,你在寫任何代碼時,也千萬不要只是實現業務正常狀況下的功能就行了,必定要多想一想,你的代碼在運行的時候,可能會遇到哪些邊界狀況或者異常狀況。遇到了應該如何應對,這樣寫出來的代碼纔夠健壯!

 

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

 

對於稍微複雜的鏈表操做,好比前面咱們提到的單鏈表反轉,指針一下子指這,一下子指那,一下子就被繞暈了。總感受腦容量不夠,想不清楚。因此這個時候就要使用大招了,舉例法和畫圖法。

 

你能夠找一個具體的例子,把它畫在紙上,釋放一些腦容量,留更多的給邏輯思考,這樣就會感受到思路清晰不少。好比往單鏈表中插入一個數據這樣一個操做,我通常都是把各類狀況都舉一個例子,畫出插入前和插入後的鏈表變化,如圖所示:

 

 

看圖寫代碼,是否是就簡單多啦?並且,當咱們寫完代碼以後,也能夠舉幾個例子,畫在紙上,照着代碼走一遍,很容易就能發現代碼中的 Bug。

 

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

 

若是你已經理解並掌握了我前面所講的方法,可是手寫鏈表代碼仍是會出現各類各樣的錯誤,也不要着急。由於我最開始學的時候,這種情況也持續了一段時間。

 

如今我寫這些代碼,簡直就和「玩兒」同樣,其實也沒有什麼技巧,就是把常見的鏈表操做都本身多寫幾遍,出問題就一點一點調試,熟能生巧!

 

因此,我精選了 5 個常見的鏈表操做。你只要把這幾個操做都能寫熟練,不熟就多寫幾遍,我保證你以後不再會懼怕寫鏈表代碼。

 

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

 

內容小結

 

這節我主要和你講了寫出正確鏈表代碼的六個技巧。分別是理解指針或引用的含義、警戒指針丟失和內存泄漏、利用哨兵簡化實現難度、重點留意邊界條件處理,以及舉例畫圖、輔助思考,還有多寫多練。

 

我以爲,寫鏈表代碼是最考驗邏輯思惟能力的。由於,鏈表代碼處處都是指針的操做、邊界條件的處理,稍有不慎就容易產生 Bug。鏈表代碼寫得好壞,能夠看出一我的寫代碼是否夠細心,考慮問題是否全面,思惟是否縝密。因此,這也是不少面試官喜歡讓人手寫鏈表代碼的緣由。因此,這一節講到的東西,你必定要本身寫代碼實現一下,纔有效果。

 

課後思考

 

今天咱們講到用哨兵來簡化編碼實現,你是否還可以想到其餘場景,利用哨兵能夠大大地簡化編碼難度?

相關文章
相關標籤/搜索