C/C++ 递归

递归

当一个函数调用它自己来定义时称它为递归函数。(什么叫它自己调用它自己呢?)

1.1、引出递归

从一个简单的问题考虑递归,求0,1,2, 3,4,5......n的和。

首先定义一个求和公式:sum(n);

显然对于(n > 0): sum(n) = sum(n - 1) + n ;

​ (n = 0 ) : sum(0) = 0;

​ 成立。

将上述公式翻译成C++函数:

unsigned int sum(unsigned int n)
{
    if(0 == n)
    {
        return 0; //基准情况(递归的出口),sum不能一直调用它自己吧,总归要有一个出口结束递归吧
    }
    else
    {
        return sum(n - 1) + n; //sum(unsigned int)调用了它自己
    }
}

假设 n = 5 分析一下计算过程:

sum(5) = sum(4) + 5;

sum(4) = sum(3) + 4;

sum(3) = sum(2) + 3;

sum(2) = sum(1) + 2;

sum(1) = sum(0) + 1;

sum(0) = 0; 当sum(0)时,sum()不再调用它自己,作为递归的出口结束递归。

假设没有n = 0, sum(0) = 0 这个基准情况作为递归的出口跳出递归,递归就会一直递归下去,没完没了直至崩溃。因此递归函数必须有一个基准情况作为递归出口

1.2、失败的递归

给出一个所谓的递归函数:

int bad(unsigned int n)
{
    if(0 == n)
    {
        return 0;
    }
    else
    {
        return bad(n/3 + 1) + n - 1;
    }
}

分析一下以上函数,函数给出了 n = 0 的情况作为递归的出口,看似没什么问题。

还是假设n = 5;

bad(5) : 调用bad(5/3 + 1), 即bad(2);

bad(2) : 调用bad(2/3 + 1), 即bad(1);

bad(1) : 调用bad(1/3 + 1), 即bad(1);

bad(1) : 调用bad(1/3 + 1), 即bad(1)..........

bad(1)一直调用bad(1), 一直调用到程序崩溃。很明显bad()函数定义虽然给出了 n = 0 作为递归出口,但是bad()函数根本不会推进到n = 0 的这种情况。因此递归调用必须总能够朝着产生基准情况(递归出口)的方向推进

1.3、递归和归纳

考虑一个问题:现在需要将一个正整数 n 打印出来,但是I/O给出的函数接口(printDigit)只能处理单个数字(即n < 10)。

我们随便假设一个n值:n = 2019,那么单个数字打印的顺序就是2, 0, 1, 9。换句话说,9是最后一个打印的,在打印9之前要先打印201,即先打印“201”,再打印“9”;依次类推对于“201”先打印“20”,再打印“1”;对于“20”先打印“2”,再打印“0”;对于2已经是单个数字,可以直接打印了, 不需要再划分,再递归了,也就是说单个数字n < 10即为递归的出口。

我们按上述思路细致的分析一下:

对2019分成2部分: 201 = 2019 / 10; 9 = 2019 % 10;

对201分成2部分:20 = 201 / 10; 1 = 201 % 10;

对20分成2部分:2 = 20 / 10; 0 = 20 % 10;

对于 2 满足 n < 10 的条件,不再递归,直接打印。

现在递归已经很明显了,尝试编写一下代码:

//假设printDigit((unsigned int n)如下,
void printDigit(unsigned int n)
{
    std::cout << n;
}

void print(unsigned int n)
{
    if(n >= 10)
    {
        print(n / 10);
    }
    printDigit(n % 10);
}

代码编写好了,现在需要证明以下代码是否正确:对于n >= 0,数的递归打印算法总是正确的。

证明:用k表示数字n的包含单个数字的个数。当k = 1,即 n < 10 时,很明显程序是正确的,因为它不需要递归,print()只调用一次printDigit(), 不调用它自己。然后假设print()对于所有k位数都能正常工作,任何k + 1位的数字n都可以通过它的前k位的数字和最低1位数字来表示。前k 位的数字恰好是[ n / 10], 归纳假设它能正常工作,而最低1位数字是[ n % 10],因此该程序能够正确的打印出任意k + 1位。于是根据归纳法[1],所有数字都能被正确打印出来。

由以上实例总结可以出一条递归的设计法则:假设所有递归调用都能运行。

1.4、递归的合成效益法则

用递归实现一个斐波那契数列:

//斐波纳契数列:1、1、2、3、5、8、13、21、34
int f(int n)
{
    if(n < 1)
    {
        return 0;
    }
    else if(n <= 2)
    {
        return 1;
    }

    return f(n-1) + f(n-2);

}

假设n = 8, 函数调用f(8), 递归调用如下图:

graph TB 8-->7; 7-->6; 6-->5; 5-->4; 4-->3; 3-->2; 8-->id0(6); id0(6)-->id1(5); id1(5)-->id2(4); id2(4)-->id3(3); id3(3)-->id4(2); 7-->id5(5); id5(5)-->id6(4); id6(4)-->id7(3); id7(3)-->id8(2); 6-->id9(4); id9(4)-->id10(3); id10(3)-->id11(2); 5-->id12(3); id12(3)-->id13(2); 4-->id14(2); 3-->id15(1); id12(3)-->id16(1); id9(4)-->id17(2); id10(3)-->id18(1); id5(5)-->id19(3); id19(3)-->id20(2); id19(3)-->id21(1); id6(4)-->id22(2); id7(3)-->id23(1); id0(6)-->id24(4); id24(4)-->id25(3); id24(4)-->id28(2); id25(3)-->id26(2); id25(3)-->id27(1); id1(5)-->id29(3); id29(3)-->id30(2); id29(3)-->id31(1); id2(4)-->id32(2); id3(3)-->id33(1);

由上图我们不厌其烦的数一下:

n = 1时,f()调用1次;

n = 2时,f()调用1次;

n = 3时,f()调用3次;

n = 4时,f()调用5次;

n = 5时,f()调用9次;

n = 6时,f()调用15次;

n = 7时,f()调用25次;

n = 8时,f()调用41次;

增长的是不是太快了,在f()里加一个计数器测试一下,可以看到在n = 30 的时候,f()的调用次数大约在160万。

究其原因,是因为我们在求解的过程时,重复了大量的计算过程, 在n = 8 的时候单单是f(3)就重复调用了8次。

由上我们可以得出一个结论:在求解一个问题的同一实例时,在不同的递归中做重复性的工作,对资源的消耗可能是灾难性的。

最后归纳一下要牢记的递归四条基本法则:

  1. 基准情形。必须总有某些基准情况,它无须递归就能求解,即递归必须有出口。
  2. 不断推进。对于那些需要递归求解的情形,每一次递归调用都必须要使求解状态朝基准情形的方向推进。
  3. 设计法则。假设所有的递归调用都能运行。
  4. 合成效益法则。在求解一个问题的同一实例时,切勿在不同的递归中做重复性的工作。

  1. 1、证明当n= 1时命题成立。2、假设n=m时命题成立,那么可以推导出在n=m+1时命题也成立。(m代表任意自然数)。3、归纳结论。 ↩︎

posted @ 2019-11-29 14:06  小肚哥  阅读(522)  评论(0编辑  收藏  举报