这是我参与8月更文挑战的第5天,活动详情查看:8月更文挑战
含义:
函数(方法)直接或间接调用自身。是一种常用的编程技巧
//直接调用自身
int sum(int n){
if(n<=1) {return n;}
return n+sum(n-1);
}
//间接调用
void a(int v){
if(v<0){return;}
b(--v);
}
void b(int v){
a(--v);
}
一个有趣的问题:
假设A在一个电影院,想知道自己坐在哪一排,但是前面人很多,
A 懒得数,于是问前一排的人 B【你坐在哪一排?】,只要把 B 的答案加一,就是 A 的排数。
B 懒得数,于是问前一排的人 C【你坐在哪一排?】,只要把 C 的答案加一,就是 B 的排数。
C 懒得数,于是问前一排的人 D【你坐在哪一排?】,只要把 D 的答案加一,就是 C 的排数。 ......
直到问到最前面的一排,最后大家都知道自己在哪一排了
函数调用的过程
首先,我们要明确一件事情就是,一个函数如果没有执行完成,它就会一直占用内存。
public static void main(String[] args) {
test1(10);
test2(20);
}
private static void test1(int v){}
private static void test2(int v){
test3(30);
}
private static void test3(int v){}
先从main函数进入(main进栈),在执行test1函数(test1进栈),test1函数执行完毕之后(test1出栈),执行test2函数(test2进栈),执行test3函数(test3进栈); test3函数执行完毕(test3出栈),test2函数执行完毕(test2出栈),main函数执行结束(main出栈)
函数的递归调用过程
public static void main( String[ ] args) {
sum( 4);
}
private static int sum( int n) {
if (n <= 1) {return n;}
return n + sum(n - 1);
}
如果递归调用没有终止,将会一直消耗栈空间 最终导致栈内存溢出(Stack Overflow)
所以必需要有一个明确的结束递归的条件 也叫作边界条件、递归基
实例分析
- 求 1+2+3+...+(n-1)+n 的和(n>0)
下面是三种解题方法
//递归方法
int sum(int n) {
if (n <= 1) return n;
return n + sum(n - 1);
}
/*
时间复杂度O(n)
空间复杂度O(n)
*/
//循环加法
int sum(int n) {
int result = 0;
for (int i = 1; i <= n; i++){
result += i;
}
return result;
}
/*
时间复杂度O(n)
空间复杂度O(1)
*/
//数学公式
int sum(int n){
if (n <= 1) return n;
return ( 1 +n)* n >>1;
}
/*
时间复杂度O(1)
空间复杂度O(1)
*/
注意:使用递归不是为了求得最优解,是为了简化解决问题的思路,代码会更加简洁 递归求出来的很有可能不是最优解,也有可能是最优解
递归的基本思想
拆解问题:
把规模大的问题变成规模较小的同类型问题
规模较小的问题又不断变成规模更小的问题
规模小到一定程度可以直接得出它的解
求解:
由最小规模问题的解得出较大规模问题的解
由较大规模问题的解不断得出规模更大问题的解
最后得出原来问题的解
凡是可以利用上述思想解决问题的,都可以尝试使用递归 很多链表、二叉树相关的问题都可以使用递归来解决 因为链表、二叉树本身就是递归的结构(链表中包含链表,二叉树中包含二叉树)
使用方法
明确函数的功能
先不要去思考里面代码怎么写,首先搞清楚这个函数的干嘛用的,能完成什么功能?
明确原问题与子问题的关系
寻找 f(n) 与 f(n – 1) 的关系
明确递归基(边界条件)
递归的过程中,子问题的规模在不断减小,当小到一定程度时可以直接得出它的解
寻找递归基,相当于是思考:问题规模小到什么程度可以直接得出解?
练习
斐波那契数列
斐波那契数列:1、1、2、3、5、8、13、21、34、……
F(1)=1,F(2)=1, F(n)=F(n-1)+F(n-2)(n≥3)
public int fib(int n){
if(n<=2){
return 1;
}
return fib(n-1)+fib(n-2);
}
根据递推式 T n= T n − 1+ T(n − 2) + O(1),可得知时间复杂度:O(2n)
空间复杂度:O(n)
递归调用的空间复杂度 = 递归深度 * 每次调用所需的辅助空间
函数调用过程
出现了特别多的重复计算
这是一种“自顶向下”的调用过程
优化1
我们也看到了,在函数调用的过程中,出现了很多的重复调用和计算,我们利用数组来储存已经计算好的斐波那契数。
public int fib(int n){
if(n<=2){
return 1;
}
int [] array=new int[n+1];
array[2]=array[1]=1;
return fib(array,n);
}
public int fib(int[] array,int n){
if(array[n]==0){
array[n]=fib(array,n-1)+fib(array,n-2);
}
return array[n];
}
优化2
递归是一种比较浪费空间的一种操作,我们舍弃递归调用
public int fib(int n){
if(n<=2){
return 1;
}
int [] array=new int[n+1];
array[2]=array[1]=1;
for (int i=3;i<=n;i++){
array[i]=array[i-1]+array[i-2];
}
return array[n];
}
时间复杂度:O(n),空间复杂度:O(n)
这是一种“自底向上”的计算过程
优化3
由于每次运算只需要用到数组中的 2 个元素,所以可以使用滚动数组来优化
public int fib(int n) {
if (n <= 2) {
return 1;
}
int[] array=new int[2];
array[0]=array[1]=1;
for (int i=3;i<=n;i++){
array[i%2]=array[(i-1)%2]+array[(i-2)%2];
}
return array[n%2];
}
时间复杂度:O(n),空间复杂度:O(1)
优化4
乘、除、模运算效率较低,建议用其他方式取代
public int fib(int n) {
if (n <= 2) {
return 1;
}
int[] array=new int[2];
array[0]=array[1]=1;
for (int i=3;i<=n;i++){
array[i&1]=array[(i-1)&1]+array[(i-2)&1];
}
return array[n&1];
}
优化5
滚动数组其实就是两个变量,我们不在采用数组的方式,使用变量
int fib(int n) {
if (n <= 2)return 1;
int first = 1;
int second = 1;
for (int i = 3; i <= n; i++){
second = first + second;
first = second - first;
}
return second;
}
时间复杂度:O(n),空间复杂度:O(1)
优化6
走楼梯
楼梯有 n 阶台阶,上楼可以一步上 1 阶,也可以一步上 2 阶,走完 n 阶台阶共有多少种不同的走法?
假设 n 阶台阶有 f(n) 种走法,第 1 步有 2 种走法
如果上 1 阶,那就还剩 n – 1 阶,共 f(n – 1) 种走法
如果上 2 阶,那就还剩 n – 2 阶,共 f(n – 2) 种走法
所以 f(n) = f(n – 1) + f(n – 2)
优化思路也是一至
汉诺塔
编程实现把 A 的 n 个盘子移动到 C(盘子编号是 [1, n] )
每次只能移动1个盘子
大盘子只能放在小盘子下面
一个盘子
两个盘子
三个盘子
思路
其实分 2 种情况讨论即可
当 n == 1时,直接将盘子从 A 移动到 C
当 n > 1时,可以拆分成3大步骤
① 将 n – 1 个盘子从 A 移动到 B
② 将编号为 n 的盘子从 A 移动到 C
③ 将 n – 1 个盘子从 B 移动到 C
步骤 ① ③ 明显是个递归调用
实现:
void hanoi(int n, String p1, String p2, String p3) {
if (n == 1) {
move(n, p1, p3);
return;
}
hanoi(n - 1, p1, p3, p2);
move(n, p1, p3);
hanoi(n - 1, p2, p1, p3);
}
void move(int no, String from, String to) {
System.out.println("将" + no + "号盘子从" + from + "移动到" + to);
}
Tn= 2 ∗ Tn − 1+ O(1) 因此时间复杂度是:O(2n);空间复杂度:O(n)
递归转非递归
递归转非递归的万能方法:自己维护一个栈,来保存参数、局部变量;但是空间复杂度依然没有得到优化
在某些时候,也可以重复使用一组相同的变量来保存每个栈帧的内容
这里重复使用变量 i 保存原来栈帧中的参数;空间复杂度从 O n 降到了 O 1
尾调用(多看)
一个函数的最后一个动作是调用函数;如果最后一个动作是调用自身,称为尾递归(Tail Recursion),是尾调用的特殊情况
一些编译器能对尾调用进行优化,以达到节省栈空间的目的
尾调用优化
尾调用优化也叫做尾调用消除(Tail Call Elimination)
如果当前栈帧上的局部变量等内容都不需要用了,当前栈帧经过适当的改变后可以直接当作被尾调用的函数的栈帧;
使用,然后程序可以 jump 到被尾调用的函数代码
生成栈帧改变代码与 jump 的过程称作尾调用消除或尾调用优化
尾调用优化让位于尾位置的函数调用跟 goto 语句性能一样高
消除尾递归里的尾调用比消除一般的尾调用容易很多
比如Java虚拟机(JVM)会消除尾递归里的尾调用,但不会消除一般的尾调用(因为改变不了栈帧)
因此尾递归优化相对比较普遍,平时的递归代码可以考虑尽量使用尾递归的形式
尾递归示例
阶乘
优化后
斐波那契数列
优化后