遞歸是算法學習中很基本也很經常使用的一種方法,可是對於初學者來講比較難以理解(PS:難點在於不斷調用自身,產生多個返回值,理不清其返回值的具體順序,以及最終的返回值究竟是哪個?)。所以,本文將選擇LeetCode中一些比較經典的習題,經過簡單測試實例,具體講解遞歸的實現原理。本文要講的內容包括如下幾點:node
題目描述(題目序號:509,困難等級:簡單):算法
求解代碼(基礎版):編程
class Solution { public int fib(int N) { if(N <= 1) return N; return fib(N - 1) + fib(N - 2); } }
如今以N = 5爲例,分析上述代碼的運行原理,具體以下圖:數組
遞歸的返回值不少,初學者很難理解最終的返回值是哪一個,此時能夠採用上圖的方式畫一個樹形圖,手動執行遞歸代碼,樹形圖的葉節點即爲遞歸的終止條件,樹形圖的根節點即爲最終的返回值。樹形圖的全部節點個數即爲遞歸程序獲得最終返回值的整體運行次數,能夠藉此計算時間複雜度,這個問題會在後文講解。編程語言
二叉樹的遍歷方式通常有四種:前序遍歷、中序遍歷、後序遍歷和層次遍歷,前三種遍歷方式應用遞歸能夠大大減小代碼量,而層次遍歷通常應用隊列方法(即非遞歸方式)求解。如下將要講解應用遞歸求解二叉樹的前、中、後序遍歷的實現原理。函數
前序遍歷方式:根節點->左子樹->右子樹。post
題目描述(題目序號:144,困難等級:中等):學習
求解代碼:測試
/** * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode(int x) { val = x; } * } */ class Solution { public List<Integer> preorderTraversal(TreeNode root) { List<Integer> list = new ArrayList<>(); if(root == null) return list; List<Integer> left = preorderTraversal(root.left); List<Integer> right = preorderTraversal(root.right); list.add(root.val); for(Integer l: left) list.add(l); for(Integer r: right) list.add(r);
return list; } }
具體測試實例以下圖:優化
中序遍歷方式:左子樹->根節點->右子樹。
題目描述(題目序號:94,困難等級:中等):
求解代碼:
/** * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode(int x) { val = x; } * } */ class Solution { public List<Integer> inorderTraversal(TreeNode root) { List<Integer> list = new ArrayList<>(); if(root == null) return list; List<Integer> left = inorderTraversal(root.left); List<Integer> right = inorderTraversal(root.right); for(Integer l: left) list.add(l); list.add(root.val); for(Integer r: right) list.add(r); return list; } }
具體測試實例以下圖:
後序遍歷方式:左子樹->右子樹->根節點。
題目描述(題目序號:145,困難等級:困難):
求解代碼:
/** * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode(int x) { val = x; } * } */ class Solution { public List<Integer> postorderTraversal(TreeNode root) { List<Integer> list = new ArrayList<>(); if (root == null) return list; List<Integer> left = postorderTraversal(root.left); List<Integer> right = postorderTraversal(root.right); for(Integer l: left) list.add(l); for(Integer r: right) list.add(r); list.add(root.val); return list; } }
具體測試實例以下圖:
題目描述(題目序號:236,困難等級:中等):
求解思路:
遞歸終止條件:
(1) 根節點爲空
(2) 根節點爲指定的兩個節點之一
遞歸方式:
在根節點的左子樹中尋找最近公共祖先。
在根節點的右子樹中尋找最近公共祖先。
若是左子樹和右子樹返回值均不爲空,說明兩個節點分別在左右子樹,最終返回root。
若是左子樹爲空,說明兩個節點均在右子樹,最終返回右子樹的返回值。
若是右子樹爲空,說明兩個節點均在左子樹,最終返回左子樹的返回值。
若是左子樹和右子樹均爲空,說明該次沒有匹配的結果。
具體代碼以下:
/** * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode(int x) { val = x; } * } */ class Solution { public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { if (root == null) { return root; } if (root == p || root == q) { return root; } TreeNode left = lowestCommonAncestor(root.left, p, q); TreeNode right = lowestCommonAncestor(root.right, p, q); if (left != null && right != null) { return root; } else if (left != null) { return left; } else if (right != null) { return right; } return null; } }
具體測試實例以下圖:
以上一節例1斐波那契數列爲例。
時間複雜度通常能夠理解爲程序運行調用的次數。在應用遞歸解題過程當中,若是當前遞歸運行過程當中,相關求解過程的運行時間不受變量影響,且運行時間是常數量級,則該算法的時間複雜度爲遞歸的整體返回次數。
以上一節例1中解題思路,求解fib(5)總共return15次,畫的樹形圖包含5層。那麼求解例1中示例解答程序代碼的時間複雜度,就是求解樹形圖的總體節點個數。對於n層滿二叉樹,共有2^n – 1個節點,因此求解fib(n),大約須要返回(2^n - 1)次,才能獲得最終的根節點值。那麼,fib(n)的時間複雜度爲O(2^n)。
遞歸算法的空間複雜度通常與遞歸的深度有關。通常來講,若是當前遞歸運行過程當中,消耗的空間複雜度是一個常數,那麼該算法的最終空間複雜度即爲遞歸的深度。計算方式:遞歸的深度*一次遞歸的空間複雜度。
遞歸的運行狀態,隨着運行深度的增長,系統會把上一次的狀態存入系統棧中,一旦遇到遞歸終止條件,便開始不斷出棧,直到棧爲空時,程序結束。因此,遞歸程序的空間複雜度通常和遞歸的深度有關。
以上一節例1中解題思路,求解fib(5)時,須要最深的層次須要經歷如下過程:
第一層:fib(5) = fib(4) + fib(3)
第二層:fib(4) = fib(3) + fib(2)
第三層:fib(3) = fib(2) + fib(1)
第四層:fib(2) = fib(1) + fib(0)
第五層:fib(1),遇到遞歸終止條件,開始進行出棧操做。
可知求解fib(5)時,遞歸的深度爲5,具體可對照例1中畫的二叉樹,正好等於二叉樹的高度。那麼求解fib(n)的空間複雜度爲O(n)。
題目描述(題目序號:344,困難等級:簡單):
遞推關係:reverse(s[0,n]) = reverse(s[1,n-1])
具體代碼以下:
class Solution { public void reverseString(char[] s) { dfs(s, 0, s.length-1); } public void dfs(char[] s, int start, int end) { if(start > end) return; dfs(s, start+1, end-1); char temp = s[start]; s[start] = s[end]; s[end] = temp; } }
題目描述(題目序號:24,困難等級:中等):
遞推關係:swapPairs(head) = swapPairs(head.next.next)
具體代碼以下:
/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode(int x) { val = x; } * } */ class Solution { public ListNode swapPairs(ListNode head) { if(head == null || head.next == null){ return head; } ListNode next = head.next; head.next = swapPairs(next.next); next.next = head; return next; } }
題目描述(題目序號:894,困難等級:中等):
遞推關係: allPossibleFBT(N) = allPossibleFBT(i) + allPossibleFBT(N – 1 - i),其中i爲奇數,1<= i<N。
具體代碼以下:
/** * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode(int x) { val = x; } * } */ class Solution { public List<TreeNode> allPossibleFBT(int N) { List<TreeNode> ans = new ArrayList<>(); if (N % 2 == 0) { return ans; } if (N == 1) { TreeNode head = new TreeNode(0); ans.add(head); return ans; } for (int i = 1; i < N; i += 2) { List<TreeNode> left = allPossibleFBT(i); List<TreeNode> right = allPossibleFBT(N - 1 - i); for (TreeNode l : left) { for (TreeNode r : right) { TreeNode head = new TreeNode(0); head.left = l; head.right = r; ans.add(head); } } } return ans; } }
由第一節例1的解答代碼可知,求解fib(n)的時間複雜度爲O(2^n),其中進行了大量重複求值過程,好比求解fib(5)時,須要求解兩次fib(3),求解三次fib(2)等。那麼如何避免重複求解的過程呢?咱們能夠採用記憶化操做。
記憶化操做就是把以前遞歸求解獲得的返回值保存到一個全局變量中,後面遇到對應的參數值,先判斷當前全局變量中是否包含其解,若是包含則直接返回具體解,不然進行遞歸求解。
例1:
原解答代碼:
class Solution { public int fib(int N) { if(N <= 1) return N; return fib(N - 1) + fib(N - 2); } }
時間複雜度爲O(2^n),空間複雜度爲O(n)。提交測試結果:
採用記憶化改進:
class Solution { private Map<Integer, Integer> map = new HashMap<>(); public int fib(int N) { if(N <= 1) return N; if(map.containsKey(N)) return map.get(N); int result = fib(N - 1) + fib(N - 2); map.put(N, result); return result; } }
具體遞歸應用測試示例以下圖:
時間複雜度爲O(n),空間複雜度爲O(n)。提交測試結果:
求解斐波那契數列,還有多種方法,好比矩陣乘法、數學公式直接計算等。因此,採用記憶化改進的代碼並非最優,這點在本文不做詳細討論。
尾遞歸是指在返回時,直接返回遞歸函數調用的值,不作額外的運算。好比,第一節中斐波那契數列的遞歸是返回: return fib(N-1) + fib(N-2);。返回時,須要作加法運算,這樣的遞歸調用就不屬於尾遞歸。
下面解釋引用自LeetCode解答
尾遞歸的好處是,它能夠避免遞歸調用期間棧空間開銷的累積,由於系統能夠爲每一個遞歸調用重用棧中的固定空間。
在尾遞歸的狀況下,一旦從遞歸調用返回,咱們也會當即返回,所以咱們能夠跳過整個遞歸調用返回鏈,直接返回到原始調用方。這意味着咱們根本不須要全部遞歸調用的調用棧,這爲咱們節省了空間。
尾遞歸的優點能夠通俗的理解爲:下降算法的空間複雜度,由原來應用棧存儲中間狀態,變換爲不斷直接返回最終值。
一般,編譯器會識別尾遞歸模式,並優化其執行。然而,並非全部的編程語言都支持這種優化,好比 C,C++ 支持尾遞歸函數的優化。另外一方面,Java 和 Python 不支持尾遞歸優化。
剪枝操做是指在遞歸調用過程當中,經過添加相關判斷條件,減小沒必要要的遞歸操做,從而提升算法的運行速度。通常來講,良好的剪枝操做可以下降算法的時間複雜度,提升程序的健壯性。下面將以一道算法題來講明。
題目描述(題目序號:698,困難等級:中等):
解題思路:
首先,對原始數組進行從小到大排序操做。
而後,初始化長度爲K的數組,每個元素賦值爲sum(nums) / K。
最後,從排序後的數組的最後一個元素開始進行遞歸操做。依次,選擇長度爲K的數組中每一個元素減去數組中的元素,若是相減的差爲0或者小於0則跳過,不然執行正常的相減操做。
剪枝策略:
(1) 若是數組nums中最大元素大於sum(nums) / K,則直接返回,結束程序。
(2) 若是執行當前減法操做獲得的結果小於nums數組中最小值,則放棄本次遞歸操做,進行下一次遞歸操做。
具體實現代碼以下(包含剪枝):
class Solution { public boolean canPartitionKSubsets(int[] nums, int k) { int sum = 0; for(int i = 0; i < nums.length; i++){ sum += nums[i]; } if(sum % k != 0){ return false; } sum = sum / k; Arrays.sort(nums); if(nums[nums.length - 1] > sum){ return false; } int[] arr = new int[k]; Arrays.fill(arr, sum); return help(nums, nums.length - 1, arr, k); } boolean help(int[] nums, int cur, int[] arr, int k){ if(cur < 0){ return true; } for(int i = 0; i < k; i++){ //若是正好能放下當前的數或者放下當前的數後,還有機會繼續放前面的數(剪枝) if(arr[i] == nums[cur] || (arr[i] - nums[cur] >= nums[0])){ arr[i] -= nums[cur]; //遞歸,開始放下一個數 if(help(nums, cur - 1, arr, k)){ return true; } arr[i] += nums[cur]; } } return false; } }
測試結果:
將剪枝操做刪除,變成正常的遞歸調用,即把下述代碼:
//若是正好能放下當前的數或者放下當前的數後,還有機會繼續放前面的數(剪枝) if(arr[i] == nums[cur] || (arr[i] - nums[cur] >= nums[0])){
變換成:
if(arr[i] >= nums[cur]){
測試結果:
由上述對比分析可知,靈活運用剪枝操做能夠有效提升程序的運行效率。