正文

算法设计之递归法(2)2007-10-06 19:14:00

【评论】 【打印】 【字体: 】 本文链接:http://blog.pfan.cn/goal00001111/29926.html

分享到:

递归和非递归的转换

在第一讲《算法设计之枚举法》中我们有一道练习题:

6:构造一个3*3的魔方:把数字1-9添入如图的表格中

2

7

6

9

5

1

4

3

8

要求每横,竖,斜列之和均相等(如图是一种情况)。输出所有可能的魔方。

当时我们是使用枚举法解的,通过剪枝等优化后得到一个8重嵌套循环,而且每个循环的结构都是一样的,既繁琐,又复杂。既然如此,那么我们是否可以用一个递归函数来实现呢?答案是肯定的。程序如下:

#include<stdio.h>

#define MAX 9

 

int IsElement(int a[], int len, int x);

void F(int a[], int len);

 

int main()

{

    int a[MAX] = {0};

    int i;

   

    for (a[0]=1; a[0]<=MAX; a[0]++)

    {

        F(a, 0);

    }

   

    getchar();

    return 0;      

}

 

void F(int a[], int len)//以递归代替多重嵌套循环

{

    int i;

    if (len < MAX-2 && IsElement(a, len, a[len]))

    {

        len++;

        for (a[len]=1; a[len]<=9; a[len]++)

        {

            F(a, len);

        }

    }

    else if (len == MAX-2)

    {

        a[8] = 45-a[0]-a[1]-a[2]-a[3]-a[4]-a[5]-a[6]-a[7];

        if ((a[0]+a[1]+a[2]) == (a[3]+a[4]+a[5]) && (a[0]+a[1]+a[2]) == (a[6]+a[7]+a[8])

         && (a[0]+a[3]+a[6]) == (a[1]+a[4]+a[7]) && (a[0]+a[3]+a[6]) == (a[2]+a[5]+a[8])

         && (a[0]+a[1]+a[2]) == (a[0]+a[4]+a[8]) && (a[0]+a[1]+a[2]) == (a[2]+a[4]+a[6]))

        {

            for (i=0; i<9; i++)

            {

                printf("%5d", a[i]);

                if ((i+1)%3 == 0)

                    printf("\n");

            }

            printf("\n\n");

        }

    }

}

 

int IsElement(int a[], int len, int x)

{

    int i;

    for (i=0; i<len; i++)

    {

        if (a[i] == x)

            return 0;

    }

  

    return 1;

}

从本例中我们可以发现,用递归代替多重嵌套循环不仅使程序结构清晰,可读性强,且容易用数学规纳法证明算法的正确性,因此设计算法与调试程序都很方便。

实际上,递归是软件设计中的一种重要的方法和技术.递归函数是通过调用自身来完成与自身要求相同的子问题的求解,编译系统能自动实现调用过程中信息的保存与恢复.在问题的求解方法具有递归特征时,采用递归技术就比不用递归技术简捷得多,且具有较高的开发效率,所设计的程序具有更好的可读性和可维护性.然而,在实际应用中,由于程序设计语言对递归的支持性和程序运行时间方面的原因,在有些情况下要求写出问题的非递归函数.由于许多问题求解程序中的递归函数比非递归函数要容易设计,因此,常常先设计出递归函数,然后将其转换为等价的非递归函数.

要向实现递归和非递归函数的相互转换,我们先要了解递归调用的内部实现原理。

在调用一个函数的过程中出现直接或间接的调用该函数本身,称为函数的递归调用.与每次调用相关的一个重要概念是递归函数运行的“层次”.设调用该递归函数的主函数为第0层,则从主函数调用递归函数为进入第1层;从第i层递归调用本函数为进入“下一层”,即第i+1层.反之,退出第i层递归应返回至“上一层”,即第i-1层.因此,编译系统需设立一个“工作栈”来进行调用函数和被调函数之间的链接和信息交换.设工作栈开始时为空,则递归调用内部实现描述如下:

1)调用时需执行的操作

a.将返回地址、调用层(第i层)中的形参和局部变量的值压人工作栈中(第0层调用不考虑);

b.为被调层(第i+1层)准备数据:计算实在参数的值,并赋予对应的形参;

C.转入被调函数执行.

2)函数返回时需执行的操作

a.如果函数有返回值,将返回值保存到一临时变量中;

b.从栈顶取出返回地址及各变量、形参的值,并退栈,即恢复调用层(第i-1层)的局部变量和形参;

c.按返回地址返回到调用层(第i-1层);

d.返回后自动执行如下操作:如函数有返回值,则从临时变量中取出返回值赋予调用层(第i-1层)相应的局部变量或代人表达式中.

