幾乎刷完了力扣全部的鏈表題,我發現了這些東西。。。

先上下本文的提綱,這個是我用 mindmap 畫的一個腦圖,以後我後繼續完善,將其餘專題逐步完善起來。java

你們也可使用 vscode blink-mind 打開源文件查看,裏面有一些筆記能夠點開查看。源文件能夠去個人公衆號《力扣加加》回覆腦圖獲取,之後腦圖也會持續更新更多內容。vscode 插件地址: https://marketplace.visualstu...

你們好,我是 lucifer。今天給你們帶來的專題是《鏈表》。不少人以爲鏈表是一個很難的專題。實際上,只要你掌握了訣竅,它並沒那麼難。接下來,咱們展開說說。node

鏈表標籤在 leetcode 一共有 54 道題。 爲了準備這個專題,我花了幾天時間將 leetcode 幾乎全部的鏈表題目都刷了一遍。git

能夠看出,除了六個上鎖的,其餘我都刷了一遍。而實際上,這六個上鎖的也沒有什麼難度,甚至和其餘 48 道題差很少。github

經過集中刷這些題,我發現了一些有趣的信息,今天就分享給你們。算法

<!-- more -->數組

簡介

各類數據結構,無論是隊列,棧等線性數據結構仍是樹,圖的等非線性數據結構,從根本上底層都是數組和鏈表。無論你用的是數組仍是鏈表,用的都是計算機內存,物理內存是一個個大小相同的內存單元構成的,如圖:數據結構

(圖 1. 物理內存)dom

而數組和鏈表雖然用的都是物理內存,都是二者在對物理的使用上是很是不同的,如圖:函數

(圖 2. 數組和鏈表的物理存儲圖)post

不難看出,數組和鏈表只是使用物理內存的兩種方式。

數組是連續的內存空間,一般每個單位的大小也是固定的,所以能夠按下標隨機訪問。而鏈表則不必定連續,所以其查找只能依靠別的方式,通常咱們是經過一個叫 next 指針來遍歷查找。鏈表其實就是一個結構體。 好比一個可能的單鏈表的定義能夠是:

interface ListNode<T> {
  data: T;
  next: ListNode;
}

data 是數據域,存放數據,next 是一個指向下一個節點的指針。

鏈表是一種物理存儲單元上非連續、非順序的存儲結構,數據元素的邏輯順序是經過鏈表中的指針連接次序實現的。鏈表由一系列結點(鏈表中每個元素稱爲結點)組成,結點能夠在運行時動態生成。

從上面的物理結構圖能夠看出數組是一塊連續的空間,數組的每一項都是緊密相連的,所以若是要執行插入和刪除操做就很麻煩。對數組頭部的插入和刪除時間複雜度都是$O(N)$,而平均複雜度也是$O(N)$,只有對尾部的插入和刪除纔是$O(1)$。簡單來講」數組對查詢特別友好,對刪除和添加不友好「。爲了解決這個問題,就有了鏈表這種數據結構。鏈表適合在數據須要有必定順序,可是又須要進行頻繁增刪除的場景,具體內容參考後面的《鏈表的基本操做》小節。

(圖 3. 一個典型的鏈表邏輯表示圖)

後面全部的圖都是基於邏輯結構,而不是物理結構

鏈表只有一個後驅節點 next,若是是雙向鏈表還會有一個前驅節點 pre。

有沒有想過爲啥只有二叉樹,而沒有一叉樹。實際上鍊表就是特殊的樹,即一叉樹。

鏈表的基本操做

要想寫出鏈表的題目, 熟悉鏈表的各類基本操做和複雜度是必須的。

插入

插入只須要考慮要插入位置前驅節點和後繼節點(雙向鏈表的狀況下須要更新後繼節點)便可,其餘節點不受影響,所以在給定指針的狀況下插入的操做時間複雜度爲O(1)。這裏給定指針中的指針指的是插入位置的前驅節點。

僞代碼:

temp = 待插入位置的前驅節點.next
待插入位置的前驅節點.next = 待插入指針
待插入指針.next = temp

若是沒有給定指針,咱們須要先遍歷找到節點,所以最壞狀況下時間複雜度爲 O(N)

