相信很多同窗和我同樣,在剛學完數據結構後開始刷算法題時,遇到遞歸的問題老是很頭疼,而一看解答,卻發現大佬們幾行遞歸代碼就優雅的解決了問題。從我本身的學習經從來看,剛開始理解遞歸思路都很困難,更別說本身寫了。html
我一直以爲刷算法題和應試同樣,既然是應試就必定有套路存在。在刷題中,我總結出了一套解決遞歸問題的模版思路與解法,用這個思路能夠秒解不少遞歸問題。
node
何爲遞歸?程序反覆調用自身便是遞歸。面試
我本身在剛開始解決遞歸問題的時候,老是會去糾結這一層函數作了什麼,它調用自身後的下一層函數又作了什麼…而後就會以爲實現一個遞歸解法十分複雜,根本就無從下手。算法
相信不少初學者和我同樣,這是一個思惟誤區,必定要走出來。既然遞歸是一個反覆調用自身的過程,這就說明它每一級的功能都是同樣的,所以咱們只須要關注一級遞歸的解決過程便可。數組
實在沒學過啥繪圖的軟件,就靈魂手繪了一波,哈哈哈勿噴。數據結構
如上圖所示,咱們須要關心的主要是如下三點:app
整個遞歸的終止條件。less
一級遞歸須要作什麼?函數
應該返回給上一級的返回值是什麼?post
所以,也就有了咱們解遞歸題的三部曲:
找整個遞歸的終止條件:遞歸應該在何時結束?
找返回值:應該給上一級返回什麼信息?
本級遞歸應該作什麼:在這一級遞歸中,應該完成什麼任務?
必定要理解這3步,這就是之後遞歸秒殺算法題的依據和思路。
但這麼說好像很空,咱們來以題目做爲例子,看看怎麼套這個模版,相信3道題下來,你就能慢慢理解這個模版。以後再解這種套路遞歸題都能直接秒了。
先看一道簡單的Leetcode題目: Leetcode 104. 二叉樹的最大深度
題目很簡單,求二叉樹的最大深度,那麼直接套遞歸解題三部曲模版:
找終止條件。 什麼狀況下遞歸結束?固然是樹爲空的時候,此時樹的深度爲0,遞歸就結束了。
找返回值。 應該返回什麼?題目求的是樹的最大深度,咱們須要從每一級獲得的信息天然是當前這一級對應的樹的最大深度,所以咱們的返回值應該是當前樹的最大深度,這一步能夠結合第三步來看。
本級遞歸應該作什麼。 首先,仍是強調要走出以前的思惟誤區,遞歸後咱們眼裏的樹必定是這個樣子的,看下圖。此時就三個節點:root、root.left、root.right,其中根據第二步,root.left和root.right分別記錄的是root的左右子樹的最大深度。那麼本級遞歸應該作什麼就很明確了,天然就是在root的左右子樹中選擇較大的一個,再加上1就是以root爲根的子樹的最大深度了,而後再返回這個深度便可。
具體Java代碼以下:
class Solution { public int maxDepth(TreeNode root) { //終止條件:當樹爲空時結束遞歸,並返回當前深度0 if(root == null){ return 0; } //root的左、右子樹的最大深度 int leftDepth = maxDepth(root.left); int rightDepth = maxDepth(root.right); //返回的是左右子樹的最大深度+1 return Math.max(leftDepth, rightDepth) + 1; } }
當足夠熟練後,也能夠和Leetcode評論區同樣,很騷的幾行代碼搞定問題,讓以後的新手看的一臉懵逼(這道題也是我第一次一行代碼搞定一道Leetcode題):
class Solution { public int maxDepth(TreeNode root) { return root == null ? 0 : Math.max(maxDepth(root.left), maxDepth(root.right)) + 1; } }
看了一道遞歸套路解決二叉樹的問題後,有點套路搞定遞歸的感受了嗎?咱們再來看一道Leetcode中等難度的鏈表的問題,掌握套路後這種中等難度的問題真的就是秒:Leetcode 24. 兩兩交換鏈表中的節點
直接上三部曲模版:
找終止條件。 什麼狀況下遞歸終止?沒得交換的時候,遞歸就終止了唄。所以當鏈表只剩一個節點或者沒有節點的時候,天然遞歸就終止了。
找返回值。 咱們但願向上一級遞歸返回什麼信息?因爲咱們的目的是兩兩交換鏈表中相鄰的節點,所以天然但願交換給上一級遞歸的是已經完成交換處理,即已經處理好的鏈表。
本級遞歸應該作什麼。 結合第二步,看下圖!因爲只考慮本級遞歸,因此這個鏈表在咱們眼裏其實也就三個節點:head、head.next、已處理完的鏈表部分。而本級遞歸的任務也就是交換這3個節點中的前兩個節點,就很easy了。
附上Java代碼:
class Solution { public ListNode swapPairs(ListNode head) { //終止條件:鏈表只剩一個節點或者沒節點了,沒得交換了。返回的是已經處理好的鏈表 if(head == null || head.next == null){ return head; } //一共三個節點:head, next, swapPairs(next.next) //下面的任務即是交換這3個節點中的前兩個節點 ListNode next = head.next; head.next = swapPairs(next.next); next.next = head; //根據第二步:返回給上一級的是當前已經完成交換後,即處理好了的鏈表部分 return next; } }
相信通過以上2道題,你已經大概理解了這個模版的解題流程了。
那麼請你先不看如下部分,嘗試解決一下這道easy難度的Leetcode題(我的以爲此題比上面的medium難度要難):Leetcode 110. 平衡二叉樹
我以爲這個題真的是集合了模版的精髓所在,下面套三部曲模版:
找終止條件。 什麼狀況下遞歸應該終止?天然是子樹爲空的時候,空樹天然是平衡二叉樹了。
應該返回什麼信息:
爲何我說這個題是集合了模版精髓?正是由於此題的返回值。要知道咱們搞這麼多花裏胡哨的,都是爲了能寫出正確的遞歸函數,所以在解這個題的時候,咱們就須要思考,咱們到底但願返回什麼值?
何爲平衡二叉樹?平衡二叉樹即左右兩棵子樹高度差不大於1的二叉樹。而對於一顆樹,它是一個平衡二叉樹須要知足三個條件:它的左子樹是平衡二叉樹,它的右子樹是平衡二叉樹,它的左右子樹的高度差不大於1。換句話說:若是它的左子樹或右子樹不是平衡二叉樹,或者它的左右子樹高度差大於1,那麼它就不是平衡二叉樹。
而在咱們眼裏,這顆二叉樹就3個節點:root、left、right。那麼咱們應該返回什麼呢?若是返回一個當前樹是不是平衡二叉樹的boolean類型的值,那麼我只知道left和right這兩棵樹是不是平衡二叉樹,沒法得出left和right的高度差是否不大於1,天然也就沒法得出root這棵樹是不是平衡二叉樹了。而若是我返回的是一個平衡二叉樹的高度的int類型的值,那麼我就只知道兩棵樹的高度,但沒法知道這兩棵樹是否是平衡二叉樹,天然也就無法判斷root這棵樹是否是平衡二叉樹了。
所以,這裏咱們返回的信息應該是既包含子樹的深度的int類型的值,又包含子樹是不是平衡二叉樹的boolean類型的值。能夠單獨定義一個ReturnNode類,以下:
class ReturnNode{ boolean isB; int depth; //構造方法 public ReturnNode(boolean isB, int depth){ this.isB = isB; this.depth = depth; } }
本級遞歸應該作什麼。 知道了第二步的返回值後,這一步就很簡單了。目前樹有三個節點:root,left,right。咱們首先判斷left子樹和right子樹是不是平衡二叉樹,若是不是則直接返回false。再判斷兩樹高度差是否不大於1,若是大於1也直接返回false。不然說明以root爲節點的子樹是平衡二叉樹,那麼就返回true和它的高度。
具體的Java代碼以下:
class Solution { //這個ReturnNode是參考我描述的遞歸套路的第二步:思考返回值是什麼 //一棵樹是BST等價於它的左、右倆子樹都是BST且倆子樹高度差不超過1 //所以我認爲返回值應該包含當前樹是不是BST和當前樹的高度這兩個信息 private class ReturnNode{ boolean isB; int depth; public ReturnNode(int depth, boolean isB){ this.isB = isB; this.depth = depth; } } //主函數 public boolean isBalanced(TreeNode root) { return isBST(root).isB; } //參考遞歸套路的第三部:描述單次執行過程是什麼樣的 //這裏的單次執行過程具體以下: //是否終止?->沒終止的話,判斷是否知足不平衡的三個條件->返回值 public ReturnNode isBST(TreeNode root){ if(root == null){ return new ReturnNode(0, true); } //不平衡的狀況有3種:左樹不平衡、右樹不平衡、左樹和右樹差的絕對值大於1 ReturnNode left = isBST(root.left); ReturnNode right = isBST(root.right); if(left.isB == false || right.isB == false){ return new ReturnNode(0, false); } if(Math.abs(left.depth - right.depth) > 1){ return new ReturnNode(0, false); } //不知足上面3種狀況,說明平衡了,樹的深度爲左右倆子樹最大深度+1 return new ReturnNode(Math.max(left.depth, right.depth) + 1, true); } }
暫時就寫這麼多啦,做爲一個高考語文及格分,大學又學了工科的人,表述能力實在差所以囉囉嗦嗦寫了一大堆,但願你們能理解這個很好用的套路。
下面我再列舉幾道我在刷題過程當中遇到的也是用這個套路秒的題,真的太多了,大部分鏈表和樹的遞歸題都能這麼秒,由於樹和鏈表天生就是適合遞歸的結構。
我會隨時補充,正好你們能夠看了上面三個題後能夠拿這些題來練練手,看看本身是否能獨立快速準確的寫出遞歸解法了。
Leetcode 226. 翻轉二叉樹:這個題的備註是最騷的。Mac OS下載神器homebrew的大佬做者去面試谷歌,沒作出來這道算法題,而後被谷歌面試官懟了:」咱們90%的工程師使用您編寫的軟件(Homebrew),可是您卻沒法在面試時在白板上寫出翻轉二叉樹這道題,這太糟糕了。」
第二部分 我對遞歸的理解與解題思路
若是尚未理解這種套路,那我就再經過幾道題目來解決它!
在上面的基礎上,我將這種套路再從新整理一下:
例題1.Leetcode24 Swap Nodes in Pairs
給定一個鏈表,兩兩交換其中相鄰的節點,並返回交換後的鏈表。
你不能只是單純的改變節點內部的值,而是須要實際的進行節點交換。
示例:
給定 , 你應該返回 .1->2->3->42->1->4->3
ListNode* swapPairs(ListNode* head)
解題1:
class Solution { public: ListNode* swapPairs(ListNode* head) { if ((head==NULL) || (head->next==NULL)) # 終止條件(特殊狀況):空結點和單節點 return head; ListNode*next = head->next; # 通常狀況 head->next = swapPairs(next->next); # 子問題是swapPairs(next->next). 這時變成了2個節點和一個子問題的反轉問題。 next->next = head; return next; }
圖解以下,和代碼一塊兒看:(下圖通常狀況,不就變成了三個節點的翻轉嘛,很簡單)
例題2.Leetcode206 Reverse Linked List
反轉一個單鏈表。
示例:
輸入: 1->2->3->4->5->NULL 輸出: 5->4->3->2->1->NULL
解題2.
class Solution { public: ListNode* reverseList(ListNode* head) { if ((head==NULL) || (head->next==NULL)) return head; ListNode* next = head->next; ListNode*newhead = reverseList(next); # 子問題是reverse下一個節點 next->next = head; head->next=NULL; return newhead; } };
上圖:
例題3 . *38. Count and Say (以前的博文)
例題4. 46. Permutations
給定一個沒有重複數字的序列,返回其全部可能的全排列。
示例:
輸入: [1,2,3] 輸出: [ [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1] ]
對於這種排列問題,確定也能夠遞歸。
具體而言,空列表返回空,單元素返回只含該元素的列表(終止條件)。大於等於兩個元素的須要額外考慮(當前要作的事情)。下面部分文字摘自:博客
若是隻有 1 個數字 [ 1 ],那麼很簡單,直接返回 [ [ 1 ] ] 就 OK 了。
若是加了 1 個數字 2, [ 1 2 ] 該怎麼辦呢?咱們只須要在上邊的狀況裏,在 1 的空隙,也就是左邊右邊插入 2 就夠了。變成 [ [ 2 1 ], [ 1 2 ] ]。
若是再加 1 個數字 3,[ 1 2 3 ] 該怎麼辦呢?一樣的,咱們只須要將 3 挨個插到上面的位置就行啦。例如對於 [ 2 1 ]而言,將3插到 左邊,中間,右邊 ,變成 3 2 1,2 3 1,2 1 3。同理,對於1 2 在左邊,中間,右邊插入 3,變成 3 1 2,1 3 2,1 2 3,因此最後的結果就是 [ [ 3 2 1],[ 2 3 1],[ 2 1 3 ], [ 3 1 2 ],[ 1 3 2 ],[ 1 2 3 ] ]。
因此規律很簡單,直接看代碼解釋就ok。
class Solution: def permute(self, nums): # 假設輸入nums=[1,2,3],那上一級返回的結果應該是[[1,2],[2,1]] if len(nums) == 0: return [] if len(nums) == 1: return [nums] # 終止條件 results = [] end = len(nums)-1 before = self.permute(nums[0:end]) # 上一級返回的結果[[1,2],[2,1]],下面要作的是將nums[end]=3這個元素挨個插到其中 len_before = len(before) # 上一級的結果 for i in range(len_before): # 在上一級每一個列表的基礎上 for j in range(len(nums)): # 在該列表的每一個位置 temp = before[i].copy() # 提取上一級返回的結果中的每一個子列表 temp.insert(j, nums[end]) # 插入到每一個空隙裏 results.append(temp) # 放到最終結果裏 return results
對於 47. Permutations II , 如出一轍,除了加一條語句判斷是否重複便可。代碼:
例題5. 39. Combination Sum (較難理解)
組合總和:給定一個無重複元素的數組 candidates
和一個目標數 target
,找出 candidates
中全部可使數字和爲 target
的組合。
candidates
中的數字能夠無限制重複被選取。
說明:
target
)都是正整數。示例 1:
輸入: candidates = target = ,
所求解集爲:
[
[7],
[2,2,3]
]
[2,3,6,7], 7
示例 2:
輸入: candidates = [2,3,5]target = 8, 所求解集爲: [ [2,2,2,2], [2,3,3], [3,5] ],
此題一個不太好理解的地方:遞歸子函數是放在了一個循環裏。前面的題目沒有對遞歸子函數作循環處理。下面詳細分析該題的解法:
變量定義:注意數組裏的每一個元素容許重複使用,先定義一個放臨時解的列表temp=[] 和起點start=0。再定義一個存放最終結果的列表的列表results。
對於candidates=[2,3,6,7],target=8的任務,能夠當作是總任務是:combinationDFS(candidates,target=8,start=0,temp,results), 則子任務是分別從不一樣位置開始,將知足target的列表存到results中。對於該總任務,當前要作的事(子任務)以下:
combinationDFS(candidates,6,start=0,temp,results); temp已經存放了start位置的元素2,candidates=[2,3,6,7],target=8-2=6的子任務。子任務完畢,彈出temp頂端的元素。
combinationDFS(candidates,5,start=1,temp,results); temp已經存放了start位置的元素3,candidates=[3,6,7],target=8-3=5的子任務。子任務完畢,彈出temp頂端的元素。
combinationDFS(candidates,2,start=2,temp,results); temp已經存放了start位置的元素6,candidates=[6,7],target=8-6=2的子任務。子任務完畢,彈出temp頂端的元素。
combinationDFS(candidates,1,start=3,temp,results); temp已經存放了start位置的元素7,candidates=[7],target=8-7=1的子任務。子任務完畢,彈出temp頂端的元素。
因此代碼以下:
1 class Solution { 2 public: 3 vector<vector<int>> combinationSum(vector<int>& candidates, int target) { 4 vector<vector<int>> results; # 存放最後結果 5 vector<int> temp; 6 combinationDFS(candidates,target,0,temp,results); # 主任務函數 7 return results; 8 } 9 void combinationDFS(vector<int>&candidates, int target, int pos,vector<int> &temp, vector<vector<int>> &results){ 10 if (target<0){ # 題目中數組全爲正數,不可能有目標<0,因此若是目標小於0,就返回空。 11 return; 12 } 13 if (target==0){ # 目標爲0,兩種狀況:主任務要求target=0,不存在結果,將temp直接返回;子任務要求target=0,說明找到了一組解 14 results.push_back(temp); 15 return;} # 以上爲終止條件,下面爲當前要作的事。 16 for (int i=pos;i<candidates.size();i++){ # 對於主任務,要先將當前的元素放到臨時解裏,再執行後面的子任務 17 temp.push_back(candidates[i]); # 將當前位置的元素放到臨時解temp裏 18 combinationDFS(candidates,target-candidates[i],i,temp,results); # 執行子任務 19 temp.pop_back(); # 這句話最很差理解。能夠這麼想,上面那句話找到了一個解後,就將臨時解的頂部元素彈出,考慮下一可能解 20 } 21 22 } 23 };
關於子任務循壞,仍是要看當前總任務的需求。前面的題目中,當前總任務只與上一次子任務相關。而這道題當前總任務與一堆子任務相關,因此須要循環。
1 class Solution { 2 public: 3 vector<vector<int>> combinationSum2(vector<int>& candidates, int target) { 4 vector<vector<int>> results; 5 vector<int> temp; 6 sort(candidates.begin(), candidates.end()); 7 combinationDFS(candidates,target,0,temp,results); 8 return results; 9 } 10 void combinationDFS(vector<int>&candidates, int target, int pos,vector<int> &temp, vector<vector<int>> &results){ 11 if (target<0){ 12 return; 13 } 14 if (target==0){ 15 results.push_back(temp); 16 return;} 17 for (int i=pos;i<candidates.size();i++){ 18 if(i>pos && candidates[i]==candidates[i-1]) # 結果去重 19 continue; 20 21 temp.push_back(candidates[i]); 22 combinationDFS(candidates,target-candidates[i],i+1,temp,results); # i+1,即從當前的下一個元素起 23 temp.pop_back(); 24 } 25 26 } 27 };