了解了内部实现原理,现在我们来看由递归到非递归的转换规则。既然编译系统内部是利用“工作栈”这种数据结构来实现递归函数的,因此,递归函数用非递归函数实现时也必然要用“栈”来保存相应的形参和局部变量.通过仔细研究编译系统内部实现递归函数的工作原理,得到转换规则如下:

1)设置一个工作栈,用S表示,并在开始时将其初始化为空.

2)在递归函数的人口处设置一个标号(如设为L0).

3)对递归函数中的每一个递归调用,用以下几个等价操作来替换:

a.保留现场:开辟栈顶存储空间,用于保存调用层中的形参、局部变量的值和返回地址;

b.准备数据:为被调层准备数据,即计算实参的值,并赋予对应的形参;

C.转入(递归函数)执行,即goto L0;

d.在返回处设一个标号Li(i一1,2,3,⋯),并根据需要设置以下语句:如果递归函数有返回值,则增设一个变量保存回传变量返回的值.

4)在返回语句前判断栈空否,如果栈不空,则依次增加如下操作:

a.回传数据:若函数有返回值,增设一个变量,将返回值保存到该变量(称回传变量)中;

b.恢复现场:从栈顶取出返回地址(不妨保存到Lx中)及各变量、形参的值,并退栈;

C.返回:按返回地址返回(即执行goto X).

5)对其中的非递归调用和返回语句照抄.

6)如果递归程序中只有一处递归调用,则在转换时,返回地址不必入栈.

7)在模拟尾递归调用时,不必执行入栈操作.

注 尾递归即递归调用处于递归函数的最后位置,其后面没有其他操作.

用上述规则可将任意的递归函数转换为等价的非递归函数.不过,转换得到的函数的结构一般比较差,因而需要重新调整.

转换规则看上去很复杂,其实我们可以理解得简单些,求解递归问题有两种方式,一种是直接求值,不需要回溯的;另一种是不能直接求值,需要回溯的。这两种方式在转换成非递归问题时采用的方法也不相同。前者使用一些中间变量保存中间结果,称之为直接转换法(即转换成迭代算法);后者需要回溯,所以要用栈保存中间结果,称为间接转换法。下面分别讨论这两种方法。

直接转换法

仍然以斐波那契(Fibonacci)数列为例,前面我们介绍了斐波那契(Fibonacci)数列的递归算法,发现该算法虽然代码短小精悍,可读性强,但是由于做了大量的重复运算,使得效率极为低下,所以应该转换成非递归算法。斐波那契(Fibonacci)数列的非递归算法即迭代法,我们在第2讲《算法设计之迭代》中已经做了详细解释,这里不再重复。

我们看一个其他的例子:

7:逆序打印数字。例如考虑如何打印数字1369。我们首先需要打印9,然后打印6,再打印3,最后打印1。

很明显我们可以把问题看成是先打印1369的个位数字,然后打印136的个位数字,然后打印13的个位数字,最后打印1的个位数字。一个很典型的递归问题。

#include<stdio.h>

 

void PrintInt(int n);

 

int main()

{

    int n = 138400;

   

    PrintInt(n);

   

    getchar();

    return 0;      

}

 

void PrintInt(int n)

{

    printf("%d", n%10);

    if (n >= 10)

        PrintInt(n/10);

}

同时我们可以发现,递归函数中使用了尾递归(仅在方法的末尾实行一次递归调用,这样的递归叫尾递归)。尾递归很容易被循环所替换,下面是使用循环的写法。

void PrintInt(int n)

{

    while (n > 0)

    {

        printf("%d", n%10);

        n /= 10;

    }

}

这个例子实在是太简单,下面我们看一个稍微复杂点的例子。

8:逆序排列数组。例如原数组为a[] = {1,2,3,4,5},经过逆序排列后变成a[] = {5,4,3,2,1}。经典的逆序排列算法是使用循环。

void Reverse(int a[], int len)

{

    int l, r;

    int temp;

   

    for (l=0,r=len-1; l<r; l++,r--)

    {

        temp = a[l];

        a[l] = a[r];

        a[r] = temp;

    }

}

实际上我们可以看到逆置数组的操作是一个从两端到中间,规模逐渐减小的过程,每次交换元素a[i]和a[n-i],动作完全是一样的,所以也可以使用递归算法。

void Reverse(int a[], int len)

{

    Rev(a, 0, len-1);

}

 

void Rev(int a[], int left, int right)

{

    int temp;

    if (left < right)

    {

        temp = a[left];

        a[left] = a[right];

        a[right] = temp;

        Rev(a, left+1, right-1); 

    }

}