提示 1: 考慮頭尾指針的狀況。

提示 2: 新手推薦先畫圖,再寫代碼。等熟練以後,天然就不須要畫圖了。

刪除

只須要將須要刪除的節點的前驅指針的 next 指針修正爲其下下個節點便可,注意考慮邊界條件

僞代碼:

待刪除位置的前驅節點.next = 待刪除位置的前驅節點.next.next
提示 1: 考慮頭尾指針的狀況。

提示 2: 新手推薦先畫圖,再寫代碼。等熟練以後,天然就不須要畫圖了。

遍歷

遍歷比較簡單,直接上僞代碼。

迭代僞代碼:

當前指針 =  頭指針
while 當前節點不爲空 {
   print(當前節點)
   當前指針 = 當前指針.next
}

一個前序遍歷的遞歸的僞代碼:

dfs(cur) {
    if 當前節點爲空 return
    print(cur.val)
    return dfs(cur.next)
}

鏈表和數組到底有多大的差別?

熟悉個人小夥伴應該常常聽到我說過一句話,那就是數組和鏈表一樣做爲線性的數組結構,兩者在不少方便都是相同的,只在細微的操做和使用場景上有差別而已。而使用場景,很難在題目中直接考察。

實際上,使用場景是能夠死記硬背的。

所以,對於咱們作題來講,兩者的差別一般就只是細微的操做差別。這麼說你們可能感覺不夠強烈,我給你們舉幾個例子。

數組的遍歷:

for(int i = 0; i < arr.size();i++) {
    print(arr[i])
}

鏈表的遍歷:

for (ListNode cur = head; cur != null; cur = cur.next) {
    print(cur.val)
}

是否是很像?

能夠看出兩者邏輯是一致的,只不過細微操做不同。好比:

  • 數組是索引 ++
  • 鏈表是 cur = cur.next

若是咱們須要逆序遍歷呢?

for(int i = arr.size() - 1; i > - 1;i--) {
    print(arr[i])
}

若是是鏈表,一般須要藉助於雙向鏈表。而雙向鏈表在力扣的題目不多,所以大多數你沒有辦法拿到前驅節點,這也是爲啥不少時候會本身記錄一個前驅節點 pre 的緣由。

for (ListNode cur = tail; cur != null; cur = cur.pre) {
    print(cur.val)
}

若是往數組末尾添加一個元素就是:

arr.push(1)

鏈表的話,不少語言沒有內置的數組類型。好比力扣一般使用以下的類來模擬。

public class ListNode {
      int val;
      ListNode next;
      ListNode() {}
      ListNode(int val) { this.val = val; }
      ListNode(int val, ListNode next) { this.val = val; this.next = next; }
 }

咱們是不能直接調用 push 方法的。想一下,若是當你實現這個,你怎麼作?你能夠先本身想一下,再往下看。

3...2...1

ok,其實很簡單。

// 假設 cur 是鏈表的尾部節點
tail.next = new ListNode('lucifer')
tail = tail.next

通過上面兩行代碼以後, tail 仍然指向尾部節點。是否是很簡單,你學會了麼?

這有什麼用?好比有的題目須要你複製一個新的鏈表, 你是否是須要開闢一個新的鏈表頭,而後不斷拼接(push)複製的節點?這就用上了。

對於數組的底層也是相似的,一個可能的數組 push 底層實現:

arr.length += 1
arr[arr.length - 1] = 'lucifer'

總結一下, 數組和鏈表邏輯上兩者有不少類似之處,不一樣的只是一些使用場景和操做細節,對於作題來講,咱們一般更關注的是操做細節。關於細節,接下來給你們介紹,這一小節主要讓你們知道兩者在思想和邏輯的神類似

有些小夥伴作鏈表題先把鏈表換成數組,而後用數組作,本人不推薦這種作法,這等因而否定了鏈表存在的價值,小朋友不要模仿。

鏈表題難度幾何?

鏈表題真的不難。說鏈表不難是有證據的。就拿 LeetCode 平臺來講,處於困難難度的題目只有兩個。

其中 第 23 題基本沒有什麼鏈表操做,一個常規的「歸併排序」便可搞定,而合併兩個有序鏈表是一個簡單題。若是你懂得數組的歸併排序和合並兩個有序鏈表,應該輕鬆拿下這道題。

合併兩個有序數組也是一個簡單題目,兩者難度幾乎同樣。

而對於第 25 題, 相信你看完本節的內容,也能夠作出來。

不過,話雖這麼說,可是仍是有不少小朋友給我說 」指針繞來繞去就繞暈了「, 」總是死循環「 。。。。。。鏈表題目真的那麼難麼?咱們又該如何破解? lucifer 給你們準備了一個口訣 一個原則, 兩種題型,三個注意,四個技巧,讓你輕鬆搞定鏈表題,不再怕手撕鏈表。 咱們依次來看下這個口訣的內容。

一個原則

一個原則就是 畫圖,尤爲是對於新手來講。無論是簡單題仍是難題必定要畫圖,這是貫穿鏈表題目的一個準則。

畫圖能夠減小咱們的認知負擔,這其實和打草稿,備忘錄道理是同樣的,將存在腦子裏的東西放到紙上。舉一個不太恰當的例子就是你的腦子就是 CPU,腦子的記憶就是寄存器。寄存器的容量有限,咱們須要把不那麼頻繁使用的東西放到內存,把寄存器用在真正該用的地方,這個內存就是紙或者電腦平板等一切你能夠畫圖的東西。

畫的好看很差看都不重要,能看清就好了。用筆隨便勾畫一下, 能看出關係就夠了。

兩個考點

我把力扣的鏈表作了個遍。發現一個有趣的現象,那就是鏈表的考點很單一。除了設計類題目,其考點沒法就兩點:

  • 指針的修改
  • 鏈表的拼接

指針的修改

其中指針修改最典型的就是鏈表反轉。其實鏈表反轉不就是修改指針麼?

對於數組這種支持隨機訪問的數據結構來講, 反轉很容易, 只須要頭尾不斷交換便可。

function reverseArray(arr) {
  let left = 0;
  let right = arr.length - 1;
  while (left < right) {
    const temp = arr[left];
    arr[left++] = arr[right];
    arr[right--] = temp;
  }
  return arr;
}

而對於鏈表來講,就沒那麼容易了。力扣關於反轉鏈表的題簡直不要太多了。

今天我給你們寫了一個最完整的鏈表反轉,之後碰到能夠直接用。固然,前提是你們要先理解再去套。

接下來,我要實現的一個反轉任意一段鏈表

reverse(self, head: ListNode, tail: ListNode)。

其中 head 指的是須要反轉的頭節點,tail 是須要反轉的尾節點。 不難看出,若是 head 是整個鏈表的頭,tail 是整個鏈表的尾,那就是反轉整個鏈表,不然就是反轉局部鏈表。接下來,咱們就來實現它。

首先,咱們要作的就是畫圖。這個我在一個原則部分講過了。

以下圖,是咱們須要反轉的部分鏈表:

而咱們指望反轉以後的長這個樣子:

不難看出, 最終返回 tail 便可

因爲鏈表的遞歸性,實際上,咱們只要反轉其中相鄰的兩個,剩下的採用一樣的方法完成便可。

鏈表是一種遞歸的數據結構,所以採用遞歸的思想去考慮每每事半功倍,關於遞歸思考鏈表將在後面《三個注意》部分展開。

對於兩個節點來講,咱們只須要下修改一次指針便可,這好像不難。

cur.next = pre

就是這一個操做,不只硬生生有了環,讓你死循環。還讓不該該一刀兩斷的它們分道揚鑣。

關於分道揚鑣這個不難解決, 咱們只須要反轉前,記錄一下下一個節點便可:

next = cur.next
cur.next = pre

cur = next

那麼環呢? 實際上, 環不用解決。由於如何咱們是從前日後遍歷,那麼實際上,前面的鏈表已經被反轉了,所以上面個人圖是錯的。正確的圖應該是:

至此爲止,咱們能夠寫出以下代碼:

