上一節我講了鏈表相關的基礎知識。學完以後,我看到有人留言說,基礎知識我都掌握了,可是寫鏈表代碼仍是很費勁。哈哈,的確是這樣的!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。鏈表代碼寫得好壞,能夠看出一我的寫代碼是否夠細心,考慮問題是否全面,思惟是否縝密。因此,這也是不少面試官喜歡讓人手寫鏈表代碼的緣由。因此,這一節講到的東西,你必定要本身寫代碼實現一下,纔有效果。
課後思考
今天咱們講到用哨兵來簡化編碼實現,你是否還可以想到其餘場景,利用哨兵能夠大大地簡化編碼難度?