class-notes/递推和递归.md
2025-04-05 19:14:42 +08:00

21 KiB
Raw Blame History

递推与递归

递推和递归两个算法很重要。递推是计算机数值计算中的一个重要算法,而递归算法在可计算性理论中占有重要地位。它们都是算法设计的有力工具,对于拓展编程思路非常有用。

1.递推算法

递推算法的思路是通过数学推导,将复杂的运算化解为若干重复的简单运算,以充分发挥计算机长于重复处理的特点。

一个数列从某一项起,它的任何一项都可以用它前面的若干项来确定,这样的数列称为递推数列,表示某项与其前面的若干项的关系的算式就称为递推公式。例如,自然数

12n的阶乘就可以形成如下数列。

123n-1n

令factn为n的阶乘依据后项与前项的关系可写出如下递推公式。

fact(n)=n* fact(n-1)(通项公式)
fact(1)=1(边界条件)

在有了通项公式和边界条件后,采用循环结构,从边界条件出发,利用通项公式通过若干步递推过程就可以求出解来。

【例7.20】 狗熊到玉米地里吃玉米,第一天吃了一半又拿走一个回去喂小狗熊。第二天又去吃了剩下的一半,走时仍然带一个回去。以后每天都吃前一天剩下的一半,并拿走一个。到第十天时,地里只剩下一个玉米。求地里一共有多少玉米。

【分析】以第十天为依据往回推算。显然第九天是3个第八天是8个。如果设上一天的数量为x1当天剩下的数量为x2则它们之间的关系满足如下递推公式。

x1=(x2+1)*2

然后把这个x1作为新的x2x2=x1再往前推算。假设用day表示天数因为第十天的数量边界条件已知道所以只要计算9次即day的初始值为9不是10。推算一次day减1这可以使用while语句实现。

因为只求第一天的玉米数所以直接使用一个变量求解变量的边界条件是1递推公式简化为x=x+1*2。

// 狗熊吃玉米参考程序
#include <stdio.h>
void main( ) 
{ 
    int day=9, x=1; 
    while ( day>0 )
    { 
            x=(x+1)*2;              // 递推
            day--;                  // 递推计数器
        } 
        printf("玉米总数=%d\n",x); 
    } 

运行结果为

玉米总数=1534

2.递归算法及其与递推的比较

以求阶乘为例。递推是从已知的初始条件出发,逐次去求所需要的阶乘值。

求5的阶乘值。

求5的初始条件fact1=1于是可推得

fact(2)=2* fact(1)=2*1=2
fact(3)=3* fact(2)=3*2=6
fact(4)=4* fact(3)=4*6=24
fact(5)=5* fact(4)=5*24=120

递推过程相当于从菜心“推到”外层。

// 递推求阶乘参考程序
#include<stdio.h>
void main()
{
    int sum=1,i=0;
    for(i=2; i<=5; i++)
        sum=i*sum;
    printf("5!=%d\n",sum);
}

求阶乘的递归调用程序。

#include <stdio.h>
int factorial(int);
void main( )
{
    int n;
    printf("输入整数 n=");
    scanf("%d",&n);
    printf("%d!=%d\n",n,factorial(n));
}
int factorial( int x )
{ 
if ( x==0 ) return 1;
    else  return ( x*factorial( x-1 ) );
}

输入整数 n=5 5!=120

main函数调用factorial函数。其中factorial有返回值函数递归调用时每调用一次其自动型变量就占据堆栈一个区域供各自的调用使用。递归调用在堆栈中临时占据的存储区域是较多的在实际运行时递归调用的时间效率较差。

函数调用,一般是一个函数调用另外一个函数。此外,函数还可以“自己”调用“自己”,这种调用叫作函数的递归调用。递归调用有两种方式,一种是直接调用其本身,另一种是通过其他函数间接地调用。在这里只给出直接递归调用的例子。

