递归(Recursion)
[toc]
函数(方法)直接或间接调用自身。是一种常用的编程技巧
1 函数的调用过程
test1(int n){}
test2(int n){
test3(30);
}
test3(int n){
}
main(List args) {
test1(10);
test2(20);
}
上面函数的调用过程如下
1.1 递归函数的调用过程
int sum(int n){
if (n<1)return n;
return n + sum(n-1);
}
main(List args) {
sum(4);
}
此时函数的调用过程为
- 如果递归调用没有终止,将会一直消耗栈空间
- 最终导致栈内存溢出(Stack Overflow)
- 所以必需要有一个明确的结束递归的条件
- 也叫作边界条件、递归基
如图所示,此时函数的空间复杂度为O(n)
递归调用的空间复杂度 = 递归深度 * 每次调用所需的辅助空间
2 函数的基本思想
- 拆解问题
- 把规模大的问题变成规模较小的同类型问题
- 规模较小的问题又不断变成规模更小的问题
- 规模小到一定程度可以直接得出它的解
- 求解
- 由最小规模问题的解得出较大规模问题的解
- 由较大规模问题的解不断得出规模更大问题的解
- 最后得出原来问题的解
- 凡是可以利用上述思想解决问题的,都可以尝试使用递归
- 很多链表、二叉树相关的问题都可以使用递归来解决
- 因为链表、二叉树本身就是递归的结构(链表中包含链表,二叉树中包含二叉树)
3 递归的使用套路
- 明确函数的功能
- 先不要去思考里面代码怎么写,首先搞清楚这个函数的干嘛用的,能完成什么功能?
- 明确原问题与子问题的关系
- 寻找 f(n) 与 f(n – 1) 的关系
- 明确递归基(边界条件)
- 递归的过程中,子问题的规模在不断减小,当小到一定程度时可以直接得出它的解
- 寻找递归基,相当于是思考:问题规模小到什么程度可以直接得出解?
4 例子
4.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)
编写一个函数求第 n 项斐波那契数
4.1.1 递归最原始方法
int fib(int n){
if (n<=2) return 1;
return fib(n-1)+ fib(n-2);
}
- 根据递推式 ,可得知时间复杂度
- 空间复杂度:
4.1.2 优化1
4.1.1出现了特别多的重复计算
这是一种“自顶向下”的调用过程
优化思路:使用数组用数组存放计算过的结果,避免重复计算
///
/// Author: liyanjun
/// description:
/// 使用数组用数组存放计算过的结果,避免重复计算
///
int fib1(int n) {
if (n <= 2) return 1;
List array = List(n+1);
array[1] = array[2] = 1;
return fib1Insert(n, array);
}
int fib1Insert(int n, List array) {
if (array[n] == null) {
array[n] = fib1Insert(n - 1, array) + fib1Insert(n - 2, array);
}
return array[n];
}
- 空间复杂度:
- 时间复杂度:每一个n算一次
4.1.3 优化2
去除递归调用
///
/// Author: liyanjun
/// description: 不用递归
///
int fib2(int n) {
if (n <= 2) return 1;
List array = List(n+1);
array[1] = array[2] = 1;
for (var i = 3; i <= n; i++) {
array[i] = array[i-1] + array[i-2];
}
return array[n];
}
- 空间复杂度:
- 时间复杂度:
- 这是一种”自底向上“的计算过程
4.1.4 优化3
由于每次运算只需要用到数组中的 2 个元素,所以可以使用滚动数组来优化
///
/// Author: liyanjun
/// description: 由于每次运算只需要用到数组中的 2 个元素,所以可以使用滚动数组来优化
///
int fib3(int n) {
if (n <= 2) return 1;
List array = List(2);
array[0] = array[1] = 1;
for (var i = 3; i <= n; i++) {
array[i%2] = array[(i-1)%2] + array[(i-2)%2];
}
return array[n%2];
}
滚动数组思想
array[i%2] = array[(i-1)%2] + array[(i-2)%2];
时间复杂度:O(n),空间复杂度:O(1)
4.1.5 优化4
乘、除、模运算效率较低,建议用其他方式取代,这里采用&计算
///
/// Author: liyanjun
/// description: 乘、除、模运算效率较低,建议用其他方式取代
///
int fib4(int n) {
if (n <= 2) return 1;
List array = List(2);
array[0] = array[1] = 1;
for (var i = 3; i <= n; i++) {
array[i&1] = array[(i-1)&1] + array[(i-2)&1];
}
return array[n&1];
}
时间复杂度:O(n),空间复杂度:O(1)
4.1.5 优化5
可以不用数组,直接定义两个变量
///
/// Author: liyanjun
/// description: 不用数组
///
int fib5(int n) {
if (n <= 2) return 1;
int first = 1;
int second = 1;
for (var i = 3; i <= n; i++) {
second = first + second;
first = second - first;
}
return second;
}
4.1.6 优化6
利用公式
dart代码
///
/// Author: liyanjun
/// description: 不用数组
///
int fib6(int n) {
if (n <= 2) return 1;
double c = sqrt(5);
return ((pow((1+c)/2,n)-pow((1-c)/2,n))/c).floor();
}
时间复杂度、空间复杂度取决于 pow 函数(至少可以低至O(logn) )
4.1.7 对比
main(List args) {
int n = 44;
TimesTools.test('fib0', (){
print(fib0(n));
});
TimesTools.test('fib1', (){
print(fib1(n));
});
TimesTools.test('fib2', (){
print(fib2(n));
});
TimesTools.test('fib3', (){
print(fib3(n));
});
TimesTools.test('fib4', (){
print(fib4(n));
});
TimesTools.test('fib5', (){
print(fib5(n));
});
TimesTools.test('fib6', (){
print(fib6(n));
});
}
结果
【fib0】
开始:2020-11-21 22:17:01.729717
701408733
结束 2020-11-21 22:17:04.244553
耗时:2.509 秒
========================================================
【fib1】
开始:2020-11-21 22:17:04.245959
701408733
结束 2020-11-21 22:17:04.246426
耗时:0.0 秒
========================================================
【fib2】
开始:2020-11-21 22:17:04.246539
701408733
结束 2020-11-21 22:17:04.247040
耗时:0.001 秒
========================================================
【fib3】
开始:2020-11-21 22:17:04.247098
701408733
结束 2020-11-21 22:17:04.247431
耗时:0.0 秒
========================================================
【fib4】
开始:2020-11-21 22:17:04.247466
701408733
结束 2020-11-21 22:17:04.247821
耗时:0.0 秒
========================================================
【fib5】
开始:2020-11-21 22:17:04.247873
701408733
结束 2020-11-21 22:17:04.248069
耗时:0.001 秒
========================================================
【fib6】
开始:2020-11-21 22:17:04.248103
701408733
结束 2020-11-21 22:17:04.249603
耗时:0.001 秒
========================================================
Exited
4.2 上楼梯(跳台阶)
楼梯有 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)
int climbstair(int n){
if (n <= 2) return 1;
int first = 1;
int second = 2;
for (var i = 3; i <= n; i++) {
second = first + second;
first = second - first;
}
return second;
}
main(List args) {
print(climbstair(4));
}
4.3 汉诺塔(Hanoi)
编程实现把 A 的 n 个盘子移动到 C(盘子编号是 [1, n] )
- 每次只能移动1个盘子
- 大盘子只能放在小盘子下面
4.3.1 思路
其实分 2 种情况讨论即可
- 当 n == 1时,直接将盘子从 A 移动到 C
- 当 n > 1时,可以拆分成3大步骤
- ① 将 n – 1 个盘子从 A 移动到 B
- ② 将编号为 n 的盘子从 A 移动到 C
- ③ 将 n – 1 个盘子从 B 移动到 C
- 步骤 ① ③ 明显是个递归调用
4.3.2 代码
/// Author: liyanjun
/// description: 将 [n] 个碟子从 [a] 挪动到 [c]
/// [b] 中间的柱子
/// 思路
/// 其实分 2 种情况讨论即可
///* 当 n == 1时,直接将盘子从 A 移动到 C
///* 当 n > 1时,可以拆分成3大步骤
/// * ① 将 n – 1 个盘子从 A 移动到 B
/// * ② 将编号为 n 的盘子从 A 移动到 C
/// * ③ 将 n – 1 个盘子从 B 移动到 C
/// * 步骤 ① ③ 明显是个递归调用
///
hanoi(int n, String a, String b, String c) {
if (n<=1) {
move(n, a, c);
return;
}
hanoi(n-1, a, c, b);// 将 n – 1 个盘子从 A 移动到 B
move(n, a, c);
hanoi(n-1, b, a, c); //将 n – 1 个盘子从 B 移动到 C
}
move(int no,String from,String to){
print('将 $no 从 $from 移动 $to');
}
4.3.3 复杂度分析
时间复杂度:
空间复杂度:O(n)
5 递归转非递归
递归调用的过程中,会将每一次调用的参数、局部变量都保存在了对应的栈帧(Stack Frame)中
比如下面的代码
void log(n){
if(n<1)return;
log(n-1);
int v = n + 10;
print(v);
}
main(List args) {
log(4);
}
- 若递归调用深度较大,会占用比较多的栈空间,甚至会导致栈溢出
- 在有些时候,递归会存在大量的重复计算,性能非常差
这时可以考虑将递归转为非递归(递归100%可以转换成非递归)
5.1 递归转非递归方法
5.1.1 递归转非递归的万能方法
- 自己维护一个栈,来保存参数、局部变量
- 但是空间复杂度依然没有得到优化
比如上面的代码可变成
class Frame {
int n;
int v;
Frame(this.n,this.v);
}
void log1(n){
List frames= List();
while(n>0){
frames.add(Frame(n, n+10));
n--;
}
while (frames.isNotEmpty) {
Frame f = frames.removeLast();
print(f.v);
}
}
main(List args) {
log1(4);
}
5.1.2 递归转非递归方法1
在某些时候,也可以重复使用一组相同的变量来保存每个栈帧的内容
void log2(n){
for (var i = 1; i <= n; i++) {
print(i+10);
}
}
这里重复使用变量 i 保存原来栈帧中的参数
空间复杂度从 O(n) 降到了 O(1)
6 尾调用
尾调用:一个函数的最后一个动作是调用函数
如果最后一个动作是调用自身,称为尾递归(Tail Recursion),是尾调用的特殊情况
如下代码
test1(){
int a =10;
int b = a+10;
test2(b);
}
test2(int n){
if(n<0)return;
test2(n-1);
}
一些编译器能对尾调用进行优化,以达到节省栈空间的目的
如图,原来test2调用的时候,开辟新空间,但是优化后,test1直接给test2使用
注意 下面的代码不是尾调用
◼ 因为它最后1个动作是乘法
int factorial(int n){
if (n<=1)return 1;
return n* factorial(n-1);
}
6.1 尾调用优化
尾调用优化也叫做尾调用消除(Tail Call Elimination)
- 如果当前栈帧上的局部变量等内容都不需要用了,当前栈帧经过适当的改变后可以直接当作被尾调用的函数的栈帧使用,然后程序可以 jump 到被尾调用的函数代码
- 生成栈帧改变代码与 jump 的过程称作尾调用消除或尾调用优
- 尾调用优化让位于尾位置的函数调用跟 goto 语句性能一样高
◼ 消除尾递归里的尾调用比消除一般的尾调用容易很多
- 比如Java虚拟机(JVM)会消除尾递归里的尾调用,但不会消除一般的尾调用(因为改变不了栈帧)
- 因此尾递归优化相对比较普遍,平时的递归代码可以考虑尽量使用尾递归的形式
这段代码尾调用优化前
优化后
6.2 尾递归示例1 – 阶乘
求 n 的阶乘 123...(n-1)*n (n>0)
源代码
int factorial(int n){
if (n<=1)return 1;
return n* factorial(n-1);
}
尾递归
int factorial1(int n){
return factorial1temp(n,1);
}
int factorial1temp(int n,int re){
if (n<=1)return re;
return factorial1temp(n-1,n*re);
}
6.3 尾递归示例2 – 斐波那契数列
int fib0(int n){
if (n<=2) return 1;
return fib0(n-1)+ fib0(n-2);
}
尾递归优化
int fib0_1(int n){
return fib0_1_temp(n,1,1);
}
int fib0_1_temp(int n,int first,int second){
if (n<=1) return first;
return fib0_1_temp(n-1,second,first+second);
}
7 注意
- 使用递归不是为了求得最优解,是为了简化解决问题的思路,代码会更加简洁
- 递归求出来的很有可能不是最优解,也有可能是最优解