文章目录
- 递归是什么
- 递归的思想
- 递归的限制条件
- 递归举例
- 求n的阶乘
- 分析和代码实现
- 画图推演
- 顺序打印一个整数的每一位
- 分析和代码实现
- 画图推演
- 递归与迭代
- 递归求第n个斐波那契数
- 迭代求第n个斐波那契数
- 拓展练习
- 青蛙跳台阶问题
- 递归求解
- 迭代求解
- 汉诺塔问题
递归是什么
递归是学习C语⾔函数绕不开的⼀个话题,那什么是递归呢?
递归其实是⼀种解决问题的⽅法,在C语⾔中,递归就是函数⾃⼰调⽤⾃⼰。 写⼀个史上最简单的C语⾔递归代码:
#include <stdio.h>
int main()
{
printf("hehe\n");
main();//main函数中⼜调⽤了main函数
return 0;
}
上述就是⼀个简单的递归程序,只不过上⾯的递归只是为了演⽰递归的基本形式,不是为了解决问题,代码最终也会陷⼊死递归,导致栈溢出(Stack overflow)。
递归的思想
把⼀个⼤型复杂问题层层转化为⼀个与原问题相似,但规模较⼩的⼦问题来求解;直到⼦问题不能再被拆分,递归就结束了。所以递归的思考⽅式就是把⼤事化⼩的过程。
递归中的递就是递推的意思,归就是回归的意思,接下来慢慢来体会。
递归的限制条件
递归在书写的时候,有2个必要条件:
- 递归存在限制条件,当满⾜这个限制条件的时候,递归便不再继续
- 每次递归调⽤之后越来越接近这个限制条件
在下⾯的例⼦中,我们逐步体会这2个限制条件
递归举例
求n的阶乘
⼀个正整数的阶乘(factorial)是所有⼩于及等于该数的正整数的积,并且0的阶乘为1。 ⾃然数n的阶乘写作n!。
分析和代码实现
我们知道n的阶乘的公式:n! = n ∗ (n − 1)!
当 n==0 的时候,n的阶乘是1,其余n的阶乘都是可以通过公式计算。
那我们就可以写出函数Fact求n的阶乘,假设Fact(n)就是求n的阶乘,那么Fact(n-1)就是求n-1的阶乘,函数如下:
int Fact(int n)
{
if(n==0)
return 1;
else
return n*Fact(n-1);
}
- 要求n的阶乘,那就是求n-1的阶乘再乘以n
这样的思路就是把⼀个较⼤的问题,转换为⼀个与原问题相似,但规模较⼩的问题来求解的。
画图推演
顺序打印一个整数的每一位
输⼊⼀个整数m,按照顺序打印整数的每⼀位。
⽐如: 输⼊:1234 输出:1234
输⼊:52 输出:52
分析和代码实现
在这之前学习循环的时候我们通过不断模10除10可以逆序打印整数的每一位
1234%10就能得到4,然后1234/10得到123,这就相当于去掉了4
然后继续对123%10,就得到了3,再除10去掉3,以此类推
不断的 %10 和 /10 操作,直到1234的每⼀位都得到;
但现在要求我们顺序打印,该怎么实现呢?
发现其实⼀个数字的最低位是最容易得到的,通过%10就能得到
那我们假设想写⼀个函数Print来打印n的每⼀位,如下表⽰:
Print(n)
如果n是1234,那表⽰为
Print(1234) //打印1234的每⼀位
其中1234中的4可以通过%10得到,那么
Print(1234)就可以拆分为两步:
1. Print(1234/10) //打印123的每⼀位
2. printf(1234%10) //打印4
完成上述2步,那就完成了1234每⼀位的打印
那么Print(123)⼜可以拆分为Print(123/10) + printf(123%10)
以此类推下去,就有:
Print(1234)
==>Print(123) + printf(4)
==>Print(12) + printf(3)
==>Print(1) + printf(2)
==>printf(1)
直到被打印的数字变成⼀位数的时候,就不需要再拆分,递归结束。 那么代码完成也就⽐较清楚:
void Print(int n)
{
if(n>9)
{
Print(n/10);
}
printf("%d ", n%10);
}
在这个解题的过程中,我们就是使⽤了⼤事化⼩的思路:
把Print(1234)打印1234每⼀位,拆解为⾸先Print(123)打印123的每⼀位,再打印得到的4
把Print(123)打印123每⼀位,拆解为⾸先Print(12)打印12的每⼀位,再打印得到的3
直到Print打印的是⼀位数,直接打印就⾏。
画图推演
递归与迭代
递归是⼀种很好的编程技巧,但是和很多技巧⼀样,也是可能被误⽤的,就像练习一求阶乘一样,看到推导的公式,很容易就被写成递归的形式:
但是,但是
在递归函数调⽤的过程中涉及⼀些运⾏时的开销。
在C语⾔中每⼀次函数调⽤,都需要为本次函数调⽤在内存的栈区,申请⼀块内存空间来保存函数调⽤期间的各种局部变量的值,这块空间被称为运⾏时堆栈,或者函数栈帧。
函数不返回,函数对应的栈帧空间就⼀直占⽤,所以如果函数调⽤中存在递归调⽤的话,每⼀次递归 函数调⽤都会开辟属于⾃⼰的栈帧空间,直到函数递归不再继续,开始回归,才逐层释放栈帧空间。 所以如果采⽤函数递归的⽅式完成代码,递归层次太深,就会浪费太多的栈帧空间,也可能引起栈溢出(stackoverflow)的问题。
所以如果不想使⽤递归,就得想其他的办法,通常就是迭代的⽅式(通常就是循环的⽅式)。
⽐如:计算n的阶乘,也是可以产⽣1~n的数字累计乘在⼀起的。
int Fact(int n)
{
int i = 0;
int ret = 1;
for(i=1; i<=n; i++)
{
ret *= i;
}
return ret;
}
上述代码是能够完成任务,并且效率是⽐递归的⽅式更好的。
事实上,我们看到的许多问题是以递归的形式进⾏解释的,这只是因为它⽐⾮递归的形式更加清晰, 但是这些问题的迭代实现往往⽐递归实现效率更⾼。
但如果当⼀个问题⾮常复杂,难以使⽤迭代的⽅式实现时,此时递归实现的简洁性便可以补偿它所带来的运⾏时开销。
递归求第n个斐波那契数
看到这公式,很容易诱导我们将代码写成递归的形式,如下所⽰:
int Fib(int n)
{
if(n<=2)
return 1;
else
return Fib(n-1)+Fib(n-2);
}
如果当我们输入50以上的数字n时,需要很⻓时间才能算出结果,这个计算所花费的时间,是我们很难接受的, 这也说明递归的写法是⾮常低效的,那是为什么呢?
其实递归程序会不断的展开,在展开的过程中,我们很容易就能发现,在递归的过程中会有重复计 算,⽽且递归层次越深,冗余计算就会越多。我们可以作业测试:
#include <stdio.h>
int count = 0;
int Fib(int n)
{
if(n == 3)
count++;//统计第3个斐波那契数被计算的次数
if(n<=2)
return 1;
else
return Fib(n-1)+Fib(n-2);
}
int main()
{
int n = 0;
scanf("%d", &n);
int ret = Fib(n);
printf("%d\n", ret);
printf("\ncount = %d\n", count);
return 0;
}
这⾥我们看到了,在计算第40个斐波那契数的时候,使⽤递归⽅式,第3个斐波那契数就被重复计算了 39088169次,这些计算是⾮常冗余的。
所以斐波那契数的计算,使⽤递归是⾮常不明智的,我们就得 想迭代的⽅式解决
迭代求第n个斐波那契数
我们知道斐波那契数的前2个数都1,然后前2个数相加就是第3个数,那么我们从前往后,从⼩到⼤计算就⾏了。
这样就有下⾯的代码:
int Fib(int n)
{
int a = 1;
int b = 1;
int c = 1;
while(n>2)
{
c = a+b;
a = b;
b = c;
n--;
}
return c;
}
迭代的⽅式去实现这个代码,效率就要⾼出很多了。
有时候,递归虽好,但是也会引⼊⼀些问题,所以我们⼀定不要迷恋递归,适可⽽⽌就好。
拓展练习
青蛙跳台阶问题
一只青蛙一次可以跳上 1 级台阶,也可以跳上2 级。求该青蛙跳上一个n 级的台阶总共有多少种跳法?
递归求解
和斐波那契数列很相似,要求跳上第n级的台阶(n>3),无非就是从n-2跳两级或者n-1的台阶跳1级,那总方法就是f(n)=f(n-2)+f(n-1)
int Fn(int n) {
if (n <= 2) {
return n;
}
else if (n > 2) {
return Fn(n - 1) + Fn(n - 2);
}
}
显然递归求解也涉及到很多的重复计算问题,效率十分之低
迭代求解
斐波那契数列是:1 1 2 3 5 8 13······
青蛙跳台阶数列是:1 2 3 5 8 13······
只需做适当更改即可:
int Fn(int n) {
int a = 1;
int b = 2;
int c = 0;
if (n == 1) {
return 1;
}
else if (n == 2) {
return 2;
}
else{
while(n>2){
c = a + b;
a = b;
b = c;
n--;
}
}
return c;
}
汉诺塔问题
汉诺塔问题是一个经典的问题。汉诺塔(Hanoi Tower),又称河内塔,源于印度一个古老传说。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,任何时候,在小圆盘上都不能放大圆盘,且在三根柱子之间一次只能移动一个圆盘。问应该如何操作?
注意:求得是最少移动次数
- 当n=1时,直接将a移到c即可
- 当n=2时,a->b,a->c,b->c
- 当n=3时,a->c,a->b,c->b,a->c,b->a,b->c,a->c
当n越来越大时,如果我们要一次一次想再把它每一步写出来,是没有意义的了
所以我们先不关注每一步细节,思考一下移动的核心逻辑是什么
核心逻辑就是每一次移动都是把最底下的盘子移动到目标盘,此时只关注剩下的n-1个盘子,又变为了新的汉诺塔问题
例如当n=3的时候,(从下到上依次设定为盘子1,2,3)我们前四步进行的就是把盘子3移动到c,此时初始柱为a,中转柱为b,目标柱为c。
此时3已经移动到最后的正确位置了,直接忽略,而接下来要做的就是把b柱上的盘子1和2移到c,这不就是n==2
的汉诺塔问题吗,此时初始柱变成了b,中转柱变成了a,目标柱就是c,我们在第5-7所做的事就是把盘子2移动到c
最后一步,即变为了只有一个盘子的汉诺塔问题,直接将盘子1移动到c即可。
上述分析仍然太过复杂,不妨这样考虑:
-
第一步,n-1个盘子移动到中转柱,这其实何尝不是一个汉诺塔问题呢
-
第二步,最底下的盘子移动到目标柱
-
第三步,中转柱的n-1个盘子移动到目标柱,这又何尝不是一个汉诺塔问题呢
并且,这种思维还能帮我们推导出n个盘子移动所需要的最少步数
手写推导如下:
就是高中数学很简单的数列题
如果想用数学归纳法求解也是可以的,也很简单,这里就不过多赘述
代码如下:
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
/*
pos1:初始柱
pos2::中转柱
pos3:目标柱
*/
void move(char x,char y)
{
printf("%c->%c ", x, y);
}
void hanoi(int n, char pos1, char pos2, char pos3)
{
if (n == 1)
move(pos1, pos3);
else
{
hanoi(n - 1, pos1, pos3, pos2);//把n-1个盘子移动到中转柱pos2上,
//是一个汉诺塔问题,对于这n-1个盘子来说,
//初始柱为pos1,中转柱为pos3,目标柱为pos2
move(pos1,pos3);//把初始柱最下面盘子移动到目标柱
//此时n-1个盘子都在pos2上,
//这也是一个汉诺塔问题
//初始柱为pos2,中转柱为pos1,目标柱为pos3
hanoi(n - 1, pos2, pos1, pos3);
}
}
int main()
{
int n = 0;
scanf("%d", &n);
hanoi(n, 'a', 'b', 'c');
return 0;
}
如果我们更多关注每一次移动的细节会发现:
要保持最小的步数,每一次汉诺塔问题(无论是最初还是递归过程中的),如果此时初始柱盘子数为偶数,我们第一步是把最上面的盘子移动到中转柱,如果为奇数,我们第一步则是将其移动到目标柱。(当做一个归纳结论了解一下就行)
以上就是有关递归的详细介绍啦,各位大佬有什么问题欢迎在评论区指正,您的支持是我创作的最大动力!❤️