如前所述,递推过程相当于从菜心“推到”外层。递归算法的出发点则并不放在初始条件上,而是放在求解的目标上,从所求的未知项出发逐次调用本身的求解过程,直到递归的边界(即初始条件)。就求阶乘的例子而言,读者会认为递归算法可能是多余的,费力不讨好。但许多实际问题不可能或不容易找到显而易见的递推关系,这时递归算法就表现出了明显的优越性。它比较符合人的思维方式,逻辑性强,可将问题描述得简单扼要,使代码具有良好的可读性,易于理解。许多看来相当复杂,或难以下手的问题,如果能够使用递归算法,就会使问题变得易于处理。

3.图解递归的执行过程

递归算法在可计算性理论中占有重要地位它是算法设计的有力工具对于拓展编程思路非常有用。递归算法并不涉及高深的数学知识只不过初学者要建立起递归概念不太容易。为了帮助初学者建立递归的概念下面以求“3”为例给语句编号以便分析程序的执行过程。图7-1用图解的方法分解执行过程。

int factorial( int x )                          // ①
{                                               // ②
	if ( x==0 ) return 1;                      // ③
    else  return ( x*factorial( x-1 ) );       // ④
}                                              // ⑤

下面结合图说明执行factorial3语句的递归调用过程。

1第1次执行factorial3当执行到语句④时递归调用factorial3-1

2执行factorial2当执行到语句④时递归调用factorial2-1

3执行factorial1满足语句③经语句⑤结束本次调用并返回1。

image-20250405183258327

4返回继续执行factorial2的语句④执行return2*1结束factorial2

5factorial2的返回值赋给factorial3的语句④执行return321。

6结束factorial3的执行返回值为3*2*1=6。

由此可见欲求factorial3先要求factorial2要求factorial2先求factorial1。就像剥一棵圆白菜从外向里一层层剥下来到了“菜心”遇到1的阶乘其值为1到达了递归的边界。然后再用factorial2=2factorial1得到factorial2再用factorial2的值和公式factorial3=3factorial2求得factorial3。这个过程是使用factorialn=n*factorialn-1这个普遍公式从里向外倒推回去得到factorialn的值。

4.小结

现将两种算法进行小结。

1递推算法的关键是用数学方法寻求一个递推公式包括能写成函数形式的通项公式和边界条件然后用循环结构实现。

2递归算法的关键是设计递归函数。

3递归函数是一个可以直接调用“自己”或者通过别的函数间接调用“自己”的函数。

4递归函数的思路是将问题分解为两个概念性部分甲和乙其中甲是能够处理的部分乙是暂时还不能处理的部分但乙与原问题相似且其规模较小。由于乙部分与原问题相似这就形成了“你中有我我中有你”的特殊局面。通过“一次一尺”地调用规模也一次一次缩小直至降到最低达到递归的边界问题从而得到解答。

5递归是分治策略的最好应用。递归思想能更自然地反映问题使程序易于理解和易于调试。

6递归程序的缺点是要占用大量的时间和空间。

递推求解切饼问题

1.切饼问题的描述

王小二自夸刀工不错有人拿来一张大煎饼把煎饼放在砧板上问他“饼不许离开砧板切100刀最多能分成多少块

2.寻找求解问题的规律

解题之前要先找出规律。图7-2给出切饼的示意图由此图寻找规律。

image-20250405183454062

设q[n]表示切n刀最多能分成的块数从图7-2可推出如下规律。

q[1]=1+1=2
q[2]=1+1+2=4
q[3]=1+1+2+3=7
q[4]=1+1+2+3+4=11

也就是说如果定义q[0]=1作为边界条件即一刀都不切饼就是一块。由此可得

q[1]= q[0]+1=2
q[2]= q[1]+2=4
q[3]= q[2]+3=7
q[4]= q[3]+4=11

在切法上是让每两条线都有交点。用归纳法可以得出

q[n]=q[n-1]+n

q[0]=1 边界条件

3.源程序

#include <stdio.h>
void qq(int);
int main( )
{
    qq(100);
    return 0;
}
void qq(int n )
{
    int i=0;
    int q[101];
    q[0]=1;
    for( i=1; i<=n; ++i)
        q[i]=q[i-1]+i;
    printf("切100刀最多可分成%d块。\n",q[n]);
}

