數據結構與算法之美 - 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語言實現的,不涉及語言方面的高級語法,很容易看懂,你可以類比到你熟悉的語言。

代碼一:
//在數組a中,查找key,返回key所在的位置
//其中,n表示數組a的長度
int find (char* a, int n, char key) { //邊界條件處理,如果a爲空,或者
代碼二:
//在數組a中,查找key,返回key所在的位置
//其中,n表示數組a的長度
//我舉2個例子,你可以拿例子走一下代碼
// a = {4, 2, 3, 5, 9, 6} n=6 key

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

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

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

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

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

  • 如果鏈表爲空時,代碼是否能正常工作?
  • 如果鏈表只包含一個結點時,代碼是否能正常工作?
  • 如果鏈表只包含兩個結點時,代碼是否能正常工作?
  • 代碼邏輯在處理頭結點和尾結點的時候,是否能正常工作?

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

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

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

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

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

你可以找一個具體的例子,把它畫在紙上,釋放一些腦容量,留更多的給邏輯思考,這樣就會感覺到思路清晰很多。比如往單鏈表中插入一個數據這樣一個操作,我一般都是把各種情況都舉一個例子,畫出插入前和插入後的鏈表變化,如圖所示:
在這裏插入圖片描述
看圖寫代碼,是不是就簡單多啦?而且,當我們寫完代碼之後,也可以舉幾個例子,畫在紙上,照着代碼走一遍,很容易就能發現代碼中的Bug。

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

如果你已經理解並掌握了我前面所講的方法,但是手寫鏈表代碼還是會出現各種各樣的錯誤,也不要着急。因爲我最開始學的時候,這種狀況也持續了一段時間。

現在我寫這些代碼,簡直就和”玩兒”一樣,其實也沒有什麼技巧,就是把常見的鏈表操作都自己多寫幾遍,出問題就一點一點調試,熟能生巧! 所以,精選了5個常見的鏈表操作。你只要把這幾個操作都能寫熟練,不熟就多寫幾遍,保證你之後再也不會害怕寫鏈表代碼。

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

內容小結

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

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

課後思考

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章