# 翻轉一個子鏈表,並返回新的頭與尾
    def reverse(self, head: ListNode, tail: ListNode):
        cur = head
        pre = None
        while cur != tail:
            # 留下聯繫方式
            next = cur.next
            # 修改指針
            cur.next = pre
            # 繼續往下走
            pre = cur
            cur = next
        # 反轉後的新的頭尾節點返回出去
        return tail, head

若是你仔細觀察,會發現,咱們的 tail 其實是沒有被反轉的。解決方法很簡單,將 tail 後面的節點做爲參數傳進來唄。

class Solution:
    # 翻轉一個子鏈表,而且返回新的頭與尾
    def reverse(self, head: ListNode, tail: ListNode, terminal:ListNode):
        cur = head
        pre = None
        while cur != terminal:
            # 留下聯繫方式
            next = cur.next
            # 修改指針
            cur.next = pre

            # 繼續往下走
            pre = cur
            cur = next
         # 反轉後的新的頭尾節點返回出去
        return tail, head

相信你對反轉鏈表已經有了必定的瞭解。後面咱們還會對這個問題作更詳細的講解,你們先留個印象就好。

鏈表的拼接

你們有沒有發現鏈表總喜歡穿來穿去(拼接)的?好比反轉鏈表 II,再好比合並有序鏈表等。

爲啥鏈表總喜歡穿來穿去呢?實際上,這就是鏈表存在的價值,這就是設計它的初衷呀!

鏈表的價值就在於其沒必要要求物理內存的連續性,以及對插入和刪除的友好。這在文章開頭的鏈表和數組的物理結構圖就能看出來。

所以鏈表的題目不少拼接的操做。若是上面我講的鏈表基本操做你會了,我相信這難不倒你。除了環,邊界 等 。。。 ^\_^。 這幾個問題咱們後面再看。

三個注意

鏈表最容易出錯的地方就是咱們應該注意的地方。鏈表最容易出的錯 90 % 集中在如下三種狀況:

  • 出現了環,形成死循環。
  • 分不清邊界,致使邊界條件出錯。
  • 搞不懂遞歸怎麼作

接下來,咱們一一來看。

環的考點有兩個:

  • 題目就有可能環,讓你判斷是否有環,以及環的位置。
  • 題目鏈表沒環,可是被你操做指針整出環了。

這裏咱們只討論第二種,而第一種能夠用咱們後面提到的快慢指針算法

避免出現環最簡單有效的措施就是畫圖,若是兩個或者幾個鏈表節點構成了環,經過圖是很容易看出來的。所以一個簡單的實操技巧就是先畫圖,而後對指針的操做都反應在圖中

可是鏈表那麼長,我不可能所有畫出來呀。其實徹底不用,上面提到了鏈表是遞歸的數據結構, 不少鏈表問題天生具備遞歸性,好比反轉鏈表,所以僅僅畫出一個子結構就能夠了。這個知識,咱們放在後面的先後序部分講解。

邊界

不少人錯的是沒有考慮邊界。一個考慮邊界的技巧就是看題目信息。

  • 若是題目的頭節點可能被移除,那麼考慮使用虛擬節點,這樣頭節點就變成了中間節點,就不須要爲頭節點作特殊判斷了。
  • 題目讓你返回的不是本來的頭節點,而是尾部節點或者其餘中間節點,這個時候要注意指針的變化。

以上二者部分的具體內容,咱們在稍微講到的虛擬頭部分講解。老規矩,你們留個印象便可。

先後序

ok,是時候填坑了。上面提到了鏈表結構天生具備遞歸性,那麼使用遞歸的解法或者遞歸的思惟都會對咱們解題有幫助。

二叉樹遍歷 部分,我講了二叉樹的三種流行的遍歷方法,分別是前序遍歷,中序遍歷和後序遍歷。

前中後序其實是指的當前節點相對子節點的處理順序。若是先處理當前節點再處理子節點,那麼就是前序。若是先處理左節點,再處理當前節點,最後處理右節點,就是中序遍歷。後序遍歷天然是最後處理當前節點了。

實際過程當中,咱們不會這麼扣的這麼死。好比:

def traverse(root):
    print('pre')
    traverse(root.left)
    traverse(root.righ)
    print('post')

