1. 程式人生 > >07-連結串列(下):如何輕鬆寫出正確的連結串列程式碼

07-連結串列(下):如何輕鬆寫出正確的連結串列程式碼

上一節我講了連結串列相關的基礎知識。學完之後,我看到有人留言說,基礎知識我都掌握了,但是寫連結串列程式碼還是很費勁。哈哈,的確是這樣的!

想要寫好連結串列程式碼並不是容易的事兒,尤其是那些複雜的連結串列操作,比如連結串列反轉、有序連結串列合併等,寫的時候非常容易出錯。從我上百場面試的經驗來看,能把“連結串列反轉”這幾行程式碼寫對的人不足 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 語言實現的,不涉及語言方面的高階語法,很容易看懂,你可以類比到你熟悉的語言

程式碼一:

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 的連結串列程式碼,一定要在編寫的過程中以及編寫完成之後,檢查邊界條件是否考慮全面,以及程式碼在邊界條件下是否能正確執行。

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

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

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

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

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

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

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

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

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

你可以找一個具體的例子,把它畫在紙上,釋放一些腦容量,留更多的給邏輯思考,這樣就會感覺到思路清晰很多。比如往單鏈表中插入一個數據這樣一個操作,我一般都是把各種情況都舉一個例子,畫出插入前和插入後的連結串列變化,如圖所示:

看圖寫程式碼,是不是就簡單多啦?而且,當我們寫完程式碼之後,也可以舉幾個例子,畫在紙上,照著程式碼走一遍,很容易就能發現程式碼中的 Bug。

技巧六:多寫多練,沒有捷徑 如果你已經理解並掌握了我前面所講的方法,但是手寫連結串列程式碼還是會出現各種各樣的錯誤,也不要著急。因為我最開始學的時候,這種狀況也持續了一段時間。

現在我寫這些程式碼,簡直就和“玩兒”一樣,其實也沒有什麼技巧,就是把常見的連結串列操作都自己多寫幾遍,出問題就一點一點除錯,熟能生巧!

所以,我精選了 5 個常見的連結串列操作。你只要把這幾個操作都能寫熟練,不熟就多寫幾遍,我保證你之後再也不會害怕寫連結串列程式碼。

(1)單鏈表反轉

(2)連結串列中環的檢測

(3)兩個有序的連結串列合併

(4)刪除連結串列倒數第 n 個結點

(5)求連結串列的中間結點

內容小結:

這節我主要和你講了寫出正確連結串列程式碼的六個技巧。分別是理解指標或引用的含義、警惕指標丟失和記憶體洩漏、利用哨兵簡化實現難度、重點留意邊界條件處理,以及舉例畫圖、輔助思考,還有多寫多練。

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

課後思考?

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

1.小結:

一、理解指標或引用的含義

1.含義:將某個變數(物件)賦值給指標(引用),實際上就是就是將這個變數(物件)的地址賦值給指標(引用)。 2.示例: p—>next = q; 表示p節點的後繼指標儲存了q節點的記憶體地址。 p—>next = p—>next—>next; 表示p節點的後繼指標儲存了p節點的下下個節點的記憶體地址。

二、警惕指標丟失和記憶體洩漏(單鏈表)

1.插入節點 在節點a和節點b之間插入節點x,b是a的下一節點,,p指標指向節點a,則造成指標丟失和記憶體洩漏的程式碼:p—>next = x;x—>next = p—>next; 顯然這會導致x節點的後繼指標指向自身。 正確的寫法是2句程式碼交換順序,即:x—>next = p—>next; p—>next = x; 2.刪除節點 在節點a和節點b之間刪除節點b,b是a的下一節點,p指標指向節點a:p—>next = p—>next—>next;

三、利用“哨兵”簡化實現難度

1.什麼是“哨兵”? 連結串列中的“哨兵”節點是解決邊界問題的,不參與業務邏輯。如果我們引入“哨兵”節點,則不管連結串列是否為空,head指標都會指向這個“哨兵”節點。我們把這種有“哨兵”節點的連結串列稱為帶頭連結串列,相反,沒有“哨兵”節點的連結串列就稱為不帶頭連結串列。 2.未引入“哨兵”的情況 如果在p節點後插入一個節點,只需2行程式碼即可搞定: new_node—>next = p—>next; p—>next = new_node; 但,若向空連結串列中插入一個節點,則程式碼如下: if(head == null){ head = new_node; } 如果要刪除節點p的後繼節點,只需1行程式碼即可搞定: p—>next = p—>next—>next; 但,若是刪除連結串列的最有一個節點(連結串列中只剩下這個節點),則程式碼如下: if(head—>next == null){ head = null; } 從上面的情況可以看出,針對連結串列的插入、刪除操作,需要對插入第一個節點和刪除最後一個節點的情況進行特殊處理。這樣程式碼就會顯得很繁瑣,所以引入“哨兵”節點來解決這個問題。 3.引入“哨兵”的情況 “哨兵”節點不儲存資料,無論連結串列是否為空,head指標都會指向它,作為連結串列的頭結點始終存在。這樣,插入第一個節點和插入其他節點,刪除最後一個節點和刪除其他節點都可以統一為相同的程式碼實現邏輯了。 4.“哨兵”還有哪些應用場景? 這個知識有限,暫時想不出來呀!但總結起來,哨兵最大的作用就是簡化邊界條件的處理。

四、重點留意邊界條件處理

經常用來檢查連結串列是否正確的邊界4個邊界條件: 1.如果連結串列為空時,程式碼是否能正常工作? 2.如果連結串列只包含一個節點時,程式碼是否能正常工作? 3.如果連結串列只包含兩個節點時,程式碼是否能正常工作? 4.程式碼邏輯在處理頭尾節點時是否能正常工作?

五、舉例畫圖,輔助思考

核心思想:釋放腦容量,留更多的給邏輯思考,這樣就會感覺到思路清晰很多。

六、多寫多練,沒有捷徑

5個常見的連結串列操作: 1.單鏈表反轉 2.連結串列中環的檢測 3.兩個有序連結串列合併 4.刪除連結串列倒數第n個節點 5.求連結串列的中間節點