递归程序设计构造及设计

转自here
     
     这次谈谈递归程序的问题,之所以选递归这个话题主要是以下三个原因。第一个是自己的体会。在我的记忆中掌握递归程序是有一定难度的。最初在写递归程序时是全靠脑子想,一层一层的想着程序如何递归下去,然后又是如何返回的,最后整个递归程序又是如何结束的。对于一些简单的递归问题,特别是一些简单的习题,这个作法虽然笨拙,但是却有着相当的实用价值。只要脑子好使,一层一层的想下去,是可以解决一部分问题的。但是对于一些逻辑有点复杂,或者递归层数比较多的情况,这个方法就不好用了。尤其是在一些递归深度不确定的情况下,单凭脑子想就很难解决问题了。 

第二个是应该有相当一部分的开发者认为递归程序不好写。这个结论来源于我的一个员工。这个员工大概有几年的开发经验,并且谈吐处事很得体稳重,给我的印象是不错的。在一次闲谈中他将会写递归程序作为一个亮点提出来的,言下之意自己的技术是很不错的。另一次经验来自一个面试的人。他把项目组的头会写递归程序作为一个敬佩的理由。由此我判断应该有相当一部分的开发者觉得递归程序不好写。 

第三个理由就比较简单了,那就是递归程序确实很有用,很值得去掌握。在开发Entity Model Studio的时序图时,需要遍历消息流经过的所有节点,从而实现一个方便的移动操作,这里就用到了递归遍历。因为结构上讲是在遍历一棵树。

一.递归的定义

递归的概念的严格定义应该是来自数学,这个google一下就可以知道的。当然数学上的定义肯定是不太好理解的,有兴趣的可以自己看一下。这里给一个比较容易理解的版本,也是一个比较实用的说法。如果定义一个概念的时候使用到了这个概念本身那么这就是递归了。比如下面的二叉树的定义:

二叉树(BinaryTree)是:n(n≥0)个结点的有限集,它或者是空集(n=0),或者由一个根结点及两棵互不相交的、分别称作这个根的左子树和右子树的二叉树组成。

在上面的文字中,冒号后面的内容就是二叉树的定义。在这个定义中又出现了二叉树这个概念,所以这是二叉树的递归定义。当然需要区分一下这和循环论证是不一样的。

二.递归程序的结构

既然凭脑子想不能很好的解决问题,那么我们就需要使用一个更好的方法。我们可以从递归程序的结构出发来构造完成递归程序。所以这里介绍一下递归程序的结构。从结构上讲递归程序分为三个部分:递归出口,逻辑处理,递归调用。

1. 递归的出口

所谓递归的出口,就是指满足什么条件时程序不再需要递归调用了。这个时候往往是递归程序递归调用到最深层的时候,需要开始回归了。还有一种情况是做出判断决定是否执行当前的递归程序,比如对递归方法的参数的容错处理。

2. 逻辑处理

在考虑写递归程序的时候,至少需要知道在递归出口时需要执行的逻辑处理是什么。其次就是某一次递归调用前后需要执行的逻辑处理是什么。需要注意的是,这个时候的处理只是针对部分的数据,因为都是在某一次递归的执行中处理数据。不是对所有数据的完整的处理。完整的处理是整个递归程序执行完毕后才能完成的。

3. 递归调用

这个很好理解,就是在递归程序内部调用自己。

三.递归程序构造举例1

为了有一个感性的认识,这里举一个例子说明一下如何从递归程序的结构出发来完成递归程序的构造。这里就用教科书上遍历二叉树的例子来分析一下如何处理递归程序。首先我们考虑一下如果我们需要遍历一颗二叉树,那么什么情况下我们可以不用再递归遍历或者没必要继续遍历了?这个答案就是遇到一颗空树的时候就没有必要再遍历了。参考下面的方法定义:

 

[csharp] view plaincopy在CODE上查看代码片派生到我的代码片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     …  
  4. }  

 

其中参数node就表示了一颗二叉树的根节点,如果这个node的值是空的话,那么我们就没有必要再递归了,可以按照需求直接处理或者什么都不做直接返回了。所以上述方法的内部需要包含如下代码来结束递归,也就是所谓的递归的出口:

 

