携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情。
前言
学完C语言的函数后,我们在刷题过程中或许会遇见一些问题不太好想出解法,这时候可以考虑考虑递归来解题,使用递归往往可以简化代码,短短几行或许就能解决问题,不过具有一定的抽象性,需要理解递归思想才能运用自如。
本文就来分享一波个人对于递归的见解和心得,新手上路,写文章难免纰漏重重,文笔拙劣,还请读者见谅,希望于你有益。
关于递归
什么是递归
程序调用自身的编程技巧就称为递归。
递归做为一种算法在程序设计语言中广泛应用。 一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解。
举个例子
我要把一个字符串两两字符互换位置,先把首尾两个交换,再去掉首尾后,把中间的字符串的首尾两个交换,再去掉首尾......一直换直到不能再换为止,其实一直在重复一些操作,并且问题规模再逐渐变小(字符串不断"缩水")。
递归策略和两个必要条件
只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。递归的主要思考方式在于:把大事化小。
必要条件
1.存在限制条件,当满足这个限制条件的时候,递归便不再继续。
2.每次递归调用之后越来越接近这个限制条件。
递———连续调用自身,过程传递。
归———递到最后一个过程结束不再传递时,不断返回上一个过程,直到回到最初的过程。
例题讲解
例题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);
}
例题2
2.编写函数不允许创建临时变量,求字符串的长度。
假如允许创建临时变量,创建临时变量计数器,直接遍历字符串,只要没遇到'\0'就让计数器+1,最后返回计数器的值。
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;
}
例题3
3.求n的阶乘 n的阶乘公式:n! = nx(n-1)x(n-2)x...x1。
int fac(int n)
{
if (n >= 1)
return n * fac(n - 1);
else
return 1;
}
例题4
4.字符串翻转
比较麻烦的是不能使用库函数,函数参数也限定只有一个,那么自己写一个求字符串长度的函数来使用就好了。
思路1:非递归的迭代法
逆置字符串,用循环的方式实现非常简单
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--;
}
}
思路2:递归法
比如对于字符串“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;
}
深入典型题
斐波那契数列问题
什么是斐波那契数列
斐波那契数列(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)
递归模板
unsigned int fib(unsigned int n)
{
if (n > 2)
{
return fib(n - 1) + fib(n - 2);
}
else
return 1;
}
走台阶问题
问题
小乐乐上课需要走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);
}
}
兔子繁殖问题
问题描述
兔子从出生的第三个月开始繁殖,此后每个月都会繁殖,且每次繁殖得到的都为一对新的异性兔子。
现在在封闭环境中,有一对异性刚出生的兔子,不考虑死亡,求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) |
代码实现
long long RabBre(int n)
{
if(n <= 2)
return 1;
if(n>2)
return RabBre(n - 1) + RabBre(n - 2);
}
汉诺塔问题
汉诺塔(Tower of Hanoi),又称河内塔,是一个源于印度古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。
一般都提问的是n阶汉诺塔移动圆盘到另一根柱子上所需步数是多少。
三阶演示图
不用递归
不使用递归的话,找到塔数与步数之间的数值联系也可以直接计算
long long Hanio(int num)
{
return (long long)(pow(2,num) - 1);
}
使用递归
思路分析
将n个盘子从A针移动到C针可以分解为三个步骤:
1.将A上n-1个盘子借助C针移动到B针
2.将A针剩下的一个盘子移动到C针
3.将B针上的n-1个盘子借助A针移动到C针
其中1、3的动作本质是相同的,递归调用解决
设函数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,这样一来我们就得到了递推公式。有了递推关系就很容易能使用递归,也就可以说遇到有递推关系的问题基本都可以先考虑用递归。
代码实现
long long Hanio(int num)
{
if(1 == num)
return 1;
else
return 2 * Hanio(num - 1) + 1;
}
震惊
梵天说假如把64个金片从A挪到C,那么这个世界就毁灭了(震惊😲)
然而2 ^ 64 - 1 -> 大约是1800亿亿步,这是个什么概念呢?1年有365天,每天24小时,每小时是3600秒。如果1秒钟移动1次,如果把这64块金片全部移动完,大概需要5800亿年😅。宇宙形成到现在也就138亿年(笑🤣)
递归与迭代
从函数栈帧角度深入理解递归
关于函数栈帧的内容,可以移步至此:[深入浅出C语言]深入函数栈帧 - 掘金
基本认识
递归本质也是函数调用,是函数调用,本质就要形成和释放栈帧
根据在栈帧那一章节的学习,我们知道调用函数是有成本的,这个成本就体现在形成和释放栈帧上:时间+空间
所以,递归就是不断形成栈帧的过程
理论认识
内存和CPU的资源是有限的,也就决定了,合法递归是绝对不能无限递归下去,也就是递归需要在达到某种条件时结束,这个条件就是递归出口
递归不是什么时候都能用,而是要满足自身的应用场景,即:目标问题的子问题,也可以采用相同的算法解决,本质就是分治的思想
核心思想:大事化小+递归出口
系统分配给程序的栈空间是有限的,但是如果出现了死循环,或者(死递归),这样有可能导致一直开辟栈空间,最终产生栈空间耗尽的情况,这样的现象我们称为栈溢出。
有些问题使用递归能够很方便地解决,但是有时候可能会栈溢出或超时,也需要注意使用场合。
大量重复计算的体现
就比如这张图,算f(6)要先求出f(4)和f(5),而求f(5)又要先求出f(3)和f(4),求f(4)又要先求出f(3)和f(2)......你会发现仅这种图所演示的就已经需要多次的重复计算了。
我们再来看看递归层数较深的情况,比如斐波那契函数的f(41)、f(42)和f(43)。
解决递归缺陷的方法
将递归改写成非递归,可以是迭代。
使用static对象替代 nonstatic 局部对象。在递归函数设计中,可以使用 static 对象替代 nonstatic 局部对象(即栈对象),这不仅可以减少每次递归调用和返回时产生和释放 nonstatic 对象的开销,而且 static 对象还可以保存递归调用的中间状态,并且可为各个调用层所访问。
迭代来解决递归弊端
简单的动态规划思路
用动态内存分配的数组来存储递推过程中的斐波那契数列的数,数组下标代表第几个斐波那契数。
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 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;
}
为什么迭代的方案效率就高呢?
因为没有多余的函数调用,从始至终也就多创建一个函数栈帧。
斟酌递归与迭代
许多问题是以递归的形式进行解释的,这只是因为它比非递归的形式更为清晰。
但是这些问题的迭代实现往往比递归实现效率更高,虽然代码的可读性稍微差些。
当一个问题相当复杂,难以用迭代实现时,此时递归实现的简洁性便可以补偿它所带来的运行时开销。
如果想要进阶,除了基本的递归学习以外,需要深入学习分治算法思想和递归形成的数据结构(二叉树之类的)。
感谢观看,你的支持就是对我最大的鼓励!