递归是一种编程方法,实现方法呢就是一个函数自己调用自己,但是对很多肛接触(滑稽) 刚接触它的朋友来说非常折磨。
看别人的代码时候,它的原理什么?它是怎么运行的?然后试图一个命令一个命令分析,从一个函数跳进下一个函数,然后迷失自我。。。。
到自己写的时候也是,在心里默念自己调用自己、自己调用自己、自己调用自己,(我tm要怎样调用自己?),最后憋了半天,啥也没写出来。
今天写这篇文章希望能给大家提供一个思考方向和模板,帮助大家理解递归。
理解递归
递归是一种循环
大家都很熟悉for,while吧,他们通过反复执行{}里的内容来达到自己的目的,比如下面这个求数组的和。
int nums[3]={0,1,2}
int sum=0,i=0;
for(;i<3;i++){
sum+=nums[i];
}
递归其实也是一样,我们可以把它看成特殊的循环体,它也只能执行函数{}内的内容,只是呢它是通过调用自己,实现重复做一件事,遇到终止条件后就结束循环,开始返回到原点。
int sum(int n){
if(n==0) //遇到0在往下递归就变成负数,所以结束循环
return 0;
return sum(n-1)+n; //让n+前面(n-1)的和
}
但是为什么有很多朋友写不出来,或者写不对呢,这是因为我们无法理解它运行的过程,为什么调用了sum(n-1)就能得到n-1的和呢,这个其实是你不知道递归的遍历顺序导致的,不知道发生了什么,自然就不懂原理了。
所以呢,为了能够明白代码发生了,在写递归前我们先来看看递归是怎样遍历的。
递归的遍历方式
要想搞懂递归,首先要弄明白它是怎么运行的,接下来让我们来看看这短短的几行究竟发生了肾么事。
这里我们借助二叉树的三种遍历方式来总结一下递归的遍历顺序。(二叉树没学也没关系,二叉树的遍历我们把它看成一个调用了“调用两次自己” 的递归函数, 我们只用记住遍历的顺序就行)
如果还不知道TreeNode ,root,root->left,root->right,root->val的话,
把TreeNode 看成新的数据类型,和int,float一样的作用,
root,root->left,root->right是TreeNode声明的变量,可以看成图中的圈圈,然后root->val就是root,root->left,root->right这些圈圈里的包含的字母,是char类型的变量
root代表中间的点,把root->left看成中间位置的点通过下图中的箭头找到左边的位置,然后把root ,->,left合在一起变成一个新的变量,这样root->left就代表中间位置左手边的圈圈,root->right一样,代表中间位置右手边的圈圈
如果学会了"调用两次自己"的递归,调用1次和n次的都可以看成"调用两次自己"递归的变形,到时候写它们对于我们来说也是有手就行。
前序遍历:
void preorder(TreeNode *root) {
if (root == NULL) {
return;
}
printf("%c ", root->val); //把printf() 看成本次节点的操作
preorder(root->left); // 调用一次自己,让左边重复printf的操作
preorder(root->right); //再次调用自己,让右边重复printf的操作
//return;
}
从上面的图可以看出,如果我们把每轮的动作放在调用自己之前,每一轮的动作从上到下发生,发生顺序为中->左->右。
这里的中就是本次函数的”主角“root,左就是左手边的root-left,右就是root->right
而且注意看图中遍历的顺序是不是和你想要它遍历的顺序一样,我想先输出这个圈圈里的值也就是中的值F,我先调用一次printf,然后想把左边一半的值BADCE都输出,这时我们只需要调用一样的函数,把执行动作的”主角“换成左,右边类似。
中序遍历
void inorder(TreeNode *root) {
if (root == NULL) {
return;
}
preorder(root->left); //左
printf("%c ", root->val); //中
preorder(root->right); //右
中序遍历就是把操作放在两次调用自己中间,他们遍历的随序是从下到上,发生顺序为左->中->右。
后序遍历
void postorder(TreeNode *root) {
if (root == NULL) {
return;
}
preorder(root->left); //左
preorder(root->right); //右
printf("%c ", root->val); //中
}
后序遍历就是把操作放在两次调用自己后面,他们遍历的随序也是从下到上,发生顺序为左->右->中。
上面的三个动图看明白了以后就可以自己画递归树分析代码了。
如果我们把上面的三种方法合并,并拓展总结一下就会得到下面这个模板:
void function() { //函数类型根据自己需求改变,并填上需要的参数
if () {
//....返回前可以做的事情
return ; //找到结束递归“循环”的条件
}
//操作 --前序遍历
for(){ //如果调用自己的次数很多的话可以像这样加循环
//操作 有一点点像中序遍历
function();
//操作
}
//操作 --后序遍历
return ; //根据函数类型返回
}
但是呢,不是明白遍历方式我们就会写递归函数了,遍历方法只是便于我们理解函数运行的过程,和提供一个大致方向,主要还是在宏观上对问题的分析,所以写递归时除了模板还需要一些方法。
写递归的方法
1.知道自己要干什么
2.明确函数的作用,指定一个"人"来帮你做一样的事
(可以把函数里的某些参数看出发生动作的"主角")
**
写递归函数的步骤,从宏观上分析,总结出一般规律,明确需要做的事情(写其他的代码也是一样),如果分析中遇到类似和函数作用相同的操作(主角不同,但是做的动作一样),这时我们相同的动作提取出来**,填到模板“操作“的位置,记住函数的作用,把函数参数换成不同的主角,调用自己就行,(这里的参数得是相邻的),让换上去的“主角”帮你做相同的事就行。
这里说了可能无法体会,下面我们通过题目来理解一下
练习
下面拿三个例题练习一下
写题过程 1.分析 2.在分析中提取要素 3.把提取的要素和动作翻译成代码就行
1.
第一题是我们很熟悉的斐波那契数列
分析问题:
斐波那契数列是从前往后推依次是0,1, 1, 2, 3, 5, 8, 13, 21…根据数列的定义,想推出下一个数我们需要后面两个数相加,后面的两个数又由后后面两个数,一直反复这两个数会之前推到第0位和第1位,再往后推就没有数了,所以终止条件就是n=1或者n=0的时候 。
加深对”调用自己“的理解
这里每次推出一个数的时候都是重复进行前面两个位置数相加的操作,相加的动作是相同的,相加的两个数的位置是不同的,这时我们把相加的动作“+”提出来,想得到f(n-1)和f(n-2)我们利用的函数作用,把得到f(n-1)和f(n-2)的任务交个n-1和n-2来完成就行,所以把函数的"主角"换成n-1和n-2就行啦。
把上面加深的字翻译成代码:
第一句我们分两步写:
第一步得到两个数:
int a=Fibonacci(n-1);
int b=Fibonacci(n-2);
第二步相加:int c =a+b;
最后就是把结束条件和动作填进函数。
int Fibonacci(int n){ //函数作用是得到斐波那契数列第n位上的值
if(n==0) //遇到第零个位置
return 0;
if(n==1) //遇到第一个位置
return 1;
//明确函数作用,我们只需要考虑一个节点需要干什么,其他的交给剩下的循环完成
int a=Fibonacci(n-1); //得到斐波那契数列第n-1位置上的值
int b=Fibonacci(n-2); //得到斐波那契数列第n-2位置上的值
return a+b; //把n-1和n-2位置上的值相加就是n位置上的值
//return Fibonacci(n-1)+Fibonacci(n-2);
}
如果感兴趣的可以把代码遍历的顺序按照上面第三个图的顺序把斐波那契数列的递归树自己动手画一遍加深印象,像下面这个图一样。
画的有亿点点丑,凑合着看吧(滑稽)
下面我们在再来看一个简单的入门题目–汉诺塔。
题目是有三根柱子ABC,我们要把柱子A上的圆环都移到c上,并且大的不能在小的上面。
分析问题:
要把A上的圆环都移到C上,每次都要把A最下面的放在C上面,如果想要把A最大的圆环放到C上,需要先借助B,得把最大的圆环上面的圆环都移到B上,然后把A上的剩下的那个圆环放到C上,最后再把刚刚放在B上的圆环全部移到C上。
但是呢,如果A上圆环只有一个就是n==1,我们可以直接放到C上就行,就不需要进行上面那一步,所以找到结束条件n=1。
加深对”调用自己“的理解
A->C的过程分成移1个和移n-1个两块进行
操作中”把换从哪移到哪“的动作是一样的,都是一样的,只是移动的对象的不一样的:移动的圆环个数,从哪出发到哪里。理清这些把这些执行动作”主角“放里面就行了。
把上面加深的字翻译成代码,
第一句就是HanNuoTa(n-1,a,c,b);
第二句就是printf("%c->%c\n",a,c);
第三句就是 HanNuoTa(n-1,b,a,c);
最后就是把结束条件和动作填进函数。
这是完成第一句后的图:
void HanNuoTa(int n,char a,char b,char c){ //函数作用是把a上的n个环移到c上
if(n==1){ //只有一个环的时候把a移到c,移完返回
printf("%c->%c\n",a,c);
//return;
}
//明确函数的作用:把(第一个参数)个环从(第二个参数)处移到(第四个参数)
HanNuoTa(n-1,a,c,b); //把A上n-1个圆环移到B上,让A只剩下最后一个
printf("%c->%c\n",a,c); //操作:把A上剩下的一个的圆环放到C上
HanNuoTa(n-1,b,a,c); //把刚刚移到B上的n-1个放回到A
//return ;
}
这里如果不理解怎么运行的话也可以套用前面中序遍历的顺序自己模拟一遍。
最后再讲一个难一点但又不是很难的算法(如果不考虑我讲的好不好懂的话应该是不难懂的[旺财])—快速排序
快速排序的核心思想是每次选一个数作为分界的那个数,把比他小的数都放在它的左边,比它大的都放在它的右边。然后在对这个分界点的左边,和右边的数组重复上面的操作。
分析问题:
**1随便找一个数,把比他小的数都放在它的左边,比它大的都放在它的右边。
**2. 对这个数左边的数组进行操作1。
3.对这个数右边的数组进行操作1。
如果这样一直分解下去会分成只有一个数字的子数组,再分就没有意义,所以结束条件找到–当新数组的区间的左端点<=右端点(两个端点相遇只有一个数字)时结束递归开始返回。
每次的操作都是对数组进行swap操作,”主角“对应的区间是不同的,我们把相应的区间参数换成对应的新区间
把上面加深的三条翻译成代码,
第一句就是 swap(a,i,j,key);
第二句就是 qsort(a,left,i-1);
第三句就是 qsort(a,i+1,right);
最后就是把结束条件和动作填进函数。
下面我们看看代码实现
这里主要是为了讲解递归,swap里的细节不了解也没关系
int swap(int *a,int l,int r,int mid){
while(l<r){ //i和j相遇说明数组遍历了一遍
while(l<r&&mid<=a[r]){ //比key大继续向左
r--;
}
a[l]=a[r]; //找到比key小的交换
while(l<r&&mid>=a[l]){ //和上面类似
l++;
}
a[r]=a[l];
}
a[l]=mid;
return l;
}
void qsort(int *a,int left,int right){ //函数作用是对区间[left,right]上的数排序
if(left>=right){
return;
}
int midnum=a[left]; //把最左边的数当成分界点的数midnum
int mid=swap(a,left,right,midnum); // 把比key小的数都放在它的左边,比key大的都放在它的右
qsort(a,left,mid-1); //对key左边的排序
qsort(a,mid+1,right); //对key右边的排序
}
总结
最后总结一下:
写递归函数呢,其实就是填模板,像补全段落一样,我们要做的就是根据分析总结的规律把”操作“空位填上,然后”找帮手“想想要让谁来帮我做相同的事情,也就是调用自己,理清楚思路后填进模板,如果有不确定的地方再利用遍历方法验证,看看递归树里发生了什么后再进行修改。
emmm,相信现在你已经对递归这种写法有点感觉了,如果把递归这个技巧学明白了,以后的很多算法也可以很快掌握,它们也就是在上面的模板是加一些动作,比如前面的快速排序就是前序遍历模板“操作”的填充,汉诺塔不就是中序遍历的模板“操作”的填充,分治算法就是对后序遍历模板“操作”的填充,回溯算法,深度优先就是在调用函数前后都进行操作。
最后对怎样在题目中提取需要什么的事(这个敲什么都一样),函数的参数有什么呀,我要赋予这个函数什么作用,怎么利用这个作用,这些分析方法需要自己一点一点分析问题得到,这些需要多多练习才能有更深的理解和感悟。
如果想练练手可以到leetcode上做做46,39,77,78,90,或者找到分类里的递归一栏,从简单的做起。
好啦,这篇文章就讲完了,如果哪有错误或者有疑问的地方可以在评论区留言,谢谢观看!