递归是算法学习中很基本也很经常使用的一种方法,可是对于初学者来讲比较难以理解(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]){
测试结果:
由上述对比分析可知,灵活运用剪枝操做能够有效提升程序的运行效率。