如上代碼,咱們既在進入左右節點前有邏輯, 又在退出左右節點以後有邏輯。這算什麼遍歷方式呢?通常意義上,我習慣只看主邏輯的位置,若是你的主邏輯是在後面就是後序遍歷,主邏輯在前面就是前序遍歷。 這個不是重點,對咱們解題幫助不大,對咱們解題幫助大的是接下來要講的內容。

絕大多數的題目都是單鏈表,而單鏈表只有一個後繼指針。所以只有前序和後序,沒有中序遍歷。

仍是以上面講的經典的反轉鏈表來講。 若是是前序遍歷,咱們的代碼是這樣的:

def dfs(head, pre):
    if not head: return pre
    next = head.next
    # # 主邏輯(改變指針)在後面
    head.next = pre
    dfs(next, head)

dfs(head, None)

後續遍歷的代碼是這樣的:

def dfs(head):
    if not head or not head.next: return head
    res = dfs(head.next)
    # 主邏輯(改變指針)在進入後面的節點的後面,也就是遞歸返回的過程會執行到
    head.next.next = head
    head.next = None

    return res

能夠看出,這兩種寫法無論是邊界,入參,仍是代碼都不太同樣。爲何會有這樣的差別呢?

回答這個問題也不難,你們只要記住一個很簡單的一句話就行了,那就是若是是前序遍歷,那麼你能夠想象前面的鏈表都處理好了,怎麼處理的不用管。相應地若是是後續遍歷,那麼你能夠想象後面的鏈表都處理好了,怎麼處理的不用管。這句話的正確性也是毋庸置疑。

以下圖,是前序遍歷的時候,咱們應該畫的圖。你們把注意力集中在中間的框(子結構)就好了,同時注意兩點。

  1. 前面的已經處理好了
  2. 後面的還沒處理好

據此,咱們不難寫出如下遞歸代碼,代碼註釋很詳細,你們看註釋就行了。

def dfs(head, pre):
    if not head: return pre
    # 留下聯繫方式(因爲後面的都沒處理,所以能夠經過 head.next 定位到下一個)
    next = head.next
    # 主邏輯(改變指針)在進入後面節點的前面(因爲前面的都已經處理好了,所以不會有環)
    head.next = pre
    dfs(next, head)

dfs(head, None)

若是是後序遍歷呢?老規矩,秉承咱們的一個原則,先畫圖

不難看出,咱們能夠經過 head.next 拿到下一個元素,而後將下一個元素的 next 指向自身來完成反轉。

用代碼表示就是:

head.next.next = head

畫出圖以後,是否是很容易看出圖中有一個環? 如今知道畫圖的好處了吧?就是這麼直觀,當你很熟練了,就不須要畫了,可是在此以前,請不要偷懶。

所以咱們須要將 head.next 改成不會形成環的一個值,好比置空。

def dfs(head):
    if not head or not head.next: return head
    # 不須要留聯繫方式了,由於咱們後面已經走過了,不需走了,如今咱們要回去了。
    res = dfs(head.next)
    # 主邏輯(改變指針)在進入後面的節點的後面,也就是遞歸返回的過程會執行到
    head.next.next = head
    # 置空,防止環的產生
    head.next = None

    return res

值得注意的是,前序遍歷很容易改形成迭代,所以推薦你們使用前序遍歷。我拿上面的迭代和這裏的前序遍歷給你們對比一下。

那麼爲何前序遍歷很容易改形成迭代呢?實際上,這句話我說的不許確,準確地說應該是前序遍歷容易改爲不須要棧的遞歸,然後續遍歷須要藉助棧來完成。這也不難理解,因爲後續遍歷的主邏輯在函數調用棧的彈出過程,而前序遍歷則不須要。

這裏給你們插播一個寫遞歸的技巧,那就是想象咱們已經處理好了一部分數據,並把他們用手擋起來,可是還有一部分等待處理,接下來思考」如何根據已經處理的數據和當前的數據來推導尚未處理的數據「就好了。

四個技巧

針對上面的考點和注意點,我總結了四個技巧來應對,這都是在平時作題中很是實用的技巧。

虛擬頭

來了解虛擬頭的意義以前,先給你們作幾個小測驗。

Q1: 以下代碼 ans.next 指向什麼?