[csharp] view plaincopy在CODE上查看代码片派生到我的代码片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     if (node == null)  
  4.         return;  
  5.     …  
  6. }  

至此已经完成递归程序的第一部分了,当然也是最简单的一部分。需要强调的是这部分虽然是最简单的,但还是建议大家在构思递归程序时最好首先明确这部分的内容。否则一个递归程序没有出口的话,那么运行起来会把栈击穿的,从而导致崩溃。

 

下面第二步就要考虑核心的问题了,那就是如果node不为空时我们如何处理?首先需要明确我们要完成的逻辑是什么。一般教科书上的遍历例子,不会讲所谓逻辑处理的,只是描述遍历,这里我们可以假设一个虚拟的逻辑处理。我们假设这个逻辑处理由如下的方法完成:

 

[csharp] view plaincopy在CODE上查看代码片派生到我的代码片
 
  1. public void DoSomething(NODEnode)  
  2. {  
  3.     …  
  4. }  

于是加上逻辑执行部分,我们的递归程序看上去就如同下面的样子了:

 

 

[csharp] view plaincopy在CODE上查看代码片派生到我的代码片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     if (node == null)  
  4.         return;  
  5.     DoSomething(node);  
  6. }  

很明显Dosomething按照既定的要求完成了对节点node的处理,但是我们需要处理二叉树中的每一个节点,只执行DoSomething(node)这一行代码是不够的。所以这时我们需要递归程序的第三部分,即递归调用。就这个例子而言,node表示一棵二叉树的根节点,并且在VisitBinaryTree方法内部我们调用了DoSomething方法完成了对node节点的处理。那么剩下的工作就是要处理node的左子树和右子树了,只有这样才算是完成了对node为根的整棵二叉树的处理。

 

这个时候我们可以再继续写代码来处理node的左子树和右子树,但是等等,由于我们现在构造的方法VisitBinaryTree就是用来处理二叉树的,而左子树或者右子树本身也是一棵二叉树,所以我们就没有必要再写额外的代码来处理而是直接递归调用该方法就可以了。所以加入递归调用的代码后,VisitBinaryTree方法差不多就是下面的样子了:

 

[csharp] view plaincopy在CODE上查看代码片派生到我的代码片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     if (node == null)  
  4.        return;  
  5.    
  6.     DoSomething(node);  
  7.     VisitBinaryTree(node.LeftSon);  
  8.     VisitBinaryTree(node.RightSon);  
  9. }  

至此遍历二叉树的方法就完成了。下面我们来讨论一个细节问题,那就是根节点的判空问题。在这个例子中node的判空处理既是递归的出口,也是一种容错处理。因为如果不进行容错处理的话,那么DoSomething方法内容如果访问了node对象的属性或者方法,就会出现null对象方法的异常。但是有些人更习惯于在递归执行前对是否为空值作出判断,从而决定是否递归调用,这可以保证每次递归调用时,传入的值都不为空。因此代码差不多是下面这个样子:

 

 

[csharp] view plaincopy在CODE上查看代码片派生到我的代码片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     DoSomething(node);  
  4.      
  5.     if (node.LeftSon != null)  
  6.         VisitBinaryTree(node.LeftSon);  
  7.     if (node. RightSon != null)  
  8.         VisitBinaryTree(node.RightSon);  
  9. }  

这样的代码确实保证了传入递归方法的根节点参数不为空。但是却忽略了一个问题,那就是第一次调用VisitBinaryTree方法时node为空的情况没有考虑。假设如下的情况,我们在一个方法OneMethod中如下调用的例子:

 

 

[csharp] view plaincopy在CODE上查看代码片派生到我的代码片
 
  1. public void OneMethod(…)  
  2. {  
  3.     …  
  4.     VisitBinaryTree(null);  
  5.     …  
  6. }  

所以为了应对这个情况,最初判断node是否为空的代码还是需要的,这样代码就变成如下的样子了:

 

 

[csharp] view plaincopy在CODE上查看代码片派生到我的代码片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     if (node == null)  
  4.        return;  
  5.    
  6.     DoSomething(node);  
  7.      
  8.     if (node.LeftSon != null)  
  9.        VisitBinaryTree(node.LeftSon);  
  10.     if (node. RightSon != null)  
  11.        VisitBinaryTree(node.RightSon);  
  12. }  

好,现在重点来了。上面的代码中最后两个判空的if语句还需要么?答案是不需要了。假设去掉最后两个判空的判断,那么传入的参数确实有可能为空,但是当这样的参数传入VisitBinaryTree方法时,该方法的最开始就对这个参数执行了判空的处理,如果为空就直接返回了。所以达到了同样的目的。请体会一下,递归程序就是这个样子的。

 

四.递归程序构造举例2

在数据结构的教材上,遍历二叉树的方法有六种不同的版本,最常用的只有三种,分别是:先序优先遍历,左序优先遍历,右序优先遍历。上面的例子是用的先序优先遍历。下面看看用同样的方法来构造左序优先遍历的递归方法。所谓左序优先,是要求先遍历处理完二叉树的左子树,然后处理根节点,然后再遍历处理完二叉树的右子树。

好,我们还是首先考虑递归的出口,对于遍历二叉树而言,其出口仍旧不变,还是判空,如果为空就直接返回不处理了。所以第一步的代码是一样的,就不再列出来了。下面是如果节点不为空,我们需要先遍历处理左子树,再处理根节点,然后再遍历处理右子树。根据这个要求我们明确了可以执行的逻辑是处理根节点。至此,第二部分完成一半了,列出代码如下:

 

[csharp] view plaincopy在CODE上查看代码片派生到我的代码片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     if (node == null)  
  4.        return;  
  5.    
  6.     DoSomething(node);  
  7. }  

下面就是关键了,那就是如何递归调用了。为了易于理解,我们可以先假设,需要处理的二叉树是没有任何左子树的,也就是说要么没有任何子树,要么就只有右子树。这样我们只需要考虑右子树就可以了,把左子树忘了吧。根据遍历处理的要求是先处理根节点然后再处理左子树,所以我们的代码如下:

 

 

[csharp] view plaincopy在CODE上查看代码片派生到我的代码片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     if (node == null)  
  4.        return;  
  5.    
  6.     DoSomething(node);  
  7.     VisitBinaryTree(node.RightSon);  
  8. }  

当VisitBinaryTree被递归调用时,传入的是node的右子树的根节点。这个右子树根节点,传入后首先是被判空,然后是调用DoSomething执行逻辑处理,然后再次递归调用来处理右子树的右子树。显然这样的递归调用逻辑是对的。

 

现在再假设需要处理的二叉树是没有任何右子树的,也就是说要么没有任何子树,要么就只有左子树。这样我们只需要考虑左子树就可以了,这次可以把右子树忘了吧。根据遍历处理的要求是先处理左子树然后再根节点,所以我们的代码如下:

 

[csharp] view plaincopy在CODE上查看代码片派生到我的代码片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     if (node == null)  
  4.        return;  
  5.    
  6.     VisitBinaryTree(node.LeftSon);  
  7.     DoSomething(node);  
  8. }  

当VisitBinaryTree被递归调用时,传入的是node的左子树的根节点。这个左子树根节点,传入后首先是被判空,然后是再次递归调用VisitBinaryTree遍历处理左子树的左子树。然后再处理根节点,显然这样的逻辑也是对的。好了,至此我们可以考虑既有左子树又有右子树的一般情况了,把两部分的代码合起来就可以了,如下所示:

 

 

[csharp] view plaincopy在CODE上查看代码片派生到我的代码片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     if (node == null)  
  4.        return;  
  5.    
  6.     VisitBinaryTree(node.LeftSon);  
  7.     DoSomething(node);  
  8.     VisitBinaryTree(node.RightSon);  
  9. }  

五.递归程序构造的比较

举例1的构造过程是从递归程序结构本身直接推导出来的,是一个很自然的过程。在构造时并没有考虑是否为后续遍历,只是构造完成后正好和后续遍历一致。在举例2中的构造过程中使用了一点技巧,那就是为了简化问题,看清递归调用的位置,先后假设不存在左子树和右子树的情况,然后再将两部分合并,从而完成递归程序的构造。这就是说举例2中在使用这个方法时多了一个简化问题的步骤,这是使用已知的知识解决问题的一个例子。关于解决问题的更多讨论可以参考本系列中问题解决篇的讨论。再将这两个例子和教科书上的例子做一个比较。这里的讨论给出了递归程序构造的详细步骤,相比教科书上直接给出结果来说,我觉得这里讨论更容易理解。另一个区别是,由于本文的例子是从递归的结构出发完成构造递归程序的,所以没有涉及讨论所谓递归程序执行时会用到的工作栈的问题。有兴趣的可以再看一下其它相关的资料,对工作栈的了解应该多少对递归程序的认识是有帮助的。

这篇谈谈递归程序设计的问题。从取名上来说是想刻意区别内容的侧重点不同。上一篇是构造,其重点是从递归程序的自身结构出发,试图用一种比较直观的方法来完成递归程序的构造。这篇的重点是设计,其中的区别在于,这次是从问题本身的结构出发来完成递归程序的开发任务。上一篇中介绍的方法,比较简单直观,八股文的意味非常浓郁,并且还有一个比较大的缺点,那就是在实际使用时往往会受制与方法本身而不能解决有一定难度的问题。实际上递归是一种客观存在的现象,递归的描述问题是对客观世界的一种认识。本文从对问题的认识,描述和分析这些步骤来介绍一下如何完成递归程序的设计。

一.问题的描述方法—巴克斯范式
在我上大学的时候,巴克斯范式出现在编译原理的课程中,是用来定义文法的。在数据结构课程中并没有介绍巴克斯范式。但是在实践中发现,这个范式对完成递归程序非常有帮助。因为根据巴克斯范式,我们可以自动生成词法分析程序,而这些程序就包含了各种递归程序及其调用。这里不打算从编译的角度来介绍巴克斯范式,而是借用巴克思范式的思想来帮助完成递归程序的开发。所以规范和严谨程度是远不如巴克斯范式的。

先从一个具体的例子开始引入巴克斯范式。现将前一篇“递归程序构造”中关于二叉树的定义再次描述如下:
n(n≥0)个结点的有限集,它或者是空集(n=0),或者由一个根结点及两棵互不相交的、分别称作这个根的左子树和右子树的二叉树组成。

这是一个用严谨的自然语言描述的定义,下面用另一种形式等价的来描述这个定义:
<二叉树> = null | 节点<左子树><右子树>
<左子树> = <二叉树>
<右子树> = <二叉树>

上面的定义由三行文本组成,每一行文本是一个等式,称之为规则,所以一共是三条规则。等号的左边称为非终结符,等号的右边表示这个非终结符的组成内容。一般非终结符用“<”和“>”两个符号包围。这些是巴克斯范式中的内容。

以第一条规则为例,等号的右边首先是null,这表示空,这等效于二叉树定义中的“它或者是空集(n=0)”这段文字。最右边的“节点<左子树><右子树>”表示二叉树有一个节点及其所属的左子树和右子树组成,这个描述二叉树概念中的“由一个根结点及两棵互不相交的、分别称作这个根的左子树和右子树”这些文字对应。第二条和第三条规则表示左子树和右子树都是一棵二叉树,这个和定义中的最后几个字“二叉树组成”相对应。最后看一下第一条规则中的字符“|”。这个字符在巴克斯范式中表示或,其含义是该字符的左边或者右边只能取一个。这个符号和定义中“或者”这个词相对应。至此可以确认上述三条规则对二叉树的描述和定义对二叉树的描述是等价的。

有了这个等价的巴克斯范式版本的二叉树定义,我们就可以使用处理巴克斯范式的方式,或者说可以使用编译原理中词法分析的思路来完成递归程序的开发了。
二. 从规则集转换得到递归程序
前一篇递归程序构造中使用了遍历二叉树的例子,这里还是使用相同的例子,看看从规则集是如何完成遍历二叉树的递归程序的开发的。事实上从规则集合转换得到递归程序的步骤是很简单的,也是可以自动化的。我们完全可以开发一个程序,通过扫描规则集自动生成递归程序。下面介绍手工完成的具体步骤。