设计的函数qq在刀数小于100时具有一般性qq[4]输出11块可以验证它的正确性。

程序运行结果如下:

切100刀最多可分成5051块。

疑案求解

话说在某地发生了一桩疑案。公安局对涉及此案的6个嫌疑人的情况进行分析。假设用A、B、C、D、E、F表示这6个嫌疑人。

1A和B至少有一个人作案。

2A和D不可能是同案犯。

3A、E和F这3人中至少有2人参与作案。

4B和C或者同时作案或者均与本案无关。

5C和D中有且只有1人作案。

6如果D没有参与作案则E也不能参与作案。

试编写一个程序,将作案人找出来。

1.解题方法分析

计算机强大的逻辑分析功能是由人通过程序赋给它的,即一些逻辑问题必须转换成计算机看得懂的数学表达式和一定的程序指令。计算机解题的一般思路如下:

1应用计算机解题的重要一步是将人的想法表示成机器能够实现的表达式、数学公式或操作步骤。

2应用计算机解题首先遇到的问题可能是“是”还是“否”“等于”还是“不等于”“大于”还是“小于”等。要把这些描述成关系表达式就要使用关系运算符。因此灵活地使用关系运算符就显得十分重要。

3用计算机解题很多时候都要涉及逻辑运算因此掌握逻辑运算符和构造逻辑表达式也是十分重要的。

4用计算机解题往往需要从许多种可能性中去寻找其中的一种或几种因此最容易想到的也是最容易做到的就是“枚举法”。

5使用枚举法会遇到大量重复计算的问题自然要用到程序的循环结构因此掌握循环结构的程序设计是一个非常重要的基本功。

6分支是计算机思维的很重要的一个方式需要掌握并能灵活运用。

7选择合适的算法去实现。

为了说明编程问题将先使用6重循环解题然后改用位运算取代6重循环解题。

2.解题思路

首先将每一条分析写成逻辑表达式。把这6个条件分别用TJ1、TJ2、TJ3、TJ4、TJ5和TJ6表示将每一条用逻辑表达式写出来。变量为A、B、C、D、E、F变量ABCDEF整体从000000变到111111。

1TJ1A和B至少有一个人作案。这是或的关系。即

TJ1 =A || B

2TJ2A和D不可能是同案犯。A&&D表示他们是同案犯不可能是同案犯即

TJ2 = A && D

当然A=0B=0时A&&B为0而TJ2=1这种多余的情况将由其他条件剔除。

3TJ3A、E和F这3人中至少有2人参与作案。这有如下3种可能

①A和E作案写成A&&E

②A和F作案写成A&&F

③E和F作案写成E&&F

这3种可能之间是或的关系所以写成

TJ3 =A && E || A && F ||E && F

三个变量A、E和F有8种组合方式所以TJ3有8种真值。可以做出它的真值表以帮助理解。TJ3有4种情况为14种情况为0。

4TJ4B和C或者同时作案或者均与本案无关。

①同时作案写成B&&C

②均与本案无关写成B&&C

两者为或的关系,因此有

TJ4 = B && C|| !B && !C

给出TJ4的真值表以加深理解。

image-20250405184222991

5TJ5C和D中有且只有1人作案。

TJ5 = C && !D|| !C && D

6TJ6如果D没有参与作案则E也不能参与作案。这种比较麻烦先根据情况作出如表7-2所示的TJ6的真值表对真值表进行分析从而构造出满足需要的表达式。

image-20250405184244581

D作案将它的值填1无论E是否作案TJ6的值都为1这就是前两行的情况。当D不做案时E也不可能作案即TJ6=1E=1对应第3行。最后一行是E=1即D不做案而E却作案了这是不可能的所以TJ6=0。由此可得

TJ6 = D || !E

将这6个条件归结成一个破案综合判断条件。

TJ = Tj1 && TJ2 && TJ3 && TJ4 && TJ5 && TJ6

