目录
🪂前言
学完函数后,我们在刷题过程中或许会遇见一些问题不太好想出解法,这时候可以考虑考虑递归来解题,使用递归往往可以简化代码,短短几行或许就能解决问题,不过具有一定的抽象性,需要理解递归思想才能运用自如。
本文就来分享一波个人对于递归的见解和心得,由于水平有限,读者各取所需即可。
🪂1.关于递归
🪂1.1什么是递归
程序调用自身的编程技巧称为递归( recursion)。
递归做为一种算法在程序设计语言中广泛应用。 一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解。
🪂举个例子:
我要把一个字符串两两字符互换位置,先把首尾两个交换,再去掉首尾后,把中间的字符串的首尾两个交换,再去掉首尾......一直换直到不能再换为止,其实一直在重复一些操作,并且问题规模再逐渐变小(字符串不断"缩水")。
🪂1.2递归策略
只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。
递归的主要思考方式在于:把大事化小
🪂1.3递归的两个必要条件
1.存在限制条件,当满足这个限制条件的时候,递归便不再继续。
2.每次递归调用之后越来越接近这个限制条件。
递———连续调用自身,过程传递。
归———递到最后一个过程结束不再传递时,不断返回上一个过程,直到回到最初的过程。
🪂1.4 递归演示动图GIF
下面是之前写的一个递归解题的动图演示过程,可供参考递归过程。
感兴趣可以去看看那篇博客 :妙解倒置字符串单词问题
🪂1.4几道例题
🪂1.4.1第一道
1.接受一个整型值(无符号),按照顺序打印它的每一位。
例如: 输入:1234,输出 1 2 3 4。
分析:
%10 可以取到最后一位,/10 可以抛弃最后一位。
递归从而把该数一位一位地拆分下来再一个一个打印出来,最后拆下来的最先被打印。
void print(unsigned int n)
{
if (n / 10 != 0)
{
print(n / 10);
}
printf("%d ", n % 10);
}
🪂1.4.2第二题
2.编写函数不允许创建临时变量,求字符串的长度。
假如允许创建临时变量,则
int MyStrlen(char *str)
{
int count = 0;
while(*str++ != '\0')
{
count++;
}
return count;
}
而不允许的话,可以使用递归法:
int MyStrlen(char* str)
{
if (*str != '\0')
{
return 1 + MyStrlen(str + 1);
}
else
return 0;
}
🪂1.4.3第三题
3.求n的阶乘
公式:n! = n*(n-1)*(n-2)*...*1。
int fac(int n)
{
if (n >= 1)
return n * fac(n - 1);
else
return 1;
}
int main()
{
int n = 0;
scanf("%d", &n);
printf("%d", fac(n));
}
🪂2.递归与迭代
🪂2.1递归的缺陷
循环是一种迭代方式。
系统分配给程序的栈空间是有限的,但是如果出现了死循环,或者(死递归),这样有可能导致一直开辟栈空间,最终产生栈空间耗尽的情况,这样的现象我们称为栈溢出。
有些问题使用递归能够很方便地解决,但是有时候可能会栈溢出或超时,也需要注意使用场合。
🪂2.2如何解决上述的问题
1. 将递归改写成非递归,可以是迭代。
2. 使用static对象替代 nonstatic 局部对象。在递归函数设计中,可以使用 static 对象替代 nonstatic 局部对象(即栈对象),这不仅可以减少每次递归调用和返回时产生和释放 nonstatic 对象的开销,而且 static 对象还可以保存递归调用的中间状态,并且可为各个调用层所访问。
🪂2.3斟酌递归与迭代
1. 许多问题是以递归的形式进行解释的,这只是因为它比非递归的形式更为清晰。
2. 但是这些问题的迭代实现往往比递归实现效率更高,虽然代码的可读性稍微差些。
3. 当一个问题相当复杂,难以用迭代实现时,此时递归实现的简洁性便可以补偿它所带来的运行时开销。
🪂3.更多例题
🪂 3.1字符串翻转
比较麻烦的是不能使用库函数,函数参数也限定只有一个,那么自己写一个求字符串长度的函数来使用就好了。
🪂3.1.1思路一
设定左标string右标string+len,左标用string+1递归,不断右移,利用静态变量计递归轮数,自制求字符串长度函数计当前字符串长度(因为string+1把进入函数的字符数组长度不断缩短),利用关系算式得到右标,并且右标也在不断左移。
左右标指向的元素就是当前还未交换的元素中最左端的和最右端的,每次交换都是两级互换,换完后再移动左右标。
右标<=0说明互换完毕。
#include<stdio.h>
int MyStrlen(char* str)
{
if (*str != '\0')
{
return 1 + MyStrlen(str + 1);
}
else
return 0;
}
void reverse_string(char* string)
{
static int count = 0;
int len = MyStrlen(string) - 1 - count;
if (len > 0)
{
char tmp = 0;
tmp = *string;
*string = *(string + len);
*(string + len) = tmp;
count++;
reverse_string(string + 1);
}
}
int main()
{
char str[30] = "Little bit,big dream";
printf("翻转之前为%s\n", str);
reverse_string(str);
printf("翻转之后为%s", str);
return 0;
}
🪂3.1.2思路二
逆置字符串,用循环的方式实现非常简单
1. 给两个指针,left放在字符串左侧,right放在最后一个有效字符位置。
2. 交换两个指针位置上的字符。
3. left指针往后走,right指针往前走,只要两个指针没有相遇,继续步骤2,两个指针相遇后,逆置结束。
void reverse_string(char* arr)
{
char *left = arr;
char *right = arr+strlen(arr)-1;
while(left<right)
{
char tmp = *left;
*left = *right;
*right = tmp;
left++;
right--;
}
}
int main()
{
char str[30] = "Little bit,big dream";
printf("翻转之前为%s\n", str);
reverse_string(str);
printf("翻转之后为%s", str);
return 0;
}
🪂3.1.3思路三
对于字符串“abcdefg”,递归实现的大概原理:
1. 交换a和g:先把a放到临时变量tmp处,把g放到原来的a处,再把原来的g处放上'\0',相当于字符串末尾向前移了一位,然后指针str+1,相当于向后移一位指向了b。
2. 以递归的方式逆置源字符串的剩余部分,剩余部分可以看成一个有效的字符串,再以类似的方式逆置。
3.注意再进入递归前要判断字符串长度不小于2才能继续逆置,当无法继续深入再传递时,开始回归,把放了'\0'的位置用tmp存的字符放入,最后完成翻转。
void reverse_string(char* arr)
{
int len = strlen(arr);
char tmp = *arr;
*arr = *(arr+len-1);
*(arr+len-1) = '\0';
if(strlen(arr+1)>=2)
reverse_string(arr+1);
*(arr+len-1) = tmp;
}
🪂3.2斐波那契数列问题
🪂3.2.1什么是斐波那契数列
斐波那契数列(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*)
递归的很多过程有重复,会导致计算效率低下。
有了递推关系就很容易能使用递归,也就可以说遇到有递推关系的问题基本都可以先考虑用递归。
经典的斐波那契数列问题有:走台阶、兔子繁衍等
🪂3.2.2一般的函数递归模板
斐波那契函数的递归实现:
unsigned int fib(unsigned int n)
{
if (n > 2)
{
return fib(n - 1) + fib(n - 2);
}
else
return 1;
}
int main()
{
unsigned int n = 0;
scanf("%u", &n);
printf("%u", fib(n));
return 0;
}
🪂3.2.3 从栈帧角度理解深入理解递归
关于函数栈帧的内容:
一文带你深入浅出函数栈帧http://t.csdn.cn/b7XVZ
基本认识
1. 递归本质也是函数调用,是函数调用,本质就要形成和释放栈帧
2. 根据栈帧的学习,调用函数是有成本的,这个成本就体现在形成和释放栈帧上:时间+空间
3. 所以,递归就是不断形成栈帧的过程
理论认识
1. 内存和CPU的资源是有限的,也就决定了,合法递归是绝对不能无限递归下去,也就是递归需要在达到某种条件时结束,这个条件就是递归出口
2. 递归不是什么时候都能用,而是要满足自身的应用场景,即:目标问题的子问题,也可以采用相同的算法解决,本质就是分治的思想
3. 核心思想:大事化小+递归出口
大量重复计算的体现:
如何解决弊端?
考虑迭代的方法
简单的动态规划思路
用动态内存分配的数组来存储递推过程中的斐波那契数列的数,数组下标代表第几个斐波那契数。
#include <stdio.h>
#include <windows.h>
int Fib(int n)
{
int *dp = (int*)malloc(sizeof(int)*(n+1));
if(NULL == dp)
{
return -1;
}
//[0]不用(当然,也可以用,不过这里我们从1开始,为了后续方便)
//条件初始化
dp[1] = 1;
dp[2] = 1;
for (int i = 3; i <= n; i++)
{
//递推过程
dp[i] = dp[i - 1] + dp[i - 2];
}
int ret = dp[n];
free(dp);
return ret;
}
int main()
{
int n = 42;
double start = GetTickCount();
int x = Fib(n);
double end = GetTickCount();
printf("fib(%d): %d\n", n, x);
//end-start得到程序运行时间
printf("%lf ms\n", (end - start)/1000);
return 0;
}
可以看出运算速度比递归要快得多
还能更进一步优化吗?
对于斐波那契数列的任何一个数字,都只和前两个数据相关,上一种思路中保留了中间数值,那可不可以不保留呢?
这样的话只使用三个变量即可,每次计算完后更新变量。这样相对来说更省空间。
#include <stdio.h>
#include <windows.h>
int Fib(int n)
{
int first = 1;
int second = 1;
int third = 1;
while (n > 2) {
third = second + first;
first = second;
second = third;
n--;
}
return third;
}
int main()
{
int n = 42;
double start = GetTickCount();
int x = Fib(n);
double end = GetTickCount();
printf("fib(%d): %d\n", n, x);
printf("%lf ms\n", (end - start) / 1000);
return 0;
}
为什么迭代的方案效率就高呢?
因为没有多余的函数调用,从始至终也就多创建一个函数栈帧。
🪂3.2.4 走台阶问题
问题:
小乐乐上课需要走n阶台阶,因为他腿比较长,所以每次可以选择走一阶或者走两阶,那么他一共有多少种走法?
思路分析:
设函数fib(n),自变量n为需要走的台阶数,函数值为走法数。
n=1时只有走一阶的方法,n=2时只有走两次一阶或者走两阶的方法,也就是fib(1) = 1,fib(2) = 2,作为递推的基础。
如果第一步走一阶那么剩下的n-1阶有fib(n-1)种走法
如果第一步走两阶那么剩下的n-2阶有fib(n-2)种走法
相当于在一步时分出了两条岔路口,就有了fib(n) = fib(n-1)+fib(n-2)。
实现代码:
unsigned int fib(unsigned int n)
{
if(n < 3)
{
return n;
}
else
{
return fib(n - 1) + fib(n - 2);
}
}
int main()
{
unsigned int n = 0;
scanf("%u", &n);
printf("%u", fib(n));
return 0;
}
🪂3.2.5 兔子繁殖问题
问题描述:
兔子从出生的第三个月开始繁殖,此后每个月都会繁殖,且每次繁殖得到的都为一对新的异性兔子。
现在在封闭环境中,有一对异性刚出生的兔子,不考虑死亡,求n个月后有多少对兔子。
分析如下:
这里计算的单位都是对(一雌一雄)
当前月的可生育兔子=上个月的可生育兔子+上上个月新兔子(这个月相当于第三个月)
第三个月开始生育之后:每个月的新兔子=当月可生育兔子
每个月的兔子对数为:当前可生育+当月新兔子+上个月新兔子(还不可以生)设函数F(n),自变量n为月数,函数值为第n月的兔子对数。
得出递推公式F(n)=F(n-1)+F(n-2)
如表:
月数n | 兔子对数F(n) |
---|---|
1 | 1 |
2 | 1 |
3 | 2 |
4 | 3 |
... | ... |
n | F(n-1)+F(n-2) |
实现代码:
#include <stdio.h>
long long RabBre(int n)
{
if(n <= 2)
return 1;
if(n>2)
return RabBre(n - 1) + RabBre(n - 2);
}
int main()
{
int n;
scanf("%d", &n);
printf("%d个月后兔子对数%lld\n", n, RabBre(n));
return 0;
}
🪂3.3 汉诺塔问题
汉诺塔(Tower of Hanoi),又称河内塔,是一个源于印度古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。
一般都提问的是n阶汉诺塔移动圆盘所需步数是多少。
三阶演示图:
不使用递归的话,找到塔数与步数之间的数值联系也可以直接计算
实现代码:
#include<stdio.h>
#include<math.h>
int main()
{
int num = 0;
scanf("%d", &num);//塔数
printf("完成%d层的汉诺塔需要%d步\n", num, (int)pow(2,num) - 1);
return 0;
}
使用递归的话
思路分析:
设函数f(n)为从一个针移动n阶圆盘到另一个针上所需步数,则上图中的(1)移动步数为f(n-1),上图的(2)移动步数为1,而(3)移动步数为f(n-1), 所以总步数为
f (n -1 ) + 1 + f (n - 1); -> 2 * f (n - 1) +1;
实现代码:
#include<stdio.h>
int Hanio(int num)
{
if(1 == num)
return 1;
else
return 2 * Hanio(num - 1) + 1;
}
int main()
{
int num = 0;
scanf("%d", &num);//塔数
int ret = Hanio(num);
printf("完成%d层的汉诺塔需要%d步\n", num, ret);
return 0;
}
推理得:
梵天说假如把64个金片从A挪到C,那么这个世界就毁灭了(震惊😲)
然而2 ^ 64 - 1 -> 大约是1800亿亿步,这是个什么概念呢?
1年有365天,每天24小时,每小时是3600秒。如果1秒钟移动1次,如果把这64块金片全部移动完,大概需要5800亿年😅。宇宙形成到现在也就138亿年(笑🤣)
🪂4. 关于递归学习的建议
🪂敬请期待更好的作品吧~
感谢观看,你的支持就是对我最大的鼓励,阁下何不成人之美,点赞收藏关注走一波~