首先为每一个非终结符定义方法,每一个方法只用来处理对应的非终结符。上述三条规则中包含了三个非终结符,所以我们需要三个方法,列出如下:

// 对应非终结符<二叉树>,表示遍历二叉树
VisitBinaryTree()
// 对应非终结符<左子树>,表示遍历左子树
VisitLeftBinaryTree()
// 对应非终结符<右子树>,表示遍历右子树
VisitRightBinaryTree()

现在我们得到了三个方法,然后给这些方法定义参数。由于三个方法都是需要遍历,所以二叉树的根节点必须是方法的参数,否则遍历无法完成。增加参数后方法如下所示:
// node是二叉树的根节点
VisitBinaryTree(Node node)
// node是左子树的根节点
VisitLeftBinaryTree(Node node)
// node是右子树的根节点
VisitRightBinaryTree(Node node)

第二步是在各个方法中对指定的非终结符的右边内容进行处理。首先看第一条规则。由于规则中有一个“|”符号,表示右边两部分内容不能同时处理,所以显然需要一个if语句做判断,然后分情况分别处理两部分的内容。先看“|”左边的内容null,这个含义是二叉树为空,如果是这样,那么就无需遍历,所以对应的代码应该如下:

if (node == null)
    return;

如果二叉树不为空,那么需要处理“|”右边的内容,这些内容分别是根节点,左子树和右子树。对于根节点的处理可以抽象的使用一个方法ProcessNode来表示,而后面的左子树和右子树是非终结符,可以直接调用处理改非终结符的方法就可以了。修改完后代码如下所示:

复制代码
if (node == null)
    return;
else
{
    ProcessNode(node);
    VisitLeftBinaryTree(node.LeftTree);
    VisitRightBinaryTree(node.RightTree);
}
复制代码

对于第二和第三条规则,由于右边只有一个非终结符,所以其内部的代码就是直接调用对应的处理该非终结符的方法就可以了,完整的代码如下所示:

复制代码
public void VisitBinaryTree(Node node)
{
    if (node == null)
        return;
    else
    {
        ProcessNode(node);
        VisitLeftBinaryTree(node.LeftTree);
        VisitRightBinaryTree(node.RightTree);
    }
}
public void VisitLeftBinaryTree(Node node)
{
    VisitBinaryTree(node);
}
public void VisitRightBinaryTree(Node node)
{
    VisitBinaryTree(node);
}
复制代码

到这里代码就完成了,而且还是一个间接递归的版本。下面对这些规则和代码再做一个讨论,让问题更明晰透彻一些。

三. 若干细节讨论
第一个需要讨论的就是间接递归的问题。我们熟知的遍历二叉树的递归程序都是直接递归,这里得到却是一个间接递归。其原因不是介绍的方法有问题,而是上述规则的设计问题。可以看到第二条和第三条规则表达含义就是<左子树>和<右子树>也是一棵二叉树。补充这个规则的用意是为了体现二叉树定义中出现的文字“分别称作这个根的左子树和右子树的二叉树组成”,这句话表明左子树和右子树也是二叉树,所以加入了上述规则。

既然非终结符<左子树>,<右子树>和非终结符<二叉树>是等价的,那么我们可以将规则一右边出现的<左子树>,<右子树>直接用<二叉树>代替。这样规则一就如下所示:
<二叉树> = null | 根节点<二叉树><二叉树>

还是使用相同的推导方法,这次我们可以得到直接递归版本的二叉树遍历程序,如下所示:

复制代码
public void VisitBinaryTree(Node node)
{
    if (node == null)
        return;
    else
    {
        ProcessNode(node);
        VisitBinaryTree(node.LeftTree);
        VisitBinaryTree(node.RightTree);
    }
}
复制代码

