本文主要内容有:
1、递归的样子
2、递归简介
3、递归特点
4、递归分析方法
5、递归程序模板
6、递归程序调试
7、总结
8、例子解答
递归的样子
递归,顾名思义,分为两个部分一是递,二是归。
去的过程叫“递”,回来的过程叫“归”,递的开始是问题的开始,递的终止就是归的开始,归的开始是递的终止,归的终止是问题的开始。好吧,已经晕了,但这就是真实的它。
递归代码举例
这段代码没有什么特别的含义,只是为了说了这段代码的执行过程。
递归代码一般分为三个部分:
1、终止条件,因为没有终止条件,代码将会进入死循环。
2、递的阶段,递归函数参数重新计算,参数规模要越来越小。从上往下执行,直到终止条件。
3、归的阶段,在调用本身函数后执行的代码。从终止条件往上执行。
public void f(int number){
// 1、终止条件
if(number <=1){
return ;
}
// 递,重新计算参数
f(number-3);
//归1
f(number-2);
// 归2
f(number-1);
//归3
}
看下这段代码的执行过程假如求f(6)
递:从上往下
归:从下往上
递归是可以树结构一一对应的,我们可以用树码转换来进行问题分析
简介
递归是程序结构中最复杂(如上图例子)的一种形式之一,递归程序的执行过程分为两步分,先递、后归。研究表明普通人一般最多能处理7个信息,递归的复杂度已经远远超出了人类处理信的能力了,所以递归理解起来非常费劲, 也是很难掌握的一个编程技能。递归除了本身的复杂性外, 还有另外一个复杂性,如何将待处理的问题,抽象处递推公式。
虽然我们不能再脑海中,演算递归的执行过程,但是它都是做重复相同的工作。这块恰恰是计算机擅长处理的事情。我们只需要告诉计算从哪儿开始,在哪儿结束,不用关心它详细的执行的过程。对于复杂问题的抽象,这个复杂性只能由人类来完成了。人类可以可以通过一些技巧,或者工具,找出问题的规律性。从而总结出 模型或者公式。有了计算模型和公式,我们写递归程序就相对容易些。计算机怎么完成这个工作的话,就让它处理吧。从心里上面不要惧怕递归,敢于正视他,我们的重点应该放在问题的抽象上面,而不是复杂程序过程里面。
递归特点
1、可以从上往下把一个大问题拆分为小问题,问题是可以递的。
2、拆分问题和汇总的过程是重复的。
3、存在最小问题的解决办法(终止条件)。
4、问题解决是从下往上一步步解决的。
递归分析方法
1、依据问题描述抽象出递推公式
例如斐波拉契数列:1、1、2、3、5、8、13、21、34、……
公式为 f(n) = f(n-1)+f(n-2) 并且 f(1)=1,f(2)=2
2、递推公式翻译成代码
int fib(int i) {
// 1、终止条件
if (i == 1 || i == 2) {
return 1;
}
// 2、递 减小问题规模 ,调用函数的参数值变小
int result1= fib(i - 1);
int result2= fib(i-2);
// 3、归
return result1 +result2 ;
// 简写 return fib(i - 1)+fib(i-2)
}
3、使用树码分析执行流程
分析我们的问题是在递的部分解决,还是需要在归的部分解决。比如穷举可能性,这种问题一般是在递的部分解决。比如要求一个最终值,那么是在归的部分解决。对照 "递归的样子" 中递归固定的代码模板,进行代码填充。
递归函数本身调用的次数对应的是树有几个分支,参见 "递归的样子" 递与归的部分。比如fib 调用了本身两次,树有两个分支。如下图:
递归程序模板
递归程序一般有三种用途1、求问题的最终解。2、记录解决的详细过程。3、记录每一个子问题的结果。这三种情况都有固定的代码格式和相似的函数参数返回值设计。
1、求问题的最终解
1.1斐波拉契数列
递推公式如下:
f(n) = f(n-1)+f(n-2) ,f(1)=1,f(2)=2
代码
static int fib(int i) {
// 1、终止条件
if (i == 1 || i == 2) {
return 1;
}
// 2、递 减小问题规模 ,调用函数的参数值变小
int result1= fib(i - 1);
int result2= fib(i-2);
// 3、归
return result1 +result2 ;
// 简写 return fib(i - 1)+fib(i-2)
}
1.2 求全排列总数
f(n)=f(n-1)*n ,1!=1
static int permutate(int number) {
// 1、终止条件
if (number == 1) {
return 1;
}
// 递,减小问题规模 ,调用函数的参数值变小
int result=permutate(number - 1);
// 归,将所有值汇总
return result * number;
}
1.3 归并排序
public static int[] merge_sort(int[] to_sort) {
// 1、终止条件
if (to_sort == null) {
return new int[0];
}
// 如果分解到只剩一个数,返回该数
if (to_sort.length == 1) {
return to_sort;
}
// 递,简化规模
// 将数组分解成左右两半
int mid = to_sort.length / 2;
int[] left = Arrays.copyOfRange(to_sort, 0, mid);
int[] right = Arrays.copyOfRange(to_sort, mid, to_sort.length);
// 嵌套调用,对两半分别进行排序
left = merge_sort(left);
right = merge_sort(right);
// 归,合并排序后的两半
int[] merged = merge(left, right);
return merged;
}
小结:
递归程序是有固定的代码格式的,如我们第一个递归一样 见“递归的样子” 从第一个到后面几个他们的格式都是一样的,说明递归代码格式是有章可循的。求问题的最终解,这样的解决方法重点在归部分的代码。
练习例子
1、假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?
2、有1,2,5,10 的钱币,能够组成10元有多少中方式。
2、记录解决的详细过程
1、求全排列所有可能情况
static void permutateDetail(ArrayList<Integer> numbers,ArrayList<Integer> result) {
// 终止条件
if(numbers.size()==0){
System.out.println(result);
}
// 路径详情在递的阶段设置
for (int i = 0; i < numbers.size(); i++) {
Integer integer = numbers.get(i);
ArrayList<Integer> tempResult= new ArrayList<>();
tempResult.add(integer);
tempResult.addAll(result);
ArrayList numberBack = (ArrayList<Integer>)numbers.clone();
numberBack.remove(i);
permutateDetail(numberBack,tempResult);
}
这种解题思路是求递归树上面,从根节点到叶子节点所有的路径。这种情况我们可以在递的阶段来处理,递阶段主要工作就是参数计算,我们每次调用递归函数的时候,用一个新的参数,新的参数添加上一次调用的节点值。在递结束阶段进行搜集,也就是终止条件处,这样就能够求出所有路径。可以画树图来进行分析。
练习
1、假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶的所有走法?
2、有1,2,5,10 的钱币,能够组成10元的组合明细。
3、记录每一个子问题的结果
1、斐波拉契数列所有节点
static int fibDetail(int i, Map<Integer, Integer> history) {
if (history.containsKey(i)) {
return history.get(i);
}
if (i == 1 || i == 2) {
// 第一步返回值->归
// 最后一步递
history.put(i, 1);
return 1;
}
// 递拆分过程,改变参数值
int fi = fibDetail(i - 1, history) + fibDetail(i - 2, history);
// 返回值->归
history.put(i, fi);
return history.get(i);
}
2、二叉树的先中后序遍历
测试数据
public static TreeNode createTree() {
TreeNode node0 = new TreeNode(0);
TreeNode node1 = new TreeNode(1);
TreeNode node2 = new TreeNode(2);
TreeNode node3 = new TreeNode(3);
TreeNode node4 = new TreeNode(4);
TreeNode node5 = new TreeNode(5);
TreeNode node6 = new TreeNode(6);
TreeNode node7 = new TreeNode(7);
node0.left = node1;
node0.right = node2;
node1.left= node3;
node1.right=node4;
node2.left=node5;
node2.right=node6;
node4.left= node7;
return node0;
}
static class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) {
val = x;
}
}
前中后序遍历名称以访问根节点的顺序来命名。
前序:根结点 ---> 左子树 ---> 右子树
中序:左子树 --->根结点---> 右子树
后续:左子树 --->右子树---> 根结点
前序:
/**
* 前序遍历,根结点 第一个 前序遍历:根结点 ---> 左子树 ---> 右子树
* 在递的阶段处理
*
*/
static void preOrderTraversal(TreeNode root, ArrayList<Integer> nodeCollect) {
// 终止条件 ,左右子节点都为空。
if (root.left == null && root.right == null) {
nodeCollect.add(root.val);
return;
}
// 分的时候添加每个节点的值
nodeCollect.add(root.val);
if(root.left!=null){
preOrderTraversal(root.left, nodeCollect);
}
if(root.right!=null){
preOrderTraversal(root.right, nodeCollect);
}
}
中序:
/**
* 中序遍历,根结点 在中间 中序遍历:左子树---> 根结点 ---> 右子树
* 在第一部分归中处理。
*/
static void inOrderTraversal(TreeNode root, ArrayList<Integer> nodeCollect) {
// 终止条件一样 ,左右子节点都为空。
if (root.left == null && root.right == null) {
nodeCollect.add(root.val);
return;
}
if(root.left!=null){
inOrderTraversal(root.left, nodeCollect);
}
nodeCollect.add(root.val);
if(root.right!=null){
inOrderTraversal(root.right, nodeCollect);
}
}
后续:
/**
* 后续遍历,根结点最后 后序遍历:左子树 ---> 右子树 ---> 根结点
* 在所有归完成后处理。
*/
static void postOrderTraversal(TreeNode root, ArrayList<Integer> nodeCollect) {
// 终止条件一样 ,左右子节点都为空。
if (root.left == null && root.right == null) {
nodeCollect.add(root.val);
return;
}
if(root.left!=null){
postOrderTraversal(root.left, nodeCollect);
}
if(root.right!=null){
postOrderTraversal(root.right, nodeCollect);
}
nodeCollect.add(root.val);
}
小结:
这类问题,我们的函数参数被设计为一个节点收集器,每一次处理一个节点就添加到收集器中。程序执行完成后,收集器中就是所有节点的信息。斐波拉契数列所有节点 中由于有重复的节点,所以需要去重。
递归程序调试
递归程序的调试出来打断点,还应应该记录日志。因为断点是一个局部信息,日志可以记录程序执行详细的过程,从全局的观点看,更容易发现问题。将代码转换成树图来进行分析,看下逻辑上面能不能走的通。用一个很小的规模自己在树图上面来推演。验证递归公式的正确性。记得,几次递归调用就会对应几个树分支。
总结
递归的难点不在程序执行,而在怎么把问题抽象出正确的递推公式。需要一定的归纳总结能力,这没有什么技巧,只有多练习。递归的优点是,利用计算机的计算能力,能够解决很多复杂的问题,如果有递推公式,可以按照固定的格式直接将递推公式翻译成代码。递归的缺点,有重复计算的可能性。调用的方法栈会太长,可能函数栈溢出。循环的性能要高于递归,减少了栈调用。尾递归,一般可以转换成循环。能用循环解决的问题,优先选择循环。
递归有比较通用的代码格式和固定的编程技巧,比如怎么设计函数的参数,返回值,都有一定方法可寻,见前面三种情况。
例子解答
1、假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?
static int step(int n) {
if (n == 1) {
return 1;
}
if (n == 2) {
return 2;
}
int k1 = step(n - 1);
int k2 = step(n - 2);
return k1 + k2;
}
2、有1,2,5,10 的钱币,能够组成10元有多少中方式?
static int reward(int total){
if(total==0){
return 1;
}else if (total<0){
return 0;
}
// f(n)=f(n-10)+f(n-5)+f(n-2)+f(n-1)
// f(0)=1
return reward(total-1)+reward(total-2)+reward(total-5)+reward(total-10);
}
3、假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶的所有走法?
static void stepDetail(int n,ArrayList<Integer> perDetail) {
if (n == 1) {
perDetail.add(1);
System.out.println(perDetail);
return ;
}
if (n == 2) {
ArrayList list1 = (ArrayList<Integer>)perDetail.clone();
list1.add(1);
list1.add(1);
System.out.println(list1);
ArrayList list2 = (ArrayList<Integer>)perDetail.clone();
list2.add(2);
System.out.println(list2);
return ;
}
ArrayList list1 = new ArrayList();
list1.add(1);
list1.addAll(perDetail);
ArrayList list2 = new ArrayList();
list2.add(2);
list2.addAll(perDetail);
stepDetail(n-1,list1);
stepDetail(n-2,list2);
}
4、有1,2,5,10 的钱币,能够组成10元的所有组合。
static int rewardDetail(int total, List<Integer> perDetail){
if(total==0){
System.out.println(perDetail);
return 1;
}else if (total<0){
return 0;
}
int totalCount=0;
// f(n)=f(n-10)+f(n-5)+f(n-2)+f(n-1)
// f(0)=1
// 递阶段拆分参数,记录拆分值。
List<Integer> y1List= new ArrayList<>();
y1List.add(1);
y1List.addAll(perDetail);
List<Integer> y2List= new ArrayList<>();
y2List.add(2);
y2List.addAll(perDetail);
List<Integer> y5List= new ArrayList<>();
y5List.add(5);
y5List.addAll(perDetail);
List<Integer> y10List= new ArrayList<>();
y10List.add(10);
y10List.addAll(perDetail);
int y1= rewardDetail(total-1,y1List);
int y2= rewardDetail(total-2,y2List);
int y5= rewardDetail(total-5,y5List);
int y10= rewardDetail(total-10,y10List);
return y1+y2+y5+y10;
}