C++两个函数可以相互递归吗_设计递归函数竟然这么简单!

本文来自“天狼啸幽月”的投稿,已获独家授权

关于设计递归函数,个人有一点总结和心得,和大家分享一下。

从一个简单的例子开始

#include "stdio.h"

运行结果是:

06efe88e301991f2a4dc8869588ab7e9.png

我们注意到,函数fun里面调用了一个函数fun,也就是调用了自己,这种函数调用自己的技巧我们就称为递归算法,而这个调用了自己的函数fun就是一个递归函数

函数fun的运行过程是这样的:

0beee0990c7df2d2330fc0a4a9422bf5.png

大家在设计递归函数时,可能也会像上图这样去思考每一层的变化,这样一来往往让人头大。

其实,根本不用这样,设计递归函数其实很简单,基本上只考虑它的第一层就可以了,设计它甚至比设计普通的函数还要简单,因为它有便利之处(后面会讲到)。

但是,要做到这样,还需要一些理解上的技巧,我会详细给大家讲明。

为了说明方便,函数fun和它调用的fun我们要分开称呼,外层的fun称作1号fun,被1号调用的fun就称为2号fun,如果调用了多个fun,就3号、4号这样顺延;而如果遇到要按上面的递归结构说明更深层的fun的情况,取名也这样顺延。

回头来解析本例,如何写出这个递归函数。

1. 首先我们要想,函数fun应该设计成一个怎样的功能?这是最重要的一环。

主要看需求,满足需求的情况下微调即可。

比如我们的需求是求n以内的自然数之和,那么我们就这样设计fun的功能——给予正整数n,返回n及以内的自然数之和。

函数功能设计(决定)好之后,那么要得到10以内的自然数之和也就很简单了,所以main里通过fun( 10 ) 就能得到答案了。

2. 制定了功能后,接着就要实现功能了。

作为递归函数,需要调用自己,调用时只需当它是一个普通函数就行,因为它的参数、返回值、功能等都已明确,不用去想它还没实现这件事,普通函数怎么用它就怎么用。

但是,有个小问题,按这个思路,fun最简单的实现应该是这样:

