递推与递归

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

1.递推算法

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

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

1,2,…,n的阶乘就可以形成如下数列。

1!,2!,3!,…,(n-1)!,n!

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

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

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

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

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

x1=(x2+1)*2

然后把这个x1作为新的x2(x2=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!的初始条件fact(1)=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 ) );       // ④
}                                              // ⑤

下面结合图,说明执行factorial(3)时,语句的递归调用过程。

1)第1次执行factorial(3),当执行到语句④时,递归调用factorial(3-1)。

2)执行factorial(2),当执行到语句④时,递归调用factorial(2-1)。

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

image-20250405183258327

4)返回继续执行factorial(2)的语句④,执行return2*1,结束factorial(2)。

5)factorial(2)的返回值赋给factorial(3)的语句④,执行return321。

6)结束factorial(3)的执行,返回值为3*2*1=6。

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

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个嫌疑人。

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

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

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

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

5)C和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。

1)TJ1:A和B至少有一个人作案。这是或的关系。即

TJ1 =(A || B)

2)TJ2:A和D不可能是同案犯。A&&D表示他们是同案犯,不可能是同案犯即

TJ2 = !(A && D)

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

3)TJ3:A、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种情况为1,4种情况为0。

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

①同时作案写成(B&&C)。

②均与本案无关写成(!B&&!C)。

两者为或的关系,因此有

TJ4 = (B && C)|| (!B && !C)

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

image-20250405184222991

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

TJ5 = (C && !D)|| (!C && D)

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

image-20250405184244581

D作案,将它的值填1,无论E是否作案,TJ6的值都为1,这就是前两行的情况。当D不做案时,E也不可能作案,即TJ6=1,!E=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的过程move(2,A,C,B)。用“第(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的过程了。实际上move(2,B,A,C)也要分解为三步:

第(3)的第1步:move 1 form B to A

第(3)的第2步:move 2 form B to C

第(3)的第3步:move 1 form A to C

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

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

move(n,A,B,C)分解为3步。

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

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

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

这里显然是一种递归定义,当解move(n-1,A,C,B)时又可想到,将其分解为3步:

1)将上面的n-2个盘子作为一个整体从A经B到C,move(n-2,A,B,C)。

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

3)再将上面的n-2个盘子作为一个整体从C经A移至B,move(n-2,C,A,B)。

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

// 用递归求解汉诺塔问题 
#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