递归基础知识和尾递归的实现

参考书籍:C++ Primer Plus(第6版)中文版、C Primer Plus(第6版)中文版
参考博客:CSDNhttp://t.csdn.cn/5WoUC

递归的背景

C/C++的函数允许自己调用自己,这种调用自己的过程叫做递归。递归有时候难以捉摸,有时候很方便很实用。结束递归是使用递归的难点,因为如果递归代码中没有终止递归的条件测试部分,一个调用自己且没有结束条件的函数是会无限递归下去的,这样最终会导致栈溢出的错误,程序会异常终止的。
然而,与C语言不同的是,C++不允许main()调用自己,C++是比C要严格的。
下面是我自己写的一段C代码:

#include <stdio.h>
#include <stdlib.h>
int n=5,i=1;
int main(){
    if(n!=0){
        n--;
        printf("这是调用的第%d次\n",i);
        i++;
        // getchar();
        system("pause");//由于使用的是Windows下VsCode的gcc,控制台会一闪而过看不到调用效果
        main();
    }
    return 0;
}

在这里插入图片描述
可以是看到这里确实是main() 是调用了自己5次,但是这样虽然实现了在一个程序中让main()运行了多次,但是作为一个函数没有参数一般来说很少,我们希望让main()函数带一个参数去递归。

#include <stdio.h>
#include <stdlib.h>
int main(int n){
    if(n==0)
        return 0;
        printf("1");
    return n+main(n--);
}

这样看似乎符合递归的条件,它的出口是当n=0的时候,来看一下它的运行结果吧
在这里插入图片描述
在这里插入图片描述
为什么呢?细心一点可以发现n是没有初始值的,那么如何给n一个初始值呢?这是不现实的,因为当给一个函数参数初始值的时候只有是函数被调用的的时候,没错,在C中只能通过主函数main() 来调用函数的,所以我们没法给n一个初始值,所以这里的n只能是一个随机值,而且我们知道随机值一般是一个非常大的数 ,而每次n-- --达到0需要n次,而函数的递归调用是在栈上来为函数开辟空间的,当函数的递归调用把栈的空间占满时,程序也会停止了,这里其实可以等价于函数没有一个出口,没有结束的条件,函数就会无线的递归调用下去

C++是不允许main()来递归调用自己的。

递归的基本思想

递归含义:
递归就是递和归,递到最基本的递归基之后根据之前的路归回去;而循环往往就直接是归回去这条路,但是没有递的路标就往往很难走;

void recur(int n){
   cout<<"n="<<n<<"次的'递'"<<endl;//用来显示调用的新的函数
   if(n>0)
       recur(n-1);//每一次能解决的规模是1
   cout<<"n="<<n<<"的'归'"<<endl;//用来显示回到了上一个函数
}
      
int main(){
   recur(5);
   system("pause");
   return 0;
}

在这里插入图片描述
可以看到一共调用了6次函数,但是通过递归是调用了5次函数,这是因为每次能解决的问题规模是1个子问题,然后程序回回到上一个调用它的函数,继续执行后面的语句,这就是归的过程。现在,通过输出我们可以看到凡是“递”的过程可以写一些语句,比如cout;凡是“归”的过程也可以写一些语句,比如cout,同样地,我们也可以不写cout,写一些其他的代码块,还注意到了一个细节是对于一开始传入的参数n它有一个递推关系的变化,并且它的这个变换和每次调用它自己传入的参数有直接的关系,我们把这个关系叫做每次可以解决的问题规模。

如何设计递归

首先要明确一个问题是,我们要设计的函数的目的是什么,要解决一个什么样的问题。比如,我要求一个数的阶乘,那么我设计的这个函数起码要有一个返回值,这个值是int型的也好,long型也罢这个不是重点。函数的三要素:参数,返回值,和生命周期。所以,我们在设计一个函数功能的时候要从这三方面去思考问题。我们在设计递归函数的时候,不用可以考虑这三方面,只需要考虑递归函数的三个要素(自己对递归的理解):函数的出口,问题规模的分解,递的时候执行什么归的时候执行什么,这样其实就包含函数的三个要素。

