十八年开发经验分享(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博文”,否则不加。

十八年开发经验分享(07)递归程序设计,布布扣,bubuko.com

时间: 2024-12-17 19:06:19

十八年开发经验分享(07)递归程序设计的相关文章

开发经验分享(一)

开发经验分享系列文章主要记录工作实际项目中遇到的问题和解决办法,希望能对大家有参考意义. 一.芯片的地址分配和变量地址的指定 芯片的存储区很小,所以要合理利用存储区,在进行地址空间的分配时就需要一定的技巧. 在进行开发时,一定要做好地址的划分. 比如CODE区的0x0000~0x8000作为COS区,接下来的0x8000~0x10000作为文件系统区,依次类推…… 在定义变量的时候,也要注意定义在了什么位置,占用的空间有多大. 比如,我们在XRAM区定义变量和数组: xdata char tem

Android开发经验分享-GridView、ListView内容错乱

在使用GridView.ListView的过程中遇到内容错乱的问题,费了较长时间才找到问题的根源,特地总结一下. 1.在自定义adapter中没有给每一项都设置内容导致内容错乱: @Override public View getView(final int position, View convertView, ViewGroup parent) { if( null == convertView ){ mGridHolder = new GridHolder( ); convertView

IOS开发经验分享

一些IOS开发的心得: 1) [Multiple Threads] IOS多线程注意, 所有的UI操作都必须在主线程上: Any code that will update the UI should be done on the main thread. Data loading should typically be done in some background thread. 示例: [self performSelectorOnMainThread:@selector(updateTh

递归程序设计心得与体会

用递归设计出来的程序总是简洁易读,极具美感.但是对于刚入门的学者来说,当遇到递归场景时,自己却难以正确的设计出合理的递归程序.博主曾经也是困惑不已,写的多了,也就渐渐的熟悉了递归设计.特谈一下自己的感受,有些术语是博主自己总结,有可能有不合理之处. 学习递归程序设计,建议首先应该从小规模的递归开始研究,小规模就是说自己可以调试跟踪代码,且自己不会晕.这个过程完成之后,才能熟练掌握递归层次之间的转换,明白递归的执行过程.在这里推荐一篇文章:http://blog.chinaunix.net/uid

在智能电视中的实时数据呈现web开发经验分享

先上图,一睹为快. 看到图,身为资源web开发者的你,是不是在大脑中闪现出了一个个的技术名词,websocket.html5.css3(animation/transition).javascript(ajax/setTimeout/setInterval). 同样专注web开发xx年的你,有没有考虑到以下问题: 1.实时数据展现,如果采用ajax定时拉取对现有业务的影响,在DB性能这块,可能导致DB服务死去 2.采用客户端主动拉取还是服务器端的推技术,服务器推技术似乎实现起来太多麻烦,后端的配

项目开发经验分享—分页查询

从今天开始,我将和大家分享一下最近经手项目的开发经验.今天我们分享的内容是:分页查询! 引言 大家在浏览网页的时候,肯定遇到过这样的效果,一个滚动条套另一个滚动条,上下拉动,看着非常不方便,或是整个检索内容都在同一个页面,导致页面加载速度太慢,等半天都没有反应(如下图),这种情况下,用户可能没有耐心等待直接关闭该页面啦!    当我们的项目检索后内容也很多,这时候我们怎么办?是把用户检索到的信息一下子都显示到页面,让用户在等待加载的漫长过程中耐心耗尽后放弃使用,还是想用户之所想,通过添加分页,去

微信支付开发经验分享

公司项目需要用到微信支付,之前没有接触过,这里把遇到的一些问题和开发流程和大家分享一下. 1.首先需要在微信开放平台注册一个开发者账号. 2.在管理中心里面创建应用,这里的重点1是创建应用时要填一个应用签名,这个签名从何而来呢?首先要求填写签名的地方可以下载一个签名APP,安装此签名APP到手机,接下来用正式的签名工具打包现在自己的APP并安装到手机,打开签名APP输入刚才自己的APP包名,这样就生成了应用签名. 3.开发者资质认证,这一步主要是要提交企业的一些基本资料,比如:开户许可证.税务登

iOS开发经验分享:UITableViewCell复用问题

很多朋友觉得UITableViewCell复用问题很难处理,百思不得其解,甚至有很多朋友自己琢磨很久也不明白个究竟.现在分享一下个人的一些经验,希望对大家有帮助,如果有好的意见或者有不同的看法也可以提出来,让我们一起分享一起进步,知识只有在分享的情况下才能实现它的最大价值.好了,废话少说,直奔主题了.列举两个场景对比一下,也许tableviewcell的复用就很清晰明了了.本文来自于无限互联的学员. 例1: - (UITableViewCell *)tableView:(UITableView

Android开发经验分享(1) 解决部分手机不能在shell下进入Sqlite数据库

今天和大家分享一个自己在这1年里Android开发中碰到的各种问题:今天开发项目中想查看本地的Sqlite数据库,以前用的客户端居然不能免费使用了.又得自己开始折腾一下.想办法进入shell来查看sqlite数据库中的数据. 首先,手机必须要root掉.我采用的是百度一键root功能,手机root之后.将手机用USB线连接.连接完后,我使用命令 cd /data/data 进入data数据文件,我用ll命令可是我发现我进入不了.提示如下: 仔细一读提示:知道原来是没有权限,那我只要给他权限就可以