(06)递归程序构造
最后更新于:2022-04-01 16:15:26
这次谈谈递归程序的问题,之所以选递归这个话题主要是以下三个原因。第一个是自己的体会。在我的记忆中掌握递归程序是有一定难度的。最初在写递归程序时是全靠脑子想,一层一层的想着程序如何递归下去,然后又是如何返回的,最后整个递归程序又是如何结束的。对于一些简单的递归问题,特别是一些简单的习题,这个作法虽然笨拙,但是却有着相当的实用价值。只要脑子好使,一层一层的想下去,是可以解决一部分问题的。但是对于一些逻辑有点复杂,或者递归层数比较多的情况,这个方法就不好用了。尤其是在一些递归深度不确定的情况下,单凭脑子想就很难解决问题了。
第二个是应该有相当一部分的开发者认为递归程序不好写。这个结论来源于我的一个员工。这个员工大概有几年的开发经验,并且谈吐处事很得体稳重,给我的印象是不错的。在一次闲谈中他将会写递归程序作为一个亮点提出来的,言下之意自己的技术是很不错的。另一次经验来自一个面试的人。他把项目组的头会写递归程序作为一个敬佩的理由。由此我判断应该有相当一部分的开发者觉得递归程序不好写。
第三个理由就比较简单了,那就是递归程序确实很有用,很值得去掌握。
**一.递归的定义**
递归的概念的严格定义应该是来自数学,这个google一下就可以知道的。当然数学上的定义肯定是不太好理解的,有兴趣的可以自己看一下。这里给一个比较容易理解的版本,也是一个比较实用的说法。如果定义一个概念的时候使用到了这个概念本身那么这就是递归了。比如下面的二叉树的定义:
二叉树(BinaryTree)是:n(n≥0)个结点的有限集,它或者是空集(n=0),或者由一个根结点及两棵互不相交的、分别称作这个根的左子树和右子树的二叉树组成。
在上面的文字中,冒号后面的内容就是二叉树的定义。在这个定义中又出现了二叉树这个概念,所以这是二叉树的递归定义。当然需要区分一下这和循环论证是不一样的。
**二.递归程序的结构**
既然凭脑子想不能很好的解决问题,那么我们就需要使用一个更好的方法。我们可以从递归程序的结构出发来构造完成递归程序。所以这里介绍一下递归程序的结构。从结构上讲递归程序分为三个部分:递归出口,逻辑处理,递归调用。
1. 递归的出口
所谓递归的出口,就是指满足什么条件时程序不再需要递归调用了。这个时候往往是递归程序递归调用到最深层的时候,需要开始回归了。还有一种情况是做出判断决定是否执行当前的递归程序,比如对递归方法的参数的容错处理。
2. 逻辑处理
在考虑写递归程序的时候,至少需要知道在递归出口时需要执行的逻辑处理是什么。其次就是某一次递归调用前后需要执行的逻辑处理是什么。需要注意的是,这个时候的处理只是针对部分的数据,因为都是在某一次递归的执行中处理数据。不是对所有数据的完整的处理。完整的处理是整个递归程序执行完毕后才能完成的。
3. 递归调用
这个很好理解,就是在递归程序内部调用自己。
**三.递归程序构造举例1**
为了有一个感性的认识,这里举一个例子说明一下如何从递归程序的结构出发来完成递归程序的构造。这里就用教科书上遍历二叉树的例子来分析一下如何处理递归程序。首先我们考虑一下如果我们需要遍历一颗二叉树,那么什么情况下我们可以不用再递归遍历或者没必要继续遍历了?这个答案就是遇到一颗空树的时候就没有必要再遍历了。参考下面的方法定义:
~~~
public void VisitBinaryTree(NODE node)
{
…
}
~~~
其中参数node就表示了一颗二叉树的根节点,如果这个node的值是空的话,那么我们就没有必要再递归了,可以按照需求直接处理或者什么都不做直接返回了。所以上述方法的内部需要包含如下代码来结束递归,也就是所谓的递归的出口:
~~~
public void VisitBinaryTree(NODE node)
{
if (node == null)
return;
…
}
~~~
至此已经完成递归程序的第一部分了,当然也是最简单的一部分。需要强调的是这部分虽然是最简单的,但还是建议大家在构思递归程序时最好首先明确这部分的内容。否则一个递归程序没有出口的话,那么运行起来会把栈击穿的,从而导致崩溃。
下面第二步就要考虑核心的问题了,那就是如果node不为空时我们如何处理?首先需要明确我们要完成的逻辑是什么。一般教科书上的遍历例子,不会讲所谓逻辑处理的,只是描述遍历,这里我们可以假设一个虚拟的逻辑处理。我们假设这个逻辑处理由如下的方法完成:
~~~
public void DoSomething(NODEnode)
{
…
}
~~~
于是加上逻辑执行部分,我们的递归程序看上去就如同下面的样子了:
~~~
public void VisitBinaryTree(NODE node)
{
if (node == null)
return;
DoSomething(node);
}
~~~
很明显Dosomething按照既定的要求完成了对节点node的处理,但是我们需要处理二叉树中的每一个节点,只执行DoSomething(node)这一行代码是不够的。所以这时我们需要递归程序的第三部分,即递归调用。就这个例子而言,node表示一棵二叉树的根节点,并且在VisitBinaryTree方法内部我们调用了DoSomething方法完成了对node节点的处理。那么剩下的工作就是要处理node的左子树和右子树了,只有这样才算是完成了对node为根的整棵二叉树的处理。
这个时候我们可以再继续写代码来处理node的左子树和右子树,但是等等,由于我们现在构造的方法VisitBinaryTree就是用来处理二叉树的,而左子树或者右子树本身也是一棵二叉树,所以我们就没有必要再写额外的代码来处理而是直接递归调用该方法就可以了。所以加入递归调用的代码后,VisitBinaryTree方法差不多就是下面的样子了:
~~~
public void VisitBinaryTree(NODE node)
{
if (node == null)
return;
DoSomething(node);
VisitBinaryTree(node.LeftSon);
VisitBinaryTree(node.RightSon);
}
~~~
至此遍历二叉树的方法就完成了。下面我们来讨论一个细节问题,那就是根节点的判空问题。在这个例子中node的判空处理既是递归的出口,也是一种容错处理。因为如果不进行容错处理的话,那么DoSomething方法内容如果访问了node对象的属性或者方法,就会出现null对象方法的异常。但是有些人更习惯于在递归执行前对是否为空值作出判断,从而决定是否递归调用,这可以保证每次递归调用时,传入的值都不为空。因此代码差不多是下面这个样子:
~~~
public void VisitBinaryTree(NODE node)
{
DoSomething(node);
if (node.LeftSon != null)
VisitBinaryTree(node.LeftSon);
if (node. RightSon != null)
VisitBinaryTree(node.RightSon);
}
~~~
这样的代码确实保证了传入递归方法的根节点参数不为空。但是却忽略了一个问题,那就是第一次调用VisitBinaryTree方法时node为空的情况没有考虑。假设如下的情况,我们在一个方法OneMethod中如下调用的例子:
~~~
public void OneMethod(…)
{
…
VisitBinaryTree(null);
…
}
~~~
所以为了应对这个情况,最初判断node是否为空的代码还是需要的,这样代码就变成如下的样子了:
~~~
public void VisitBinaryTree(NODE node)
{
if (node == null)
return;
DoSomething(node);
if (node.LeftSon != null)
VisitBinaryTree(node.LeftSon);
if (node. RightSon != null)
VisitBinaryTree(node.RightSon);
}
~~~
好,现在重点来了。上面的代码中最后两个判空的if语句还需要么?答案是不需要了。假设去掉最后两个判空的判断,那么传入的参数确实有可能为空,但是当这样的参数传入VisitBinaryTree方法时,该方法的最开始就对这个参数执行了判空的处理,如果为空就直接返回了。所以达到了同样的目的。请体会一下,递归程序就是这个样子的。
**四.递归程序构造举例2**
在数据结构的教材上,遍历二叉树的方法有六种不同的版本,最常用的只有三种,分别是:先序优先遍历,左序优先遍历,右序优先遍历。上面的例子是用的先序优先遍历。下面看看用同样的方法来构造左序优先遍历的递归方法。所谓左序优先,是要求先遍历处理完二叉树的左子树,然后处理根节点,然后再遍历处理完二叉树的右子树。
好,我们还是首先考虑递归的出口,对于遍历二叉树而言,其出口仍旧不变,还是判空,如果为空就直接返回不处理了。所以第一步的代码是一样的,就不再列出来了。下面是如果节点不为空,我们需要先遍历处理左子树,再处理根节点,然后再遍历处理右子树。根据这个要求我们明确了可以执行的逻辑是处理根节点。至此,第二部分完成一半了,列出代码如下:
~~~
public void VisitBinaryTree(NODE node)
{
if (node == null)
return;
DoSomething(node);
}
~~~
下面就是关键了,那就是如何递归调用了。为了易于理解,我们可以先假设,需要处理的二叉树是没有任何左子树的,也就是说要么没有任何子树,要么就只有右子树。这样我们只需要考虑右子树就可以了,把左子树忘了吧。根据遍历处理的要求是先处理根节点然后再处理左子树,所以我们的代码如下:
~~~
public void VisitBinaryTree(NODE node)
{
if (node == null)
return;
DoSomething(node);
VisitBinaryTree(node.RightSon);
}
~~~
当VisitBinaryTree被递归调用时,传入的是node的右子树的根节点。这个右子树根节点,传入后首先是被判空,然后是调用DoSomething执行逻辑处理,然后再次递归调用来处理右子树的右子树。显然这样的递归调用逻辑是对的。
现在再假设需要处理的二叉树是没有任何右子树的,也就是说要么没有任何子树,要么就只有左子树。这样我们只需要考虑左子树就可以了,这次可以把右子树忘了吧。根据遍历处理的要求是先处理左子树然后再根节点,所以我们的代码如下:
~~~
public void VisitBinaryTree(NODE node)
{
if (node == null)
return;
VisitBinaryTree(node.LeftSon);
DoSomething(node);
}
~~~
当VisitBinaryTree被递归调用时,传入的是node的左子树的根节点。这个左子树根节点,传入后首先是被判空,然后是再次递归调用VisitBinaryTree遍历处理左子树的左子树。然后再处理根节点,显然这样的逻辑也是对的。好了,至此我们可以考虑既有左子树又有右子树的一般情况了,把两部分的代码合起来就可以了,如下所示:
~~~
public void VisitBinaryTree(NODE node)
{
if (node == null)
return;
VisitBinaryTree(node.LeftSon);
DoSomething(node);
VisitBinaryTree(node.RightSon);
}
~~~
**五.递归程序构造的比较**
举例1的构造过程是从递归程序结构本身直接推导出来的,是一个很自然的过程。在构造时并没有考虑是否为后续遍历,只是构造完成后正好和后续遍历一致。在举例2中的构造过程中使用了一点技巧,那就是为了简化问题,看清递归调用的位置,先后假设不存在左子树和右子树的情况,然后再将两部分合并,从而完成递归程序的构造。这就是说举例2中在使用这个方法时多了一个简化问题的步骤,这是使用已知的知识解决问题的一个例子。关于解决问题的更多讨论可以参考本系列中问题解决篇的讨论。再将这两个例子和教科书上的例子做一个比较。这里的讨论给出了递归程序构造的详细步骤,相比教科书上直接给出结果来说,我觉得这里讨论更容易理解。另一个区别是,由于本文的例子是从递归的结构出发完成构造递归程序的,所以没有涉及讨论所谓递归程序执行时会用到的工作栈的问题。有兴趣的可以再看一下其它相关的资料,对工作栈的了解应该多少对递归程序的认识是有帮助的。
这次就写到这里,感谢阅读。下一篇还是谈谈递归程序,介绍一个更强更"广谱适用"的方法来完成递归程序的设计。