第二点是需要强调一下推导的步骤。我相信有些读者已经发现了间接递归的问题,并且也能够直接修改代码,将其改为直接递归。比如直接通过读代码就可以发现方法VisitLeftBinaryTree和VisitRightBinaryTree什么都没干,只是调用了方法VisitBinaryTree,所以就可以直接调用VisitBinaryTree从而替换掉对方法VisitLeftBinaryTree和VisitRightBinaryTree的调用。这样做是可以的,尤其在这个具体的简单问题上。但是当规则足够多,并且足够复杂时问题就不太可能如此直白,如此易于观察并得到结论。所以强烈推荐的做法是先修改规则,然后再根据规则推导出程序,这是工程化的做法。

第三点,不是需要给所有的非终结符都定义方法,然后再重构,如果能看清问题那么可以直接写出最终的代码。这也是不太规范的一个地方。

第四点是强调一下这里用到的规则和巴克斯范式的差异。前文已经提到巴克斯范式是一个规范而严谨的定义,而这里使用的规则只是借用了巴克斯范式的思路来描述问题,不是很规范和严谨。比如在巴克斯范式中规则一的右边不仅表示<二叉树>可以由根节点,<左子树>和<右子树>组成,同时也表示这三者先后出现顺序。但是这里使用的规则,仅仅表示组成内容。或者说仅仅想表示二叉树的结构,从而和二叉树定义的描述等价。注意二叉树定义中的描述没有规定左子树和右子树出现的先后顺序。所以在VisitBinaryTree方法中对处理内容的先后没有限制。由此可以推导出遍历二叉树的不同版本,只需要改变调用处理非终结符方法的先后顺序即可。

当然根据具体的问题,可以给规则加入其它的变化和含义,以便于等价的描述问题。这其中的取舍和尺度的把握是体现问题分析和程序设计能力的地方。下面再举一个例子来说明这个问题。

四. 规则的设计
从前文的介绍可以看出,只要得到了规则,那么推导出递归程序是非常容易的。
这样开发递归程序的问题就转化为如何得到规则了,也就是规则的设计问题。我的建议是多练习,多实践。因为没有一个固定的做法可以让我们比较容易的得到规则集,所以通过练习和实践来提升问题的分析能力和程序的设计能力就是关键和捷径了。但是在有些时候思考问题的技巧对我们也是有辅助帮助作用的。这里举一个例子来说明一下,想以此扩展一下读者的思路。这个例子是:逆转字符串。

如何逆转一个字符串是非常容易的,但是如何写出递归版本的代码呢?请注意写出递归的关键是发现问题的递归结构,这个递归结构是事物本身的特性,而不是只指我们需要对该事物执行什么样的操作。这就是说逆转操作不是关键,关键是如何找到字符串的递归结构或者说如何找到字符串的递归定义。当然这个能力需要在实践中逐步培养。下面直接给出规则版本的定义:

<字符串> = null | <字符> | <字符><字符串><字符>
<字符> = …

先看第一条规则的右边,null表示空串,<字符>表示只有一个字符的字符串,最后部分表示有多个字符的字符串。第二条规则定义了<字符>可以是哪些字符,比如’a’,’b’,’c’或者’1’,’2’,’3’,之类的,由于比较多就不全写了。然后使用上文介绍的方法来推导,首先给<字符串>定义方法,然后分别处理右边的内容,代码如下所示:

复制代码
public string ReverseString(string str, int start, int end)
{
    if (start >= end)
        return str;
    else if (str == null || str.Length < 1)
        return str;
    else if (str.Length == 1)
        return str;
    else
    {
        char temp = str[start];
        str[start] = str[end];
        str[end] = temp;

        return ReverseString(str, start + 1, end - 1);
    }
}
复制代码

方法的调用如下:

ReverseString(str, 0, str.Length - 1);
ReverseString中的第一个if是加入的递归出口判断,这不能从规则推导出来,需要自己加。关于递归的出口可以阅读前一篇:递归程序构造。另外还可以修改规则如下:
<字符串> = null | <字符> | <字符><字符串>
<字符> = …
依据这个规则也是可以推出递归程序的。

关于递归程序还有一些话题可以讲,比如数学归纳法,递推,递归程序的测试等等。这些扩展的话题留在以后再介绍了,这次就写到这里了。

posted @ 2014-08-03 21:00  tt_tt--->  阅读(143)  评论(0编辑  收藏  举报