十八年开发经验分享(07)递归程序设计

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

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

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

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

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

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

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

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

// 对应非终结符<二叉树>,表示遍历二叉树
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 | <字符> | <字符><字符串>
<字符> = …
依据这个规则也是能够推出递归程序的。

关于递归程序还有一些话题能够讲,好比数学概括法,递推,递归程序的测试等等。这些扩展的话题留在之后再介绍了,此次就写到这里了。最后推广一下个人群244054966,欢迎正在创业的程序员加入。入群时请写明“csdn博文”,不然不加。

相关文章
相关标签/搜索