当TJ为1时说明这6条的每一条都满足了从而找出作案人。

根据分析6个嫌疑人每人有涉案和不涉案两种情况就有26种组合。枚举就是用这64种情况逐一去试。枚举ABCDEF就是使这6个变量的排列从000000变到11111。

根据是否使用6重循环可以给出两种典型的解题方法。

这个程序简单,可以直接在主函数里实现。但为了演示字符串数组作为参数的使用方法,所以将解题过程编写为单独的函数,由主程序调用求解。

3.使用6重循环解题

// 使用移位指令实现64次枚举的程序
#include<stdio.h> 
#include <string.h>                                       // 字符串库函数
void find(char(*)[9]);                                  // 注意声明参数的格式
int main()
{
    char str[2][9]={"不是罪犯","是罪犯"};              // 初始化字符数组
    find(str);                                          // 注意调用参数的表示
    return 0;
}
void find(char (*str)[9]                                        // 注意定义参数的声明方式
{
    int tj1,tj2,tj3,tj4,tj5,tj6;
    int A,B,C,D,E,F;
    int i;
    for (i=0; i<=63; i++) 
    {
        A = ( i & 32) >>5;
        B = ( i & 16) >>4;
        C = ( i & 8) >>3;
        D = ( i & 4) >>2;
        E = ( i & 2) >>1;
        F = i & 1;
        tj1 = A || B;
        tj2 = !(A && D);
        tj3 = (A && E) || (A && F) || (E && F);
        tj4 = (B && C) || (!B && !C) ;
        tj5 = (C && !D) || (D && !C) ;
        tj6 = D || (!E) ;
    	if (tj1 + tj2 + tj3 + tj4 + tj5 +tj6 == 6)
        {  
            printf("A:%s\n",str[A]);
            printf("B:%s\n",str[B]);
            printf("C:%s\n",str[C]);
            printf("D:%s\n",str[D]);
            printf("E:%s\n",str[E]);
            printf("F:%s\n",str[F]);
        }
    }
}

程序运行结果如下:

A是罪犯 B是罪犯 C是罪犯 D不是罪犯 E不是罪犯 F是罪犯

汉诺塔问题

这个问题源于如图所示的示意图图中有三根柱子左边柱子上有64个由下向上逐次缩小的金盘。

image-20250405185655462

相传在古代印度的Bramah庙中有位僧人整天把三根柱子上的金盘倒来倒去原来他是想把64个金盘从一根柱子上移到另一根柱子上去。移动过程中恪守下述规则每次只移动一个盘且大盘不得落在小盘上面。有人会觉得这很简单真的动手移盘就会发现如以每秒移动一个盘子的话按照上述规则将64个盘子从一根柱子移至另一根柱子上所需时间约为5800亿年。

另一个传说是国王要奖赏庙里的僧人僧人提出寺庙里有三根柱子一根柱子上有64个一个比一个小的金盘把64个金盘从一根柱子上移到另一根柱子上。移动过程中恪守下述规则每次只允许移动一个盘且大盘不得落在小盘上面但每移动一次国王就派人在盘子上放一粒米。国王觉得他们要的很少就答应了回去跟宰相讨论宰相算了一下告诉国王国王根本给不起。

下图是三个盘子“汉诺塔”的示意图。下面就分析一下如何移动它们。

image-20250405185722306

假如在A柱上只有一个盘子盘号为1这时只需将该盘从A搬至C一次完成记为

move 1 from A to C

假如在A柱上有两个盘子1为小盘2为大盘。

第①步将1号盘从A移至B这是为了让2号盘能移动。

第②步将2号盘从A移至C。

第③步再将1号盘从B移至C。

这三步记为(演示):

move 1 from A to B move 2 from A to C move 3 form B to C

假如在A柱上有3个盘子从小到大分别为1号、2号和3号。

第①步 将1号盘和2号盘视为一个整体先将二者作为整体从A移至B给3号盘创造能够一次移至C的机会。这一步记为

move( 2, A, C, B)

意思是将上面的两个盘子作为整体从A借助C移至B。

第②步 将3号盘从A移至C一次到位。记为

move 3 from A to C

第③步 处于B上的作为一个整体的两个盘子再移至C。这一步记为

 move( 2, B, A, C)

意思是将两个盘子作为整体从B借助A移至C。

所谓“借助”的意思,等下面这件事做完了不言自明。

移动3个盘子的分解图如图所示。

image-20250405185938219

从题目的约束条件看大盘上可以随便摞小盘相反则不允许。现在讨论将1号和2号盘当整体从A移至B的过程move2ACB。用“第1”代表这一行的操作实际上可以把它分解为以下三步

1的第1步move 1 form A to C

1的第2步move 2 form A to B

1的第3步move 1 form C to B

经过以上步骤将1号和2号盘作为整体从A移至B为3号盘从A移至C创造了条件。同样3号盘一旦到了C就要考虑如何实现将1号和2号盘当整体从B移至C的过程了。实际上move2BAC也要分解为三步

3的第1步move 1 form B to A

3的第2步move 2 form B to C

3的第3步move 1 form A to C

看move2ACB是说要将两个盘子从A搬至B但没有C是不行的因为“第1的第1步”就要将1盘从A移到C给2盘创造条件从A移至B然后再把1盘从C移至B。看到这里就能明白“借助C”的含义了。因此在构思搬移过程的参量时要把3根柱子都用上。

定义搬移函数movenABC物理意义是将n个盘子从A经B搬到C。

movenABC分解为3步。

1moven-1ACB理解为将上面的n-1个盘子作为一个整体从A经C移至B。

2输出nA to C理解为将n号盘从A移至C是直接可解结点。

3Moven-1BAC理解为将上面的n-1个盘子作为一个整体从B经A移至C。

这里显然是一种递归定义当解moven-1ACB时又可想到将其分解为3步

1将上面的n-2个盘子作为一个整体从A经B到Cmoven-2ABC

2第n-1号盘子从A直接移至B即“n-1A to B”。

3再将上面的n-2个盘子作为一个整体从C经A移至Bmoven-2CAB

根据以上分析,可以编写出完整的程序。

// 用递归求解汉诺塔问题 
#include <stdio.h>                        // 预编译命令
int step=1;                             // 整型全局变量,预置1,步数
void  move(int, char, char, char                // 声明要用到的被调用函数
void main()             
{                               
int n;                                  // 整型变量,n为盘数,
printf("请输入盘数 n=");                     // 提示信息
scanf("%d",&n);                             // 输入正整数n
printf("在3根柱子上移%d只盘的步骤为:\n",n);
move(n,'a','b','c');                    // 调用函数 move(n,'a','b','c')
}
void move(int m, char p, char q, char r)
{                                               // 自定义函数体开始
if (m==1)                                       // 如果m为1,则为直接可解结点,
{
    // 直接可解结点,输出移盘信息
    printf("[%d] move 1# from %c to %c\n", step,p,r);  
    step++;                             // 步数加1
}
else                                    // 如果不为1,则要调用move(m-1)
{
    move(m-1,p,r,q);                    // 递归调用move(m-1)
    // 直接可解结点,输出移盘信息
    printf("[%d] move %d# from %c to %c\n", step, m, p, r); 
    step++;                             // 步数加1
    move(m-1,q,p,r);                    // 递归调用move(m-1)
}
}                                               // 自定义函数体结束

下面分别演示1、2和3个盘子的移动步骤。

请输入盘数 n=1在3根柱子上移1只盘的步骤为
[1] move 1# from a to c
请输入盘数 n=2在3根柱子上移2只盘的步骤为
[1] move 1# from a to b
[2] move 2# from a to c
[3] move 1# from b to c
请输入盘数 n=3在3根柱子上移3只盘的步骤为
[1] move 1# from a to c
[2] move 2# from a to b
[3] move 1# from c to b
[4] move 3# from a to c
[5] move 1# from b to a
[6] move 2# from b to c
[7] move 1# from a to c