再沒對遞歸瞭解以前,遞歸一直個人噩夢,對於寫遞歸代碼一直都是無從下手,但當理解了遞歸以後,才驚歎到,編程真的是一門藝術。在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
,請使用遞歸。
既然是求abcd
的逆序,拆解後那就是求解d
加上剩下abc
的逆序;求解abc
的逆序,那就又是求解c
加上ab
的逆序,直到問題被拆解到不能拆解爲止。瀏覽器
沒有終止條件的遞歸會無限遞歸下去,直至爆棧,因此咱們要給遞歸函數設置一個終止條件,知足條件後,就不要再遞下去了。很明顯這個題目的終止條件是當字符串長度爲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
人的大腦是習慣平鋪直敘的,因此這也是遞歸代碼難理解的地方。而計算機擅長的確是重複,那麼如何調試遞歸程序就很重要,這裏分享幾個我常常會使用到小技巧。
debugger
法例如求解的字符串的逆序,就代入abc
,而後在遞歸的函數的內部打上斷點,一層層去看當前層的變量變化是否符合處理邏輯。
console.log
大法在遞歸函數的內部直接在每一個關鍵節點輸出須要觀測的變量變化,看是否符合邏輯。
藉助debugger
,在進入的遞歸函數層數足夠深了以後,切換系統棧Call Stack
裏的遞歸層數,並經過Local
查看該層變量的值,查看對應的參數的狀況。
鏈表和樹都是很是適合學習並理解遞歸算法的示例,因此以後所有都會使用遞歸,也是爲以後更難理解的回溯打好基礎。
再解決鏈表問題時,若是沒有思路,能夠用紙和筆把指針指向的過程畫下來,而後再嘗試從裏面找到重複子問題會頗有幫助。
反轉一個單鏈表 輸入: 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 };
給定一個排序鏈表,刪除全部重複的元素,使得每一個元素只出現一次。 輸入: 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 };
將兩個升序鏈表合併爲一個新的升序鏈表並返回。新鏈表是經過拼接給定的兩個鏈表的全部節點組成的。 輸入:1->2->4, 1->3->4 輸出:1->1->2->3->4->4
首先仍是拆解子問題,子問題是最小值的節點拼接上剩餘已經合併好的兩個鏈表。例如1
指向的就是23
與124
拼接好的結果;剩下的最小節點仍是1
,那麼剩下的1
指向的就是23
與24
拼接好的結果。繼續拆解直達問題不能拆解爲止,若是某一個節點已經到頭,那麼說明另外一個鏈表全部的值都比這個鏈表大,直接返回便可。代碼以下:
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 // 同理 } };
給定一個鏈表,兩兩交換其中相鄰的節點,並返回交換後的鏈表。 你不能只是單純的改變節點內部的值,而是須要實際的進行節點交換。 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 // 返回第二個節點做爲新的頭節點 };
有些複雜的鏈表問題內的子問題可能須要用上,也是解決部分鏈表問題的一種小技巧。
給定一個帶有頭結點 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 // 走完後慢指針指向中間節點 };
給定一個鏈表,判斷鏈表中是否有環。 爲了表示給定鏈表中的環,咱們使用整數 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 };
給定一個鏈表,刪除鏈表的倒數第 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. 有序鏈表轉換二叉搜索樹 給定一個單鏈表,其中的元素按升序排序,將其轉換爲高度平衡的二叉搜索樹。