什么是递归?
函数直接或间接调用自身
递归的特点:
- 自身调用:原问题可以分解为子问题,子问题和原问题的求解方法是一致的,即都是调用自身的同一个函数。
- 终止条件:递归必须有一个终止的条件,不能无限循环地调用本身。
public int sum(int n) {
if (n <= 1) { // 终止条件
return 1;
}
return sum(n - 1) + n; // 自身调用
}
递归与栈的关系:
其实,递归的过程,可以理解为出入栈的过程
函数sum(n=5)的递归执行过程,如下:
递归的应用场景
- 阶乘问题
- 二叉树深度
- 汉诺塔问题
- 斐波那契数列
- 快速排序、归并排序(分治算法体现递归)
- 遍历文件,解析xml文件
递归解题思路
- 定义函数功能
- 寻找终止条件
- 递推关系式
int fibonaci(int n) // 1.定义函数功能
{
if(n==0) // 2.递归终止条件
return 0;
if(n==1)
return 1;
return fibonaci(n-1)+fibonaci(n-2); // 3.递推关系式
}
leetcode 案例分析
示例:
输入:
4
/ \
2 7
/ \ / \
1 3 6 9
输出:
4
/ \
7 2
/ \ / \
9 6 3 1
-
定义函数功能
给出一棵树,然后翻转它,题目已给出
//翻转一颗二叉树 public TreeNode invertTree(TreeNode root) { } /** * Definition for a binary tree node. * struct TreeNode { * int val; * struct TreeNode *left; * struct TreeNode *right; * }; */ struct TreeNode* invertTree(struct TreeNode* root){ struct TreeNode *left, *right; }
-
寻找终止条件
public TreeNode invertTree(TreeNode root) { if(root==null || (root.left ==null && root.right ==null)){ return root; } }
-
递推关系式
翻转一棵树,就是递归地交换左右子树
递推关系式:
invertTree(root)= invertTree(root.left) + invertTree(root.right);
//翻转一颗二叉树 public TreeNode invertTree(TreeNode root) { if(root==null || (root.left ==null && root.right ==null){ return root; } //翻转左子树 TreeNode left = invertTree(root.left); //翻转右子树 TreeNode right= invertTree(root.right); }
-
完整版
/* C语言 */ struct TreeNode* invertTree(struct TreeNode* root){ if(root==NULL || (root->left ==NULL && root->right ==NULL)){ return root; } struct TreeNode* left = invertTree(root->left); //翻转左子树 struct TreeNode* right= invertTree(root->right); //翻转右子树 //左右子树交换位置 root->left = right; root->right = left; return root; }
/* Java */ class Solution { public TreeNode invertTree(TreeNode root) { if(root==null || (root.left==null)&&(root.right==null)){ return root; } TreeNode left = invertTree(root.left); TreeNode right = invertTree(root.right); root.left = right; root.right = left; return root; } }
递归存在的问题
-
递归调用层级太多,导致栈溢出问题
- 每一次函数调用在内存栈中分配空间,而每个进程的栈容量是有限的。
- 当递归调用的层级太多时(比如50000次),就会超出栈的容量,从而导致调用栈溢出。
-
递归重复计算,导致效率低下
看一道经典的青蛙跳阶问题:一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。
直接使用递归会超时
class Solution { public int numWays(int n) { if (n == 0){ return 1; } if(n <= 2){ return n; } return numWays(n-1) + numWays(n-2); } }
画出递归树分析
- 要计算原问题 f(10),就需要先计算出子问题 f(9) 和 f(8)
- 然后要计算 f(9),又要先算出子问题 f(8) 和 f(7),以此类推。
- 一直到 f(2) 和 f(1),递归树才终止。
「递归时间复杂度 = 解决一个子问题时间*子问题个数」
- 一个子问题时间 = f(n-1)+f(n-2),也就是一个加法的操作,所以复杂度是 「O(1)」;
- 问题个数 = 递归树节点的总数,递归树的总结点 = 2n-1,所以是复杂度**「O(2n)」**。
因此,青蛙跳阶,递归解法的时间复杂度 = O(1) * O(2^n) = O(2^n),就是指数级别的,爆炸增长的,「如果n比较大的话,超时很正常的了」。
问题:这棵递归树存在**「大量重复计算」**,比如f(8)被计算了两次,f(7)被重复计算了3次…
解决:先把计算好的答案存下来,即造一个备忘录,等到下次需要的话,先去**「备忘录」查一下,如果有,就直接取就好了,备忘录没有才再计算!这就是「带备忘录的解法」**
一般使用一个数组或者一个哈希map充当这个**「备忘录」**。
假设f(10)求解加上**「备忘录」**:
- f(10)= f(9) + f(8),f(9) 和f(8)都需要计算出来,然后再加到备忘录中
- f(9) = f(8)+ f(7),f(8)= f(7)+ f(6), 因为 f(8) 已经在备忘录中啦,所以可以省掉,f(7),f(6)都需要计算出来,加到备忘录中
- f(8) = f(7)+ f(6),发现f(8),f(7),f(6)全部都在备忘录上了,所以都可以不用计算。
带「备忘录」的递归算法,子问题个数 = 树节点数 = n,解决一个子问题还是O(1),所以**「带「备忘录」的递归算法的时间复杂度是O(n)」**。代码如下:
public class Solution { //使用哈希map,充当备忘录的作用 Map<Integer, Integer> tempMap = new HashMap(); public int numWays(int n) { // n = 0 也算1种 if (n == 0) { return 1; } if (n <= 2) { return n; } //先判断有没计算过,即看看备忘录有没有 if (tempMap.containsKey(n)) { //备忘录有,即计算过,直接返回 return tempMap.get(n); } else { // 备忘录没有,即没有计算过,执行递归计算,并且把结果保存到备忘录map中,对1000000007取余(这个是leetcode题目规定的) tempMap.put(n, (numWays(n - 1) + numWays(n - 2)) % 1000000007); return tempMap.get(n); } } }
参考: