日撸 Java 三百行(16 天: 疯狂套娃的递归)
注意:这里是JAVA自学与了解的同步笔记与记录,如有问题欢迎指正说明
前言
今天我们简单认识下函数自我调用的技巧:递归
为什么要在温习栈的时候谈论递归呢?因为函数调用本质上从说就是一个栈的使用过程,认识递归在认识迭代编程思路的基础上也可以更加深入了解栈的思想。
一、关于递归的理解
常常伴随递归出现的就是递归函数(Recursive Function),简单来说:
就是在函数内部再次调用函数自己的过程,但是通过每次调用时携带参数的不同,从而保证每次调用都是一个全新的上下文。然后通过调用过程中的参数传递性,保证每次调用都与上一次调用存在关联性,从而实现一种逻辑上的迭代过程。
递归若用得好,可以极大简化我们的操作过程,把会多次干同样事情的操作给简化。这种思想会伴随编程实现算法的全部学习,像DFS,归并,快排等等…
理论上,对于一切一维迭代的for循环,都可以用递归去模拟。但是反之,某些多维迭代的函数(比如DFS等)就很难使用for循环模拟,除非实现总结出转移方程,用矩阵的方式模拟出数据更新的方式,然后用动态规划的实现完成for循环。
我们用图来看下递归的现象
我们定义递归函数中有三个部分:
1). 递归入口:一般递归入口就是我们函数的调用口(例如图上的FunctionA),它是本函数的短暂停顿处,也是是下一个函数进入处。
2). 递归出口:我们的递归函数中必须要有函数的退出机制,不然我们的函数递归会把编译器分配的递归栈撑爆,陷入永无止境的进栈。这个退出口往往是对于函数的参数进行相应的判断从而终止退出的语句,简言之,就是本函数的条件退出语句。
3) 函数作用主体:一般来说,非一二即三。往往函数出口放于函数最开始,后续基本大部分内容属于函数作用主体,而函数入口放于这些主体内容之中。而函数入口往往又可以把函数作用主体分为上下两个篇章,下篇章往往可用于回溯操作。
下图是函数作用主体的顺序:
上图每个函数的两种颜色分界处就是递归入口,因此,合理设计函数作用主体离不开递归入口的选择。
用代码简单示意就是(此代码只用于简单示意,不代表标准格式):
void FunctionA(int N) {
// 1.递归出口
if (N 的条件)return;
// 2.函数作用主体(前篇)
// 3.递归入口
FunctionA(N - 1);
// 2.函数作用主体(后篇:回溯)
}
二、用递归模拟解迭代方程
数学中我们可以找到很多可以展开为递推方程的式子,而这种递推思想我们之前已经讲过for循环实现,而今天我们通过这个递推式来看下递归的设计,从而领悟递归设计的思想。
递推式的特点在于当下不知道数值的特点,但是我们知道当前环境与之前(之后)环境的关系以及某个知道数值的特殊情况。这是个很典型的递归。
用阶层计算来举例:
给出求N的阶层函数f(N)。现在我不知道这个值的具体情况,但是有一点我清楚就是:
f(N) = N * f(N-1)
解释:我们把当前求N的任务交给了N-1,实现了状态的转移(其实动态规范也是这样的递归思路)
然后进一步,代入f(N-1) = (N-1) * f(N-2)到上式有:
f(N) = N * (N-1) * f(N-2)
又因为我们知道特例f(1) = 1
于是乎可以有全部迭代的展开式:
f(N) = N * (N-1) * (N-2) * (N-3) * … * 3 * 2 * f(1)
似乎到这一步我们已经可以设计for循环了,但若要设计递归的话,这里还要加入回溯实现。
这里我们规定:若是一个常数与f()相乘,那么还要继续展开,直到出现常数与常数相乘,便从括号内向外逐步化简(回溯)
这里我们用个数举例,假如N = 6,那么我可以把迭代式成下面这个形式:
=>f(6)
=> 6 * f(5)
=> 6 * (5 * f(4))
=> 6 * (5 * (4 * f(3)))
=> 6 * (5 * (4 * (3 * f(2))))
=> 6 * (5 * (4 * (3 * (2 * f(1)))))
=> 6 * (5 * (4 * (3 * (2 * 1))))
=> 6 * (5 * (4 * (3 * 2)))
=> 6 * (5 * (4 * 6))
=> 6 * (5 * 24)
=> 6 * 120
=> 720
变得越来越长的就是深入调用,变得越来越少的就是回溯,这就是一个递归的全过程。
综上,按照我们提到的递归函数中的三个部分思想来设计有:
public static int FunctionA(int N) {
if (N == 1) { // 1.递归出口
return 1;
} // Of if
// 2.函数作用主体(前篇)
int value = N;
// 3.递归入口
int before = FunctionA(N - 1);
// 2.函数作用主体(后篇:回溯)
value = value * before;
return value;
}// Of FunctionA
简化为图(部分)就是:
当然,上面这种写法是针对一般性的递归的统一格式。
如果是递推式,其实还可以更简化些,从而能更好地体现递推式的模样:
public static int FunctionA(int N) {
if (N == 1) { // 递归出口一般无法简化
return 1;
} // Of if
return N + FunctionA(N- 1);
}// Of FunctionA
这样是不是舒服多了!
其实这个可以作为递推式的递归写法一般模板用哦!
三、试着模拟两个算式
在完成上面的学习后,不妨试着模仿这个一般递推式模板完成两个比较简单的递归函数完成递推式:
0到N的递增模拟
使用的递归方程:
f(N) = N + f(N-1)
这里N>=1
有f(0) = 0
于是不难设计(相比于刚刚举例的说明,这里我把代码简化了):
/**
*********************
* Sum to N. No loop, however a stack is used
*
* @param paraN The given value.
* @return The sum.
*********************
*/
public static int sumToN(int paraN) {
if (paraN <= 0) {
// Basis
return 0;
} // Of if
return sumToN(paraN - 1) + paraN;
}// Of sumToN
Fibonacci数列
关于Fibonacci数列:
斐波那契数列(Fibonacci sequence),又称黄金分割数列,因数学家莱昂纳多·斐波那契(Leonardo Fibonacci)以兔子繁殖为例子而引入,故又称为“兔子数列”,指的是这样一个数列:1、1、2、3、5、8、13、21、34、……在数学上,斐波那契数列以如下被以递推的方法定义:F(0)=0,F(1)=1, F(n)=F(n - 1)+F(n - 2)(n ≥ 2,n ∈ N*)
只要有完美的递推方程,那么设计递归就完全不在话下:
f(N) = f(N-1) + f(N-2)
这里N>=2
有f(0) = 0
有f(1) = 1
可得代码(由于递推式有之前多个关系决定,于是我们的递归出口也有多个)
/**
*********************
* Sum to N. No loop, however a stack is used
*
* @param paraN The given value.
* @return The sum.
*********************
*/
/**
*********************
* Fibonacci sequence.
*
* @param paraN The given value.
* @return
*********************
*/
public static int fibonacci(int paraN) {
if (paraN <= 0) {
// Negative value are invalid. Index 0 corresponds to the first element
return 0;
}
if (paraN == 1) {
// Basis.
return 1;
} // Of if
return fibonacci(paraN - 1) + fibonacci(paraN - 2);
}// Of fibonacci
数据模拟
演示代码如下:
/**
*********************
* The entrance of the program.
*
* @param args Not used now.
*********************
*/
public static void main(String args[]) {
int tempValue = 5;
System.out.println("0 sum to " + tempValue + " = " + sumToN(tempValue));
tempValue = -1;
System.out.println("0 sum to " + tempValue + " = " + sumToN(tempValue));
for (int i = 0; i < 10; i++) {
System.out.println("Fibonacci " + i + ": " + fibonacci(i));
} // Of for i
}// Of main
运行所示:
小说明下,第二种情况下返回0是因为我们设计的求和的N是>=0的,若出现违法输出则默认只加上0
总结
从上面的案例中也能看到了,我们的递归设计思想与一般的循环设计思想是截然不同的。
一般的循环总是从已知的地方开始循环,比如今天代码中的0到N求和,我们可以从已知的N=0开始,Fibonacci数列中从已知的N=0,N=1开始。
而递归的话我们不是先找起点和终点,而是先试着把我们的问题抽象为一种递推关系,而后从递推入手,关注于数据的转移过程。
换句话说,把一个大问题化为一个个相同的问题,然后专注于每个相同的小问题实现,当每个小问题解决了,只需要通过递归入口将其连接起来就好了,后面的问题就交给计算机了。
这是个非常关键和常见的解决大型搜索算法思想。
比如对图和树的遍历,我们不去考虑复杂的全图信息和逻辑,而只需把我们的注意力放在如何遍历一个点以及这个点怎么去遍历他的邻边。
归并算法中,看似复杂的全过程归并,但我们只需要考虑给定一个范围为i~j的有序数组与范围为j~k有序数组,怎么实现他们的按序合并就好。
对于麻烦的快速排序,我们只需要考虑一趟排序中怎么用双指针法选出枢轴的下标就好。
因此,想学好搜索类算法,掌握递归非常关键。