ans = ListNode(1)
ans.next = head
head = head.next
head = head.next

A1: 最開始的 head。

Q2:以下代碼 ans.next 指向什麼?

ans = ListNode(1)
head = ans
head.next = ListNode(3)
head.next = ListNode(4)

A2: ListNode(4)

彷佛也不難,咱們繼續看一道題。

Q3: 以下代碼 ans.next 指向什麼?

ans = ListNode(1)
head = ans
head.next = ListNode(3)
head = ListNode(2)
head.next = ListNode(4)

A3: ListNode(3)

若是三道題你都答對了,那麼恭喜你,這一部分能夠跳過。

若是你沒有懂也不要緊,我這裏簡單解釋一下你就懂了。

ans.next 指向什麼取決於最後切斷 ans.next 指向的地方在哪。好比 Q1,ans.next 指向的是 head,咱們假設其指向的內存編號爲 9527

以後執行 head = head.next (ans 和 head 被切斷聯繫了),此時的內存圖:

咱們假設頭節點的 next 指針指向的節點的內存地址爲 10200

不難看出,ans 沒變。

對於第二個例子。一開始和上面例子同樣,都是指向 9527。然後執行了:

head.next = ListNode(3)
head.next = ListNode(4)

ans 和 head 又同時指向 ListNode(3) 了。如圖:

head.next = ListNode(4) 也是同理。所以最終的指向 ans.next 是 ListNode(4)。

咱們來看最後一個。前半部分和 Q2 是同樣的。

ans = ListNode(1)
head = ans
head.next = ListNode(3)

按照上面的分析,此時 head 和 ans 的 next 都指向 ListNode(3)。關鍵是下面兩行:

head = ListNode(2)
head.next = ListNode(4)

指向了 head = ListNode(2) 以後, head 和 ans 的關係就被切斷了,當前以及以後全部的 head 操做都不會影響到 ans,所以 ans 還指向被切斷前的節點,所以 ans.next 輸出的是 ListNode(3)。

花了這麼大的篇幅講這個東西的緣由就是,指針操做是鏈表的核心,若是這些基礎不懂, 那麼就很難作。接下來,咱們介紹主角 - 虛擬頭。

相信作過鏈表的小夥伴都聽過這麼個名字。爲何它這麼好用?它的做用無非就兩個:

  • 將頭節點變成中間節點,簡化判斷。
  • 經過在合適的時候斷開連接,返回鏈表的中間節點。

我上面提到了鏈表的三個注意,有一個是邊界。頭節點是最多見的邊界,那若是咱們用一個虛擬頭指向頭節點,虛擬頭就是新的頭節點了,而虛擬頭不是題目給的節點,不參與運算,所以不須要特殊判斷,虛擬頭就是這個做用。

若是題目須要返回鏈表中間的某個節點呢?實際上也可藉助虛擬節點。因爲我上面提到的指針的操做,實際上,你能夠新建一個虛擬頭,而後讓虛擬頭在恰當的時候(恰好指向須要返回的節點)斷開鏈接,這樣咱們就能夠返回虛擬頭的 next 就 ok 了。25. K 個一組翻轉鏈表 就用到了這個技巧。

不只僅是鏈表, 二叉樹等也常常用到這個技巧。 好比我讓你返回二叉樹的最左下方的節點怎麼作?咱們也能夠利用上面提到的技巧。新建一個虛擬節點,虛擬節點 next 指向當前節點,並跟着一塊兒走,在遞歸到最左下的時候斷開連接,最後返回 虛擬節點的 next 指針便可。

快慢指針

判斷鏈表是否有環,以及環的入口都是使用快慢指針便可解決。這種題就是不知道不會,知道了就不容易忘。很少說了,你們能夠參考我以前的題解 https://github.com/azl3979858...

除了這個,求鏈表的交點也是快慢指針,算法也是相似的。不這都屬於不知道就難,知道了就容易。且下次寫不容易想不到或者出錯。

這部分你們參考我上面的題解理一下, 寫一道題就能夠掌握。接下來,咱們來看下穿針引線大法。

另外因爲鏈表不支持隨機訪問,所以若是想要獲取數組中間項和倒數第幾項等特定元素就須要一些特殊的手段,而這個手段就是快慢指針。好比要找鏈表中間項就搞兩個指針,一個大步走(一次走兩步),一個小步走(一次走一步),這樣快指針走到頭,慢指針恰好在中間。 若是要求鏈表倒數第 2 個,那就讓快指針先走一步,慢指針再走,這樣快指針走到頭,慢指針恰好在倒數第二個。這個原理不難理解吧?這種技巧屬於會了就容易,且不容易忘。不會就很難想出的類型,所以你們學會了拿幾道題練一下就能夠放下了。

穿針引線

這是鏈表的第二個考點 - 拼接鏈表。我在 25. K 個一組翻轉鏈表61. 旋轉鏈表92. 反轉鏈表 II 都用了這個方法。穿針引線是我本身起的一個名字,起名字的好處就是方便記憶。

這個方法一般不是最優解,可是好理解,方便書寫,不易出錯,推薦新手用。

仍是以反轉鏈表爲例,只不過此次是反轉鏈表的中間一部分,那咱們該怎麼作?

反轉前面咱們已經講過了,因而我假設鏈表已經反轉好了,那麼如何將反轉好的鏈表拼後去呢?

咱們想要的效果是這樣的:

那怎麼達到圖上的效果呢?個人作法是從作到右給斷點編號。如圖有兩個斷點,共涉及到四個節點。因而我給它們依次編號爲 a,b,c,d。

其實 a,d 分別是須要反轉的鏈表部分的前驅和後繼(不參與反轉),而 b 和 c 是須要反轉的部分的頭和尾(參與反轉)。

所以除了 cur, 多用兩個指針 pre 和 next 便可找到 a,b,c,d。

找到後就簡單了,直接穿針引線

a.next = c
b.next = d

這不就行了麼?我記得的就有 25 題,61 題 和 92 題都是這麼作的,清晰不混亂。

先穿再排後判空

這是四個技巧的最後一個技巧了。雖然是最後講,但並不意味着它不重要。相反,它的實操價值很大。

繼續回到上面講的鏈表反轉題。

cur = head
pre = None
while cur != tail:
    # 留下聯繫方式
    next = cur.next
    # 修改指針
    cur.next = pre
    # 繼續往下走
    pre = cur
    cur = next
# 反轉後的新的頭尾節點返回出去

何時須要判斷 next 是否存在,上面兩行代碼先寫哪一個呢?

是這樣?

next = cur.next
    cur.next = pre

仍是這樣?

cur.next = pre
    next = cur.next

先穿

我給你的建議是:先穿。這裏的穿是修改指針,包括反轉鏈表的修改指針和穿針引線的修改指針。先別管順序,先穿

再排

穿完以後,代碼的總數已經肯定了,無非就是排列組合讓代碼沒有 bug。

所以第二步考慮順序,那上面的兩行代碼哪一個在前?應該是先 next = cur.next ,緣由在於後一條語句執行後 cur.next 就變了。因爲上面代碼的做用是反轉,那麼其實通過 cur.next = pre 以後鏈表就斷開了,後面的都訪問不到了,也就是說此時你只能返回頭節點這一個節點

實際上,有假若有十行穿的代碼,咱們不少時候沒有必要全考慮。咱們須要考慮的僅僅是被改變 next 指針的部分。好比 cur.next = pre 的 cur 被改了 next。所以下面用到了 cur.next 的地方就要考慮放哪。其餘代碼不須要考慮。

後判空

和上面的原則相似,穿完以後,代碼的總數已經肯定了,無非就是看看哪行代碼會空指針異常。

和上面的技巧同樣,咱們不少時候沒有必要全考慮。咱們須要考慮的僅僅是被改變 next 指針的部分

好比這樣的代碼

while cur:
    cur = cur.next

咱們考慮 cur 是否爲空呢? 很明顯不可能,由於 while 條件保證了,所以不需判空。

那如何是這樣的代碼呢?

while cur:
    next = cur.next
    n_next = next.next

如上代碼有兩個 next,第一個不用判空,上面已經講了。而第二個是須要的,由於 next 多是 null。若是 next 是 null ,就會引起空指針異常。所以須要修改成相似這樣的代碼:

while cur:
    next = cur.next
    if not next: break
    n_next = next.next

以上就是咱們給你們的四個技巧了。相信有了這四個技巧,寫鏈表題就沒那麼艱難啦~ ^\_^

題目推薦

最後推薦幾道題給你們,用今天學到的知識解決它們吧~

總結

數組和棧從邏輯上沒有大的區別,你看基本操做都是差很少的。若是是單鏈表,咱們沒法在 $O(1)$ 的時間拿到前驅節點,這也是爲何咱們遍歷的時候總是維護一個前驅節點的緣由。可是本質緣由實際上是鏈表的增刪操做都依賴前驅節點。這是鏈表的基本操做,是鏈表的特性天生決定的。

可能有的同窗有這樣的疑問」考點你只講了指針的修改和鏈表拼接,難道說鏈表就只會這些就夠了?那我作的題怎麼還須要我會前綴和啥的呢?你是否是坑我呢?「

我前面說了,全部的數據結構底層都是數組和鏈表中的一種或兩種。而咱們這裏講的鏈表指的是考察鏈表的基本操做的題目。所以若是題目中須要你使用歸併排序去合併鏈表,那其實歸併排序這部分已經再也不本文的討論範圍了。

實際上,你去力扣或者其餘 OJ 翻鏈表題會發現他們的鏈表題大都指的是入參是鏈表,且你須要對鏈表進行一些操做的題目。再好比樹的題目大多數是入參是樹,你須要在樹上進行搜索的題目。也就是說須要操做樹(好比修改樹的指針)的題目不多,好比有一道題讓你給樹增長一個 right 指針,指向同級的右側指針,若是已是最右側了,則指向空。

鏈表的基本操做就是增刪查,牢記鏈表的基本操做和複雜度是解決問題的基本。有了這些基本還不夠,你們要牢記個人口訣」一個原則,兩個考點,三個注意,四個技巧「。

作鏈表的題,要想入門,無它,惟畫圖爾。能畫出圖,並根據圖進行操做你就入門了,甭管你寫的代碼有沒有 bug 。

而鏈表的題目核心的考察點只有兩個,一個是指針操做,典型的就是反轉。另一個是鏈表的拼接。這兩個既是鏈表的精髓,也是主要考點。

知道了考點確定不夠,咱們寫代碼哪些地方容易犯錯?要注意什麼? 這裏我列舉了三個容易犯錯的地方,分別是環,邊界和先後序。

其中環指的是節點之間的相互引用,環的題目若是題目自己就有環, 90 % 雙指針能夠解決,若是自己沒有環,那麼環就是咱們操做指針的時候留下的。如何解決出現環的問題?那就是畫圖,而後聚焦子結構,忽略其餘信息。

除了環,另一個容易犯錯的地方每每是邊界的條件, 而邊界這塊鏈表頭的判斷又是一個大頭。克服這點,咱們須要認真讀題,看題目的要求以及返回值,另一個頗有用的技巧是虛擬節點。

若是你們用遞歸去解鏈表的題, 必定要注意本身寫的是前序仍是後序。

  • 若是是前序,那麼只思考子結構便可,前面的已經處理好了,怎麼處理的,不用管。非要問,那就是一樣方法。後面的也不需考慮如何處理,非要問,那就是用一樣方法
  • 若是是後續,那麼只思考子結構便可,後面的已經處理好了,怎麼處理的,不用管。非要問,那就是一樣方法。前面的不需考慮如何處理。非要問,那就是用一樣方法

若是你想遞歸和迭代都寫, 我推薦你用前序遍歷。由於前序遍歷容易改爲不用棧的遞歸。

以上就是鏈表專題的所有內容了。你們對此有何見解,歡迎給我留言,我有時間都會一一查看回答。更多算法套路能夠訪問個人 LeetCode 題解倉庫:https://github.com/azl3979858... 。 目前已經 37K star 啦。你們也能夠關注個人公衆號《力扣加加》帶你啃下算法這塊硬骨頭。

我整理的 1000 多頁的電子書已經開發下載了,你們能夠去個人公衆號《力扣加加》後臺回覆電子書獲取。

相關文章
相關標籤/搜索