前端學數據結構與算法(四):理解遞歸及拿力扣鏈表題目練手

前言

再沒對遞歸瞭解以前,遞歸一直個人噩夢,對於寫遞歸代碼一直都是無從下手,但當理解了遞歸以後,才驚歎到,編程真的是一門藝術。在01世界裏,遞歸是極其重要的一種算法思想,不可能繞的開。這一章咱們從調用棧、圖解、調試、用遞歸寫鏈表的方式,再進一步鞏固上一章鏈表的同時,也更進一步理解遞歸這種算法思想。node

什麼是遞歸?

《盜夢空間》你們應該都看過,那麼你能夠把遞歸想象成電影裏的夢境,當在這一層沒有獲得答案時,就進入下一層的夢境,直到在最後一層找到了答案,而後返回到上一層夢境,逐層返回直到現實世界,遞歸結束。因此遞歸二字描述的實際上是解決問題的兩個過程,首先是遞,而後是歸。而遞與歸之間的臨界點,又能夠叫作遞歸終止條件,意思是咱們告訴計算機:行了,別遞了,開始歸的過程吧您嘞。git

函數調用棧

爲了更好的理解遞歸,函數調用棧這個前提仍是得先弄明白了,咱們首先來看下這段程序`:github

function a() {
  b();
  console.log('a')
}
function b() {
  c();
  console.log('b')
}
function c() {
  console.log('c');
}

a(); // c b a

簡單先說下JavaScript的執行機制,當遇到函數執行時,會爲其建立一個執行上下文,而後壓入一個棧結構內,當這個函數執行完成以後,就會從棧頂彈出,這是引擎追蹤函數執行的一個機制。再看上述代碼時,執行a函數,就將a推入調用棧,可是a函數還沒執行完時又遇到了b函數的執行,因此又將b函數推入調用棧,再b函數裏又執行了c函數,因此就向調用棧裏推入c函數。在c函數裏打上斷點後,咱們能夠在瀏覽器的調用棧裏看到三個函數最終入棧的順序:

出棧的時機是當前棧頂的函數執行完畢時,就彈出,因此最終打印的順序是c b a算法

其實遞歸函數的調用是相同的,只要沒到遞歸的終止條件,就一直將相同的函數壓入棧,這也就是遞的過程。當遇到了終止條件後,就開始從棧頂彈出函數,當遞歸函數的系統棧所有彈出,歸的過程結束後,整個遞歸也就結束。編程

如何寫遞歸代碼

舉一個例子,求解字符串的逆序,如 abcd返回 dcba,請使用遞歸。
  1. 拆解重複邏輯子問題

既然是求abcd的逆序,拆解後那就是求解d加上剩下abc的逆序;求解abc的逆序,那就又是求解c加上ab的逆序,直到問題被拆解到不能拆解爲止。瀏覽器

  1. 找到遞歸終止條件

沒有終止條件的遞歸會無限遞歸下去,直至爆棧,因此咱們要給遞歸函數設置一個終止條件,知足條件後,就不要再遞下去了。很明顯這個題目的終止條件是當字符串長度爲1時就不用拆解了,爲了兼容傳入空字符串,能夠將終止條件設置爲字符串爲空時。函數

  1. 使用套路

遞歸也是有套路的,若是勤加練習,並無太難,這裏再附上一個編寫遞歸函數的基本步驟:學習

function recursion(params) {
  1. 遞歸終止條件  (避免無限遞歸)
  2. 當前函數層邏輯處理  (遞歸函數的主要處理邏輯)
  3. 進入下一層函數  (再次執行遞歸函數)
  4. 處理當前層函數其餘邏輯  (可能有這一步,也可能沒有)
}

因此代碼以下,稍微詳細些↓:spa

function reverseStr(s) {
  if (s === "") { // 終止條件
    return "";
  }
  const lastC = s[s.length - 1]; // 字符串的最後一位
  const otherC = s.slice(0, -1); // 除去最後一位的其餘字符串
  return lastC + reverseStr(otherC); // 進入遞歸下一層
}

仍是使用畫圖的方式,更方便的一目瞭然其內部執行邏輯。
debug

如何調試遞歸代碼

人的大腦是習慣平鋪直敘的,因此這也是遞歸代碼難理解的地方。而計算機擅長的確是重複,那麼如何調試遞歸程序就很重要,這裏分享幾個我常常會使用到小技巧。

  1. 最小示例代入debugger

例如求解的字符串的逆序,就代入abc,而後在遞歸的函數的內部打上斷點,一層層去看當前層的變量變化是否符合處理邏輯。

  1. console.log大法

在遞歸函數的內部直接在每一個關鍵節點輸出須要觀測的變量變化,看是否符合邏輯。

  1. 藉助瀏覽器調用棧

藉助debugger,在進入的遞歸函數層數足夠深了以後,切換系統棧Call Stack裏的遞歸層數,並經過Local查看該層變量的值,查看對應的參數的狀況。

使用遞歸解決鏈表問題

鏈表和樹都是很是適合學習並理解遞歸算法的示例,因此以後所有都會使用遞歸,也是爲以後更難理解的回溯打好基礎。

再解決鏈表問題時,若是沒有思路,能夠用紙和筆把指針指向的過程畫下來,而後再嘗試從裏面找到重複子問題會頗有幫助。

206. 反轉鏈表↓

反轉一個單鏈表
輸入: 1->2->3->4->5->NULL
輸出: 5->4->3->2->1->NULL

上一章使用循環,此次嘗試使用遞歸解決。由於是鏈表,因此思路是改變指針的指向。子問題就是讓最後一個節點指向它以前的節點。首先仍是遞的過程,咱們須要遞到最後一個節點。而後開始歸,讓它的指針指向倒數第二個節點便可,因此要知道倒數第二個節點,然而原先倒數第二個節點正指着倒數第一節點了,此時它們就會造成一個互指的環,最後再讓倒數第二個節點指向空便可,斷開環。

代碼以下:

var reverseList = function (head) {
  if (head === null || head.next === null) {
    return head
  }
  const node = reverseList(head.next) // 最後一個節點
  head.next.next = head // 讓3指向二、讓2指向1。
  head.next = null // 讓2指向空、讓1指向空。
  return node
};

83. 鏈表去重↓

給定一個排序鏈表,刪除全部重複的元素,使得每一個元素只出現一次。
輸入: 1->1->2
輸出: 1->2

輸入: 1->1->2->3->3
輸出: 1->2->3

有了鏈表反轉的技巧後,再解這個題目就很容易了,仍是遞歸到底,由於咱們知道倒數一個節點和倒數第二個節點,因此再歸的過程裏,若是倒數兩個節點的值相同,則倒數第二個指向它的下下個節點便可。這個相對簡單,再理解了反轉後,使用以前的遞歸調試法去理解相信一點都不難,就不畫圖了。

var deleteDuplicates = function (head) {
  if (head === null || head.next === null) {
    return head
  }
  const ret = deleteDuplicates(head.next) 
  // 遞歸到底去,由於遞歸的終結條件,ret就是最後一個節點
  // 而此時head就是倒數第二個節點
  
  if (ret.val === head.val) {
    head.next = ret.next // 倒數第二個節點指向它的下下個節點
  }
  return head
};

21. 合併兩個有序鏈表↓

將兩個升序鏈表合併爲一個新的升序鏈表並返回。新鏈表是經過拼接給定的兩個鏈表的全部節點組成的。 
輸入:1->2->4, 1->3->4
輸出:1->1->2->3->4->4

首先仍是拆解子問題,子問題是最小值的節點拼接上剩餘已經合併好的兩個鏈表。例如1指向的就是23124拼接好的結果;剩下的最小節點仍是1,那麼剩下的1指向的就是2324拼接好的結果。繼續拆解直達問題不能拆解爲止,若是某一個節點已經到頭,那麼說明另外一個鏈表全部的值都比這個鏈表大,直接返回便可。代碼以下:

var mergeTwoLists = function (l1, l2) {
  if (l1 == null) { // l1到了頭,說明l2接下來都比l1最大值還大
    return l2
  }
  if (l2 == null) { // 同理
    return l1
  }
  if (l1.val > l2.val) {
    l2.next = mergeTwoLists(l1, l2.next) // 則l2換下一個更大節點來比較
    return l2 // 當前小節點指向拼接好的結果後,返回
  } else {
    l1.next = mergeTwoLists(l1.next, l2) // 同理換更大的節點來比較
    return l1 // 同理
  }
};

24. 兩兩交換鏈表中的節點↓

給定一個鏈表,兩兩交換其中相鄰的節點,並返回交換後的鏈表。
你不能只是單純的改變節點內部的值,而是須要實際的進行節點交換。

 1->2->3->4, 返回 2->1->4->3.

若是嘗試用紙和筆畫出過程,就很容易發現子問題,讓第一個節點指向第二個節點以後已經交換好的鏈表,而後讓第二個節點指向以前的節點。

var swapPairs = function (head) {
  if (head === null || head.next === null) { 
      //判斷head.next是爲了防止鏈表節點個數是奇數
    
    return head
  }
  const firstNode = head // 鏈表的第一個節點
  const secondNode = head.next // 鏈表的第二個節點
  
  firstNode.next = swapPairs(secondNode.next) 
  // 第一個節點指向第二個節點以後已經交換好的鏈表
  
  secondNode.next = firstNode // 第二個節點指向第一個節點
  
  return secondNode // 返回第二個節點做爲新的頭節點
};

快慢指針解決鏈表問題

有些複雜的鏈表問題內的子問題可能須要用上,也是解決部分鏈表問題的一種小技巧。

876. 鏈表的中間結點↓

給定一個帶有頭結點 head 的非空單鏈表,返回鏈表的中間結點。
若是有兩個中間結點,則返回第二個中間結點。
1->2->3->4->5
返回3->4->5

設置兩個指針,慢指針一次走一步,快指針一次走兩步,當快指針走完時,正好慢指針在鏈表的中間。

var middleNode = function (head) {
  let slow = head // 慢指針
  let fast = head // 快指針
  while (fast !== null && fast.next !== null) {
    fast = fast.next.next // 走兩步
    slow = slow.next // 走一步
  }
  return slow // 走完後慢指針指向中間節點
};

141. 環形鏈表↓

給定一個鏈表,判斷鏈表中是否有環。
爲了表示給定鏈表中的環,咱們使用整數 pos 來表示鏈表尾鏈接到鏈表中的位置(索引從 0 開始)。
若是 pos 是 -1,則在該鏈表中沒有環。

輸入:3->2->0->-4,pos=1。 
輸出:true。
尾部連接到下標1的位置,爲有環。

輸入:3->2->0->-4,pos=-1。
輸入:false。
尾部沒有連接,沒環。

快指針一次走兩步,慢指針一次走一步,若是這個鏈表是循環的,快慢指針總會相遇;若是是直線行駛,沒有環的話,快指針就會走到空。

var hasCycle = function (head) {
  let slow = head
  let fast = head
  while(fast !== null && fast.next !== null) {
    slow = slow.next
    fast = fast.next.next
    if (slow === fast) { // 相遇了
      return true
    }
  }
  return false
};

19. 刪除鏈表的倒數第N個節點↓

給定一個鏈表,刪除鏈表的倒數第 n 個節點,而且返回鏈表的頭結點。

給定一個鏈表: 1->2->3->4->5, 和 n = 2.
當刪除了倒數第二個節點後,鏈表變爲 1->2->3->5.

說明:給定的 n 保證有效。
進階嘗試:你能嘗試使用一趟掃描實現嗎?

首先刪除鏈表第n個節點,則須要找到它以前的節點,讓它以前的節點跨過要刪除的節點便可。

而後問題是怎麼一趟掃描找到倒數第n個節點以前的節點?咱們仍是可使用快慢指針的方式,須要刪除倒數第幾個,就讓快指針多走幾步,快指針把先走的幾步走完後,快慢指針一塊兒走,快指針到了頭,慢指針停留的位置正好就是待刪除節點以前的節點。

最後刪除該節點,返回鏈表頭節點便可。代碼以下:

var removeNthFromEnd = function (head, n) {
  const dummy = new ListNode() // 設置一個虛擬節點,統一邊界處理
  dummy.next = head
  let slow = dummy // 由於要找的是待刪除以前節點的緣故
  let fast = dummy.next // 讓快指針事先就領先一步
  while (fast !== null) {
    if (n > 0) { // 先把領先的走完
      n--
      fast = fast.next
    } else { // 而後一塊兒走
      fast = fast.next
      slow = slow.next // 走完後慢指針正好在待刪除節點以前
    }
  }
  const delNode = slow.next
  slow.next = delNode.next // 移除待刪除節點
  delNode.next = null
  return dummy.next // 返回頭節點
};

最後

寫遞歸也沒什麼其餘的技巧,無非就是多練、多畫、多想、多調試。下一章將開始介紹樹結構,正好有一個與鏈表和樹都相關的問題,你們能夠嘗試解決。本章github源碼

力扣 109. 有序鏈表轉換二叉搜索樹
給定一個單鏈表,其中的元素按升序排序,將其轉換爲高度平衡的二叉搜索樹。
相關文章
相關標籤/搜索