这里的递归函数是Rev,为了保证同循环算法的接口保持一致,我们把函数Reverse作为Rev的驱动函数,这在比较复杂的递归算法中是很常见的。

间接转换法

前面讲了一个逆序打印数字的例子,现在来看一个与它类似的例子:

9:以任意基数打印数字。例如考虑如何打印数字1369。我们首先需要打印1,然后打印3,再打印6,最后打印9。问题是获得首位麻烦,给定数字n,我们需要循环确定n的首位。与之相反的是末位,利用n%10就可以得到它。

    用递归实现这个例程是非常简单的:

#include<stdio.h>

 

void PrintInt(int n, int base);

 

int main()

{

    int n = 100;

    int base = 2;

   

    PrintInt(n, base);

   

    getchar();

    return 0;      

}

 

void PrintInt(int n, int base)//n表示被输出的十进制数,base表示被输出的数的基数,即进制,

{

    if (n >= base) //这里要求base <= 10

        PrintInt(n/base, base);

   

    printf("%d", n%base);

}

但是使用非递归方式就没那么简单了,这不比上一题,这里不是尾递归,不能直接转换,必须先用栈把得到的数字存储起来,再一次性输出。程序如下:

void PrintInt(int n, int base)//n表示被输出的十进制数,base表示被输出的数的基数,即进制,

{

    int a[50] = {0}; //假设n的最大位数为50

    int i = 0;

   

    while (n > 0)//用栈把得到的数字存储起来

    {

        a[i++] = n % base;

        n /= base;

    }

   

    while (i > 0)//逆序输出栈的元素

    {

        printf("%d", a[--i]);

    }

}

二叉树数据结构是一种典型的递归定义和递归操作的数据结构,涉及到很多递归算法,现在我们来看一个简单的二叉树算法:前序遍历二叉树。

链接存储的二杈树类型和结构定义如下:

typedef struct bnode

{

    ElemType data;

    struct bnode *lchild, *rchild;

} btree;

1.递归算法非常简明:

void preorder(btree *p)

{

    if(p != NULL)

    {

        printf("%d", p->data);//输出该结点(根结点)

        preorder(p->lchild);  //遍历左子树

        preorder(p->rchild);//遍历右子树

    }

}

2.非递归算法(使用栈存储树):

void preorder(btree *bt)

{

    btree *p, *stack[MAX];//p表示当前结点,栈stack[]用来存储结点

    int top=0;

   

    if(bt != NULL)//先判断是否为空树

    {

        stack[top] = bt; //根结点入栈

        while(top >= 0)

        {

            p = stack[top--]; //栈顶元素出栈

            printf("%d", p->data);//输出该结点

            if(p->rchild != NULL) //如果该结点有右孩子,将右孩子入栈

            {

                 stack[++top] = p->rchild;

            }

            if(p->lchild != NULL) //如果该结点有左孩子,将左孩子入栈,按照后入先出原则,左孩子先出栈

            {

                 stack[++top] = p->lchild;

            }

        }

    }  

}

或者:

void preorder(btree *bt)

{

    btree *p, *stack[MAX];//p表示当前结点,栈stack[]用来存储结点

    int top=0;

 

    if(bt != NULL)//先判断是否为空树

    {

            p = bt;

            while (p || top > 0)

            {

                  if (p)

                  {

                        stack[top++] = p;

                        printf("%c", p->data);//输出该结点

                        p = p->lchild;

                  }

                  else

                  {

                      p = stack[--top]; //栈顶元素出栈

                      p = p->rchild;

                  }

            }

      }

}

 

总结:递归算法既是一种有效的算法设计方法,也是一种有效的分析问题的方法。递归算法求解问题的基本思想是:对于一个较为复杂的问题,把原问题分解成若干个相对简单且类同的子问题,这样,原问题就可递推得到解。

  

适宜于用递归算法求解的问题的充分必要条件是:

1)问题具有某种可借用的类同自身的子问题描述的性质;

2)某一有限步的子问题(也称作本原问题)有直接的解存在。

   当一个问题存在上述两个基本要素时,该问题的递归算法的设计方法是:

1)把对原问题的求解设计成包含有对子问题求解的形式。

2)设计递归出口。

递归算法的执行过程是不断地自调用,直到到达递归出口才结束自调用过程;到达递归出口后,递归算法开始按最后调用的过程最先返回的次序返回;返回到最外层的调用语句时递归算法执行过程结束。

阅读(4958) | 评论(0)


版权声明:编程爱好者网站为此博客服务提供商,如本文牵涉到版权问题,编程爱好者网站不承担相关责任,如有版权问题请直接与本文作者联系解决。谢谢!

评论

暂无评论
您需要登录后才能评论,请 登录 或者 注册