递归是一种很神奇的编程操作,利用递归往往可以用简洁的代码去处理问题;不过虽然他的代码十分简单,但其背后的原理和机制却容易让人琢磨不透,本文将简单地介绍一下递归的原理并用两道基础题目介绍一下递归在编程中的用法
目录
壹。 递归的介绍
结合一个最简单的递归用法(求n的阶乘)我们对递归进行几个角度的理解
//题目:求n的阶乘(递归处理)
#include <cstdio>
#include <iostream>
using namespace std;
//1.这就是我们的功能函数(递归函数)——实现的功能是求传入参数的阶乘
double fact( int n )
{
//3.递归结束的条件(边界)
if(n == 1 || n == 0) return 1;
//2.迭代过程:即不断缩小参数的范围去逼近边界(结束的条件)
else return n * fact(n-1);
}
int main()
{
int n;
cin>>n;
printf("fact(%d) = %.0f\n", n, fact(n));
return 0;
}
递归(Recursion)是指在函数的定义中使用函数自身的方法。我们来试着从各个角度来理解一下递归。
1.函数调用的角度:递归其实也就是(特殊的)函数调用,其特殊之处在于递归是在函数内部自己调用自己
2.从编程技巧来说:递归是一种“大事化小”的编程技巧,即我们尝试去把原本一个复杂的问题给分成一个或几个更加简单的问题。
递归的基本思想就是把规模大的问题转化为规模小的相似的子问题来(即不断缩小参数范围)解决。又由于我们转化后的问题与原问题具有相似性,往往解决也使用的是同一个方法,因此可以继续调用原功能函数进行处理
3.从字面上来看:递归就是递与归。
递是“迭代”:
迭代即“重复反馈过程的活动,其目的通常是为了逼近所需目标或结果”
在递归中,我们是通过不断缩小(参数)范围去逼近递归结束的条件的,而这个缩小参数范围的过程就是递。
体现在后面的代码部分即步骤2,寻找等价关系式从而不断迭代去缩小范围
归则是回归:
即我们把小问题解决后再回到原先的大问题,去解决原本我们要解决的问题。
体现在后面的代码部分即步骤3,在遇到递归结束的条件时终止并返回
贰。 递归函数的书写
在初学递归时,我们往往无法准确理解递归的意思,但这其实并不影响我们正确地去书写递归函数。
要正确的书写递归函数,我们往往需要三个步骤。
步骤一:确定递归函数的参数和功能:
要做到:理解传入递归函数的参数是什么含义以及我们要对这些参数进行什么操作。
递归中第一步也是最重要的一步就是去确定递归函数,主要包含两个部分:一是要去确定递归函数的功能,二是要确定需要什么参数传入递归函数并明白传入参数的意义。
注意,在这一步我们并不用写出函数体内具体的代码实现,而只用空想去确定参数和功能
1.在我们求阶乘的递归函数中,我们要实现的功能很简单,就是去求n的阶乘
2.在递归函数想要实现的功能的基础上,我们去分析应该传入什么参数可以易知应该传入一个int类型n,对这个n要进行的操作就是求n的阶乘
在确定了递归函数的功能和参数后,就是具体递归函数内代码的书写了,分为步骤2-递归结束的条件(归)和步骤3-迭代过程(递)
这两个部分都是代码实现部分,因此我们放到一起来讲。
在确定完递归函数的功能和参数后,我们就要对传入的参数进行操作了。一般而言,操作只有两种,要么递(找到等价关系式进行迭代),要么就是归(递归结束 进行返回)
这两个方法综合在一起来看其实我们就是要不断进行步骤2迭代去缩小参数范围,直到缩小范围到我们已知的量,这时候再去进行步步骤3-去结束递归
步骤二:找到等价关系式进行迭代
首先我们先看步骤2——找到等价关系式进行迭代。在前面的分析中我们知道了,想用递归解决问题应该不断迭代去缩小(参数)范围直到遇到我们已知的值。
那么实现步骤2的关键就应该是找到等价关系式,要求等价关系式能缩小范围。
此时的等价关系式就应该是:
fact(n) = n * fact(n-1);
在这个等价关系式里面,我们成功地把参数范围从n缩小成了n-1,且没有引入新的方法,所调用的函数仍是我们的递归函数。因此等价关系式成立
步骤三:确定递归结束的条件
现在我们实现了步骤2,但是只有步骤2的话,我们就会不断地进行迭代而没有一个终点,这很明显是不可以的(显然参数范围也不是可以无限缩小的),因此我们还需要最后一步,就是找到递归结束的条件。
实现步骤3的关键是知道迭代的边界在哪里,即什么是我们已知的(显然,迭代到已知的地方就可以终止了,无需继续迭代缩小范围)
在求阶乘中,我们已知的就是n==1 和 n==0 时 ,他们的阶乘应该是1
所以递归结束的条件是
if(n == 1 || n == 0) return 1;
步骤2 3 的总结:
1.通过步骤2和步骤3,我们实现了递归的整个过程。综合两个步骤来看,每次递归函数传进来一个参数n,我们都会对他进行判断——去看它究竟是递还是归
因为判断的过程是,如果不是归(递归解释),就是递(进行迭代)。因此我们书写代码的顺序应该是先步骤3再步骤2
叁。 两道例题
a. 24点问题
题目:
输入有多组数据。每次输入四个整数,请你判断通过在其中任意添加加减乘除符号能否凑出24点。如果能则输出yes 否则输出no
#include <bits/stdc++.h>
using namespace std;
int n;
int nums[4];
int flag;
void f(int nums[], int n)
{
//3.递归结束的条件(边界)
if(n==1)/
{
if(nums[n-1]==24) flag = 1;
return ;
}
//2.迭代过程:即不断缩小参数的范围去逼近边界(结束的条件)
//遍历两个数的组合
for(int i=0; i<n-1; i++)
{
for(int j=i+1; j<n; j++)
{
int a=nums[i], b=nums[j];
nums[j] = a + b;//不断计算当前值放给nums[j]
nums[i] = nums[n-1];//不断把未计算的值放给nums[i]
f(nums, n-1);
nums[j] = a * b;
nums[i] = nums[n-1];
f(nums, n-1);
nums[j] = a - b;
nums[i] = nums[n-1];
f(nums, n-1);
nums[j] = b - a;
nums[i] = nums[n-1];
f(nums, n-1);
//注意除法的时候要额外判断一下分母不为0的情况
if(b!=0 && a%b==0)
{
nums[j] = a / b;
nums[i] = nums[n-1];
f(nums, n-1);
}
if(a!=0 && b%a==0)
{
nums[j] = b / a;
nums[i] = nums[n-1];
f(nums, n-1);
}
//返回原先的状态
nums[i] = a;
nums[j] = b;
}
}
}
int main()
{
while(1)
{
for(int j=0; j<4; j++)
{
cin >> nums[j];
}
if( nums[0]==0 && nums[1]==0 && nums[2]==0 && nums[3]==0 ) return 0;//特判排除特殊情况
flag = 0 ;
f(nums, 4);
if(flag) cout<<"Yes"<<endl;
else cout<<"No"<<endl;
}
}
三个步骤的书写:
1.此时的递归函数就是去求传入的n个数能否有可能组合成24。参数有两个,一个是n——表示当前还有几个数要操作,另一个即使存放这n个数的num【】数组
2.迭代过程:一开始是对四(n=4)个数进行分析,我们希望能让操作数越来越小。所以每次取其中的两个数,遍历他们的加减乘除操作,把n个数变成n-1个数
3.递归结束的条件:每一次迭代都是让操作数n减1,所以当操作数n为1时就不用再进行迭代了。此时直接判断当前的数是否为24即可
代码总体分析:
- 首先定义了全局变量n、nums和flag,分别用于存储输入的数字个数、存储输入的四个数字、判断是否能得到24的标志位。
- 定义了一个递归函数f,接受两个参数:nums数组和n。
- 在f函数中,首先判断递归结束的条件,即当n为1时,判断最后一个数字是否为24,如果是,则将标志位flag置为1。
- 然后进行迭代过程,遍历两个数的组合,进行加、减、乘、除的运算,并将结果递归传入下一层。
- 在每次迭代中,将计算过的值放入nums[j],将未计算的值放入nums[i],然后调用递归函数f。
- 在每次迭代结束后,需要将nums[i]和nums[j]恢复为原来的值,以返回原先的状态。
- 在主函数中,通过一个循环,不断输入四个数字,并调用f函数判断是否能得到24。如果flag为1,则输出"Yes",否则输出"No"。
注意:
注意此道题与求阶乘有一个区别,如图:
求阶乘时:我们每次迭代都只有一种情况,是线性的,因此只需要一路迭代即可
求24点时:每次迭代都会有多种情况(加减乘除),是树状的。因此每一次迭代都是有多个分支的——这就要求我们要迭代结束后要返回原先的状态。
//返回原先的状态
nums[i] = a;
nums[j] = b;
在迭代的过程中,我们改变了num【i】和num【j】的值,因此在迭代函数的最后,我们要去恢复原先的状态
b. 汉诺塔问题:
题目:
如图有一个三根柱子的汉诺塔,输入为一正整数n,要求你将n层汉诺塔从x柱移到y柱的过程输出出来
代码:
#include <cstdio>
#include <iostream>
#include <algorithm>
using namespace std;
int c = 0;//记录步数
//此函数适用于j柱子上只有1个盘子时,将j柱子上的盘子移到k柱子上(并打印他的过程及编号m)
void moveHanoi(char j, int m, char k)
{
//将编号为m的圆盘,从j移动到k
printf(" %2d. Move disk %d from %c to %c\n", ++c, n, j, k);
return ;
}
//递归函数——功能是把x上编号从1到n的圆盘都移动到z上(y柱子是辅助柱子)
void hanoi(int n, char x, char y, char z)
{
//3.递归结束的条件(边界)——当n为1,表示x柱子上只有一个盘子,所以可以直接移动
if (n == 1)
{
moveHanoi(x, 1, z);
}
//2.迭代过程:
else
{
hanoi(n - 1, x, z, y);//将x上编号1到n-1的圆盘移动到y
moveHanoi(x, n, z); //将编号为n的圆盘移动到z
hanoi(n - 1, y, x, z);//将y上编号1到n-1的圆盘移动到z
}
}
int main()
{
int n ;
char x,y,z;
while(cin>>n)
{
char x = 'X', y = 'Y', z = 'Z';
c = 0;
hanoi(n,x,y,z);
cout<<endl;
}
return 0;
}
三个步骤分析:
1.递归函数功能和参数的确定:这里面我们要实现的是把n层汉诺塔从x柱子移到z柱子上。
所以递归函数直接去实现这个功能
//递归函数——功能是把x上编号从1到n的圆盘都移动到z上(y柱子是辅助柱子)
void hanoi(int n, char x, char y, char z)
注意:这里要理解函数的参数意义,区分函数的参数xyz柱子与我们实际的xyz柱子
2.迭代过程:
//2.迭代过程:
else
{
hanoi(n - 1, x, z, y);//将x上编号1到n-1的圆盘移动到y
moveHanoi(x, n, z); //将编号为n的圆盘移动到z
hanoi(n - 1, y, x, z);//将y上编号1到n-1的圆盘移动到z
}
移动应该是先移n盘到z,再n-1.....直到1。
所以进入迭代过程时,我们先把n上面的1到n-1个盘子都先移到辅助柱子y,然后把n盘从x柱子移到z柱子。——这就完成了一个盘子的移动
随后我们继续调用现在这个函数,去实现把y柱子上的圆盘移动到z
3.递归结束:
当n为1时,此时显然已经不用再进入迭代过程了(因为此时可以直接移动;且若再进入迭代过程,n-1会出现0)
注:此时仅有一个盘子时的移动过程单独分离利用一个函数实现了(便于打印结果)
//此函数适用于j柱子上只有1个盘子时,将j柱子上的盘子移到k柱子上(并打印他的过程及编号m)
void moveHanoi(char j, int m, char k)
{
//将编号为m的圆盘,从j移动到k
printf(" %2d. Move disk %d from %c to %c\n", ++c, n, j, k);
return ;
}
黑盒子:
汉诺塔的递归代码书写并不复杂,但很可能写出来之后也不知道递归函数具体是怎么运行出结果的,这是很正常的学习现象;学习递归时经常会出现这样的情况,我们能能书写出递归代码,却不知道他是如何运行的,如同一个黑盒子意义。
这时候我们要是想了解递归的过程,可以令n为一个较小的量,并手动模拟。
详见b站视频:三层汉诺塔模拟