1.递归函数的出口
我们在前面的背景已经知道了一个递归函数,要有一个出口,不然它就会无限制的递归调用下去的。所以,我们在已经明确的问题的前提下,要分析这个问题是否可以分解成多个子问题呢,以及子问题和原问题(上一个调用它的函数)的关系。什么是可以分解成多个子问题呢?拿一个数的阶乘为例,我们要得到一个数的阶乘就是把1一直乘到这个数,也就是 1 ∗ 2 ∗ 3 ∗ 4 ∗ . . . ∗ n 1*2*3*4*...*n 1234...n这样我们就会得到这个数的阶乘, 也就是我们知道了 ( n − 1 ) ! (n-1)! (n1)!乘一个 n n n就完成了这个功能,而他们直接就差了一个1,这就是原问题和子问题之间的规模。而这个问题的最小规模(无法在继续分解下去)的时候,这就是函数的出口,递归结束的条件
2. 如何来分解一个问题
我们都知道的一个问题是 n ! n! n!,对于这个问题规模分解我们可能想一会就得到了答案,可是在面对更为一般的情况下,我们对于一些问题是无从下手的不知道如何去分解的,这个时候我们要首先知道,这个问题是否有非常明确的递推关系,以及更难一点问题需要数学推到的递推关系。因为只有我们确定了递推关系,才可以把问题的规模减小。现在假设我们已经确定了它的递推关系,但是我们要怎么来描述这个关系呢?递推关系其实包含了两个部分一个是问题规模的缩小关系,一个是原问题和小问题之间的关系。而这个小一点的规模是要在下一次调用函数的时候去体现的,所以要在参数中体现出来。
3.子问题和原问题的关系与递归前后要执行什么的关系
分别有几种形式:子问题*原问题、子问题+原问题、Max(子问题)+原问题等等
而实现这个就要考虑这个个过程是要在递归前完成还是要在递归后完成,一个是递的过程,一个是归的过程,有时候要两个过程一起来实现。

尾递归

如果一个函数的所有递归形式的调用都出现在函数的末尾,我们称这个递归函数是尾递归。当递归调用时整个函数中过最后执行的语句且它的返回值不属于表达式的一部分时,这个地柜调用就是尾递归。尾递归的特点是在回归过程中不用做任何操作,这个特性很重要。
从上面分析的结果来看就是,函数在递的过程执行它要做的事情,而在归的过程中没有任何的动作。通常来讲就是在递归函数中出现的第一次递归调用的后面是没有任何语句的,这样的递归函数被称为尾递归。 尾递归就是只有递的过程,没有归的过程。
这样光讲显然还是不够直观,下面来说一下前面的一个例子:

void recur(int n){
    cout<<"n="<<n<<"次的'递'"<<endl;//用来显示调用的新的函数
    if(n>0)
        recur(n-1);//每一次能解决的规模是1
    cout<<"n="<<n<<"的'归'"<<endl;//用来显示回到了上一个函数
}

大家看这个函数是不是尾递归呢?显然,在 recur(n-1) 之后还有cout语句,那么它是不符合尾递归的定义的。
如果把recur(n-1)后面的语句删掉呢?

void recur(int n){
    cout<<"n="<<n<<"次的'递'"<<endl;//用来显示调用的新的函数
    if(n>0)
        recur(n-1);//每一次能解决的规模是1
    //cout<<"n="<<n<<"的'归'"<<endl;//用来显示回到了上一个函数
}

可能有人会说,这不是废话吗?你咋不都注释掉呢,这样都没函数了。哈哈,开个玩笑,当然了现在我们就可以比较它们俩个的区别。先来看一下他们的运行结果:
在这里插入图片描述
在这里插入图片描述

  • 有人会发现第一个图不就是上面的图吗,当然了我只是换了个颜色而已;在第一个图中我们发现每次调用函数的时候 n n n是不是就减小1,这是为什么?这是因为我们每次递归减小的规模是1(每次传递的参数是n-1),而我们知道每次调用函数就是重新开辟了一片空间,在新开辟的这片空间上这个函数的 n n n,就是调用它时传给它的参数 n − 1 n-1 n1
    那么对于 n n n在递的过程的顺序其实就是我们每次缩小问题规模的顺序,而这个顺序其实是与我们想的顺序是一样的,这样关系就是正序。相反的,我们看到了第一张图的后面还有一个对于 n n n来说的一个相反的序列,为什么会出现这样呢?这是因为,问题的规模一直被分解,就像切蛋糕一样,切到最后实在是没有办法在继续切下去了,那么请问,你还可以切蛋糕吗,恐怕只有切你自己的手指头了。所以对于我们的程序而言,当问题被分解到最后,无法继续分解下去的时候,这就是函数的出口(这个在前面非常详细的说过)所以函数就会停止调用的行为,那么不调用意味着这个函数是不是的生命就到头了呢?一般来说是这样的,但是这里的这个函数在后面还有一句话,就是 cout<<n ,所以当这个函数执行完这话的时候,它也就死了。这个 n n n的值也就是当前的这个函数的 n n n的值,因为它是最后一个被调用的,那么它就是第一个死的,只不过在临死之前告诉了我们它的 n n n的值是多少而已。
  • 第二个图其实就是我们尾递归函数输出的一个结果了,当然了,它只有递的过程,我们可以发现什么问题呢?是不是比较第一个图,它可是整整少了6句话啊!为什么会是这样的呢?原因很简单,我们函数调用了6次输出每次可以输出的地方有俩个一个是递的过程,一个是归的过程,显然,尾递归少了归的输出,就少了6句话。那这样是不是和循环的概念很像呢?我们来看看!