int  fun(int  n){

毕竟根据fun的功能,直接调用fun( n ) 就能得到结果了,然后将结果返回不就实现本函数了吗?

哈哈,这样会死循环,刚刚的方法还不够,需要加点限制——在“当做一个普通函数来用”这个思路的基础上,加上“不能原封不动地把自己的参数给它”。

比如fun首先会收到10,那么它调用2号fun时,就不能给它10,至少要改变一点。

同样的,如果1号fun收到的是a、b、c,那么调用2号fun时就不能给a、b、c,稍微改变一点,比如给a-1、b、c。

受到这条限制后,最简单的实现方法就变成了:

int  fun(int  n){

这个方法也就是让自己只计算最简单的一部分,剩下的任务全交给2号去做。

当然,根据这个理论,实现的方法太多了,这样也是可以的

int  fun(int  n){

这仍然是“自己只计算一点点,剩下的全交给2号去做”的思路,只是比起上一个,这次1号计算的稍微多了一点而已。

3. 想办法结束递归。

递归也是一种循环,循环语句尚且有结束循环的条件,递归当然也应该有。

不过需要注意的是,这个结束的条件需要放在调用2号之前,因为如果不这样做的话,仍然是无休止递归,控制流始终无法到达结束条件这里。

那么,如何写结束条件呢?可以分两种情况来考虑,一种是按最简的方法实现的,此时基本考虑最简单的情况即可;当实现不是最简时,情况要考虑多点。

最简的实现方法如下:

int  fun(int  n){

那么此时最简单的情况就是n为1,结束条件就这样写:

int  fun(int  n){

当然,你可能觉得最简单的情况是n为0,这也可以(不过根据需求,最简情况理应是1):

int  fun(int  n){

下面是没按最简方式来实现的方案:

int  fun(int  n){

这时再用n==1(或n==0)来作为结束条件就有漏洞了,比如当n为2时,传给2号fun的是0,n无法经过1,死循环。

此时的方法稍复杂点——

根据收到的参数与给2号的参数的差来考虑结束条件

(简单来说就是让1号和2号的参数衔接,不要出现断层)

以及从递归结构的尾端的角度来验证正确性

(简单来说就是去测试几个边缘值看会不会死循环)。

概括就是根据参数设条件,以及验证

比如fun收到的是n,传给2号的是n-2,中间少了n-1,那么1号fun就应该考虑n和n-1两种最简的情况,比如我们可以认为n为1或0时最简:

int  fun(int  n){

也可以认为n为1和2时最简:

int  fun(int  n){

接下来是验证环节,测试几个值看看会不会有缺漏,不然又会是死循环。

假如n为5(取值随意,不放心就稍微取宽泛点),则2号得到n=3,3号得到n=1,3号收到的1遇到结束条件而正确返回。

假如n为4,则2号得到n=2,会遇到结束条件而正确返回。

假如n为3,则2号得到n=1,会遇到结束条件而正确返回。

假如n为2,遇到结束条件而正确返回。

假如n为1,遇到结束条件而正确返回。

只要调用时不乱来,那么目前就验证完毕。

而如果担心n的参数为0或负数等错误情况,另加一个结束条件即可:

int  fun(int  n){

至此,基本方法已经说明完毕,我们做个归纳总结:

1. 设定好函数的功能(包括参数和返回值的设计),这是最关键的一环。

2. 将自身作为一个普通函数来调用,认定它能完成它的工作。

3. 调用自身时给的参数不能和自己的完全相同。

4. 将一个最简单的情况作为结束条件,放在调用自身之前。

5. 检查结束条件是否有疏漏。

如果对这个过程和理论还不能理解,可以借助这个小故事:

main想要用fun来求n以内的自然数之和,但函数fun就想,诶呀好麻烦呀,赶紧应付应付得了,搞完回家喝喝小酒才是生活呀,该怎么应付呢,诶,有了,我知道有个叫fun的函数,它可以计算n以内的自然数之和,让它来搞就好了嘿嘿嘿,诶,等等,它的名字怎么这么眼熟呢,唉,不管了,就让它帮我计算1~10的和好了,诶呀,突然才想起,主上大人给我制定过一条规则——“调用它时给的参数不能和自己的完全相同”,上有政策,下有对策,main给我10,既然规定我不能把10给fun的话,那我就给9,把它的计算结果再加个10就可以交差了,嘻嘻嘻,聪明如我。

fun完成了工作正回家呢,突然眼前景色一闪,它回到了工作的地方并收到通知——计算1~9的和。是不是喝酒喝太多眼花了,不管了,先把工作完成再说。它故技重施,偷懒用2号计算1~8的和,自己把结果再加上9交差。

fun又回家了,然后它又突然回到了工作的地方,fun开始觉得不对劲了,工作的时候努力回想前因后果,它终于想起,它使唤的函数fun不正是自己吗?原来让自己回来工作的,就是过去的自己!

既然发现了问题就要想办法阻止,可是自己偷懒惯了,未来的自己也不是现在的自己,干脆还是像以前一样交给未来的自己干吧,不过为了防止自己永无止境地干下去,必须要设一个结束条件。

既然计算的是1~n的和,那么n最后就是1,让最后计算1的自己不要再偷懒使唤别的函数就可以了。结束条件写哪里呢?这时fun又想起了主上大人制定的规则——“结束条件要放在调用自身前”,不愧是传说中的主上大人,原来他已经知晓一切,fun在心里对主上肃然起敬。

学习了方法后,接下来我们通过两个例子来验证成果——汉诺塔、反转链表。

汉诺塔问题

汉诺塔是一个游戏,游戏规则如下:

b7d628eb1a0c0f7b0b9a1134ed019ed9.png

如图有A、B、C三根柱子,A柱子上有一些叠起来的圆盘,大的在下,小的在上,现在要把圆盘都转移到C柱上,移动规则是:

1. 每次移动一个圆盘到另一根柱子上;

2. 大的盘子不能在小的上面。

(这种游戏网上很多,可以先试玩看看)

题目是:指定A柱子上的圆盘数,移动到C柱子上,程序应显示圆盘在柱子间的移动过程。

老规矩

一:设计函数功能。

题目要求把n个圆盘从A柱子移到C柱子,那我们就把函数的功能设计为——把n个圆盘从某根柱子移动到另一根柱子上并显示移动过程。

函数原型设为void hanoi( int n, char a, char b, char c )

a表示初始情况圆盘所在的柱子

c表示要转移的柱子

b表示用来中转的柱子

n表示a柱子上的圆盘数

三个char变量存储的是三根柱子的名字。

函数功能设计好后,那么main要得到从'A'柱移动10个圆盘到'C'柱的过程,只需一行即可—— hanoi ( 10 , 'A' , 'B' , 'C' )

二:实现函数功能

依照hanoi的功能,可以让它移动n-1个盘子,自己则移动剩下的一个,于是我们只需三步即可完成这个过程:

第一步,让2号hanoi移动n-1个圆盘到B柱子。

由于对1号来说,a是自己的起始柱,b是中转柱,c是目标柱,因此它要让2号把盘子从a移动到b,所以调用hanoi( n-1 , a , c , b ) ,也就是对2号来说,1号的b柱就是它的目标柱。效果如下:

d30dd9cbb719350706fa9ac5f5e63078.png

第二步,1号将一个圆盘从A盘移动到C盘。

由于实际上并没有什么盘子的数据,因此输出移动过程就是在移动盘子了——printf("%c--->%c\n", a, c) ,它表示将起始a柱的一个圆盘移动到目标c柱:

43284c7c62dfb1dc3d0b792897fa3815.png

第三步,2号将b柱上n-1个圆盘移动到c柱上——调用hanoi( n-1 , b, a, c ) ,结果如下:

f73e6b02dd84a235da862414b97a0197.png

三. 结束条件:

当圆盘数为1就是最简情况

if(n==

得出代码:

#include "stdio.h"    return 0;
}

0d808d57dad2000f42bdd53401f690bd.png

四. 检查:

由于n==1是结束条件,而n在调用时都是按1递减,因此当n大于1时,n最终都会变成1而结束,不考虑n为负数或0这种错误的情况,已正确实现。

对此方法的思考

你们可能会觉得荒诞—— 2号能让n-1个圆盘直接转移?难道可以无视“一次移动一个”和“大的在上小的在下”这些规则?何等荒谬!怎么做到的?

这正是此方法的魅力,也正是前面说的“更便利”的所在。

如果不按递归来写,那会很复杂,而按这种思路的递归来写,直接凭空得到了一个大帮手——一个叫hanoi的函数,它能完成n个盘子的转移,简直就像配备了一个完美的API,它总是能刚好满足我们的需求,要不是有一个规则限制(调用时不能给出一样的参数),任务甚至简单到直接调用它就完成了,而即便有这个限制,也不过是让我们实现最简单的一层,其余更复杂的事务全交给这个“API”去完成了,还有比这更完美和简单的事吗?

这也难怪大家会觉得不可思议、无法理解,毕竟天底下哪有这样的好事,但它确实实现了,那么我们也不得不承认——递归算法是何等高明和美妙!

但有个问题还是要解释的——为什么这么简单无脑的方法真的可以实现?

前面说过,递归也是一种循环,循环就是重复,说明不同层的递归函数仍然是高度相似的,它们都是在完成一个相同的任务,也就是说,这个任务我们只要实现一次就行了,就像循环语句里的循环体,而我们也确实把这个单次的任务的实现写出来了,也就是2号做的事务之外,由我们实现的那最简单的一层。

所以,“调用时不能给出完全相同的参数”这个规则,除了防止无限递归外,也是在让你去实现一个基本的步骤,而递归的实现,不过就是在不断循环这个步骤罢了。

这个方法基本上可以适应绝大多数递归算法,不过,有些情况可能需要稍稍注意,设计递归函数时,我们总是需要各种参数,但是第一次调用时(比如main对该函数的调用)却不一定喜欢给这些参数,比如汉诺塔,目前为止几乎所有的汉诺塔游戏都是从A柱转移到C柱,这几乎是默认的,那么调用时只需给出盘子数才是最简洁方便的设计,但递归函数的参数显然不能这么设计,不然没法递归了,对于这种情况,我们可以将对递归函数的调用包装一次,就像这样:

void view_hanoi( int n )    return 0;   
}

反转链表

本次我们改变顺序,先展示代码,然后解读代码:

#include "stdio.h"

c7c5ec8ecafc12a134bc7f0363fd2585.png

本例使用了3个递归函数——递归创建链表递归释放链表递归反转链表,接下来分别解析。

递归创建链表——create函数

struct node* create( int num )    {

功能:指定生成的结点数量(给予参数),返回头结点的地址。

实现:先创建了一个结点作为头结点并设置数据后,用2号创建num-1个结点的链表,返回它第一个结点的地址给头结点的next指针,于是创建完毕,返回头结点p的地址。

结束条件:考虑到参数是递减1传递,那么肯定会经过0,于是当num==0时返回NULL,考虑到错误情况,因此设置结束条件num<=0。

递归释放链表——my_free函数

void my_free( struct node* p )    {

功能:给予头结点的地址,释放该链表。

实现:先调用2号释放从第二个结点开始的链表,也就是把除头结点外剩下的结点释放了,然后释放头结点。

结束条件:链表逐点传递,会经过链表尾,设尾端NULL为结束条件。

需要注意的是,这个过程不能反过来,比如像这样:

free( p );

my_free( p->next );                  

如果p释放了,p->next的值也就没有了,这不是递归和本方法的问题,站在普通调用的角度来看也是这个情况。当然,如果你硬要这样做也有办法:

struct node* t = p->next;

free( p );

my_free( t );

递归反转链表——reverse函数

struct node* reverse( struct node* p )        {

功能:给予头结点的地址,反转链表,返回新的头结点的地址。

实现:先调用2号,让它把第二个结点开始的链表反转,得到反转后链表的头结点head,然后将原本的头结点p放到链表尾端,返回头结点的地址head。

这个过程略有点绕,我们用图来说明,首先是原链表的样子:

7f4f5802ae60c5c52582330fd6768c73.png

先将p结点之后的链表(也就是从结点9开始的链表)反转,头结点也就变为了1(创建head来保存头结点地址),而由于结点p没有变化,因此p的下一个结点依然是9:

6c3c2bddaddce183478bc6302ab6a510.png

由于结点p的问题,整个链表还没完全反转,因此再做几个操作——让结点9的next指向p(即p成为结尾),p的next指向NULL:

5fb08807578a1d70fdcc619abf9d1f65.png

如此,链表反转完毕,返回head。

结束条件:当结点只有一个时是最简情况(p==NULL是排除错误情况),判断是否只有一个结点的方法是看它的next指针是否为NULL。

从另一方面来看,实现的方法中,p->next->next必须保证p->next不为空,即至少是在有两个结点的情况下进行的反转,因此结束条件设为1个。

这个思路的一个难点就是要想清楚如何把头结点p放到链表的结尾,你可以从头结点遍历,但其实完全没必要,既然是反转的话,第二个结点就会成为倒数第二个结点,p的内容一直没变过,所以可以直接找到结点9,两次赋值操作就能让它成为尾结点。

结语

关于递归的例子就讲解到这里吧。

如果面对的是较长的递归函数,也无需惊慌,按照我们设定的步骤操作就行了

首先将函数功能明确,以快速排序和归并排序为例,虽然都需要递归,但排序的算法不一样,这个时候设计函数功能就需要包含具体的排序方式,如果在设计功能时仅说明是排序的话,那么实现时就无从下手,因为用递归实现排序的方法有很多种;

接着根据设计的功能去实现,利用自己(2号),实现一遍单层的逻辑,如果代码较长,说明你可能没有实现最简方案,或者它确实比较复杂,以至于实现单层就需要较多代码,比如tree命令的实现。

往期 精彩回顾

我是一个线程

我是一个Java Class

面向对象圣经

函数式编程圣经

TCP/IP之大明邮差

CPU阿甘

我是一个网卡

我是一个路由器

一个故事讲完HTTPs

编程语言的巅峰

Java:一个帝国的诞生

JavaScript:一个屌丝的逆袭

负载均衡的原理

阅读源码的三种境界

1291e74015794c9e4a49aa563600f94b.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值