十八年开发经验分享(07)递归程序设计
这篇谈谈递归程序设计的问题。从取名上来说是想刻意区别内容的侧重点不同。上一篇是构造,其重点是从递归程序的自身结构出发,试图用一种比较直观的方法来完成递归程序的构造。这篇的重点是设计,其中的区别在于,这次是从问题本身的结构出发来完成递归程序的开发任务。上一篇中介绍的方法,比较简单直观,八股文的意味非常浓郁,并且还有一个比较大的缺点,那就是在实际使用时往往会受制与方法本身而不能解决有一定难度的问题。实际上递归是一种客观存在的现象,递归的描述问题是对客观世界的一种认识。本文从对问题的认识,描述和分析这些步骤来介绍一下如何完成递归程序的设计。
一.问题的描述方法—巴克斯范式
在我上大学的时候,巴克斯范式出现在编译原理的课程中,是用来定义文法的。在数据结构课程中并没有介绍巴克斯范式。但是在实践中发现,这个范式对完成递归程序非常有帮助。因为根据巴克斯范式,我们可以自动生成词法分析程序,而这些程序就包含了各种递归程序及其调用。这里不打算从编译的角度来介绍巴克斯范式,而是借用巴克思范式的思想来帮助完成递归程序的开发。所以规范和严谨程度是远不如巴克斯范式的。
先从一个具体的例子开始引入巴克斯范式。现将前一篇“递归程序构造”中关于二叉树的定义再次描述如下:
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 | <字符> | <字符><字符串>
<字符> =
…
依据这个规则也是可以推出递归程序的。
关于递归程序还有一些话题可以讲,比如数学归纳法,递推,递归程序的测试等等。这些扩展的话题留在以后再介绍了,这次就写到这里了。最后推广一下我的群244054966,欢迎正在创业的程序员加入。入群时请写明“csdn博文”,否则不加。