尾递归和循环的联系

#include <iostream>
using namespace std;
int main(){
    int n=5;
    int i=1;
    while(n!=0){
        printf("这是执行的第%d次,n=%d\n",i,n);
        i++;
        n--;
    }
    system("pause");
    return 0;
}

在这里插入图片描述
果然,我们用循环同样的实现了尾递归的效果,每一次的 n n n都是在减小1。那么如果不是尾递归呢?还是可以用循环去实现吗?

#include <iostream>
using namespace std;
int main(){
    int n=1;
    int i=1;
    while(n!=6){
        printf("这是执行的第%d次,n=%d\n",i,n);
        i++;
        n++;
    }
    system("pause");
    return 0;
}

在这里插入图片描述
可以看到我们依然用循环去实现了非尾递归的效果,可是,我们看到了对于循环的条件发生了改变, n n n的初始值发生了改变。就是说这个循环的逻辑发生了改变。这个程序是非常简单的,我们可以轻松的算出反转条件,可是在面对大部分问题时,我们能解得它的逻辑条件就不错了,再求出它的反转条件不是给自己找事吗?其实,这种非尾递归可以用栈或者是栈+循环来解决的,这里暂时不展开来说。
这样我们就知道了尾递归可以和循环很容易的来相互转换的对吗,非尾递归转化成循环不是不可以是非常难受的,只适用于可以简单求的一些情况,一般情况下用栈。
那么,该如何把尾递归变成循环呢?

如何把尾递归变成循环

我们大家都知道其实尾递归就是只有归的部分,而这个归的部分其实就是一个循环的一个正序序列。
那么该如何把尾递归变成循环呢?
如何把一个递归变成循环,应该是这样去思考。递归有什么特点,循环有什么特点,这些特点一定是有一样的。或者这句话这样理解:他们的这些特点,一定是在程序运行的过程中起到的作用是一样的,对吧?这样他们才能相互转换。
那么我们这里就不细说递归的特点了,如果你认真的读过前面的知识,你其实会发现这么一件事:文章存在大量的重复的陈述。我这样的目的是希望自己再写这篇文章的时候加强对递归的理解,也希望在如果有人阅读这篇文章的时是真的可以帮助你理解递归,好了不废话了。递归的特点就是,函数的出口,递归问题规模的分解,递的时候做什么,出的时候做什么。好了下面让我们从这三个方面,非常仔细的来研究一下:

递归的出口

我们已经非常熟悉了,一个递归函数的出口就是分解问题最小的规模或者是让递归停止调用的那个条件。也就是说递归是函数的停止条件,那么这和循环有啥关系呢???或者说,循环有类似的和递归一样的功能体现在程序吗???
想必,这个答案显而易见了循环结束的条件不就是递归的出口吗,换句话说,循环结束的条件就是递归问题的规模的最小分解
通常呢,我们把循环结束的条件叫做边界,既然说到边界,那么它一定是由两部分组成的,一部分是开始的边界,一部分是结束的边界。如果说递归的出口是循环的结束边界,那么循环的开始边界又是什么呢?
让我们来回忆一下,我们在一开始使用递归函数的时候,是不是要给他一个初始值啊?在尾递归中,是不是只有递的过程啊,我们知道递的这个过程是与我们规模减小的那个规模是一致的。在上面的程序中那个 n n n的初始值是5,在第一次递的过程中就是输出5吧?所以边界我们现在就都找到了:

递归函数的参数的初始值是循环的开始边界
递归函数的出口是循环的结束边界

递归问题规模的分解和递的时候做什么

递归问题规模的分解首先要明确这个子问题和原问题关系是什么,才能确定要怎么去分解,以上面的例子为例,我们每次的规模都是-1,也就是我们每次能处理的问题规模就是1个原子问题,然后把 n − 1 n-1 n1个子问题交给这个递归函数来做,也就是说,这个原子问题 (这是特例,恰好是一个不可在分割的问题) 和子问题的规模有一个相同的处理办法(递的时候做什么)
那么我们想一想,这个相同的处理办法是不是就是我们循环体在做的事情,而这个规模每次的分解是不是就是我们的循环变量++或者–(一次处理一个原子问题) 或者是+,-,*,/的任意一种。
那么现在我们就找到了循环变量的每次变化量和循环体了:

递归函数的每次问题缩减的规模就是循环的循环变量的每次变化量
递归函数的子问题和原问题的关系的处理(递的时候做什么)就是循环的循环体

现在我们就掌握了如何把尾递归转换为循环!
不过,理论上非递归也是可以变成循环的,只不过栈的出现让我们不需要动脑筋来思考它和循环的关系。换句话来说,归的过程本来就是栈的操作,因为函数的调用就是在栈上面实现的,我们用栈来代替非递归似乎是更合理一些。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值