![计算机程序的构造和解释(JavaScript版)](https://wfqqreader-1252317822.image.myqcloud.com/cover/951/50417951/b_50417951.jpg)
1.2.2 树形递归
另一种常见计算模式称为树形递归。作为例子,现在考虑斐波那契(Fibonacci)数序列的计算,这一序列中的每个数都是前面两个数之和:
0, 1, 1, 2, 3, 5, 8, 13, 21,…
一般而言,斐波那契数由下面的规则定义:
![](https://epubservercos.yuewen.com/1E97F9/29686461304596906/epubprivate/OEBPS/Images/46_04.jpg?sign=1739673666-8cxgbSYygWF7ekqwptipW76Z06LrzShS-0-dca9038c6b0c899b64390b31cb2559c2)
我们立刻就能把这个定义翻译为一个计算斐波那契数的递归函数:
![](https://epubservercos.yuewen.com/1E97F9/29686461304596906/epubprivate/OEBPS/Images/46_05.jpg?sign=1739673666-vKCMX4w6sreWSjVQzkm0P97PpOM5Lq3J-0-c367e89248f5acaab64a952e0058aa1f)
![](https://epubservercos.yuewen.com/1E97F9/29686461304596906/epubprivate/OEBPS/Images/47_01.jpg?sign=1739673666-6woZYfvaNnlzdxvEJUIT8lWX2iCMnTfk-0-4bb6c5f834e5c9be2b792a8e422d1640)
现在考虑这一计算的模式。为了计算fib(5),我们需要计算fib(4)和fib(3)。而为了计算fib(4),又需要计算fib(3)和fib(2)。一般而言,这一演化过程看起来像一棵树,如图1.5所示。请注意,这里的分支在每一层分裂为两个分支(除了最下面的),反映出对fib函数的每个调用里两次递归调用自身的事实。
![](https://epubservercos.yuewen.com/1E97F9/29686461304596906/epubprivate/OEBPS/Images/47_02.jpg?sign=1739673666-Yg3K2QOgHwML5nfLYFqfDYclre5UAEgl-0-7a17df6b40a752b09f995767e2417892)
图1.5 计算fib(5)产生的树形递归计算过程
上面这个函数作为典型的树形递归很有教育意义,但对计算斐波那契数而言,它却是一种很糟糕的方法,因为做了太多的冗余计算。从图1.5中可以看到,对fib(3)的全部计算重复做了两次,差不多占到所有工作的一半。事实上,不难证明,在整个计算过程中,计算fib(1)和fib(0)的次数(一般来说,也就是上面这种树里的树叶数)正好是Fib(n+1)。要感受一下这种情况有多糟,我们可以证明Fib(n)值的增长相对于n是指数的。更准确地说(见练习1.13),Fib(n)等于最接近的那个整数,其中:
![](https://epubservercos.yuewen.com/1E97F9/29686461304596906/epubprivate/OEBPS/Images/47_04.jpg?sign=1739673666-2CRNGx2OBMHXJkldrFpcLVAC3SIE7wyw-0-2d82c4b5ea3d5f2fee478a7614d81b86)
这也就是黄金分割的值,它满足方程:
ϕ2=ϕ+1
这样,这个计算过程中所用的步骤数将随着输入的增长而指数增长。在另一方面,其空间需求只是随着输入的增长而线性增长,因为,在计算中的每一点,我们都只需要保存树中在此结点之上的结点的轨迹。一般而言,树形递归计算过程里所需要的步骤数正比于树中的结点数,其空间需求正比于树的最大深度。
我们也可以规划出一种计算斐波那契数的迭代计算过程,其基本想法就是用一对整数a和b,把它们分别初始化为Fib(1)=1和Fib(0)=0,然后反复地同时应用下面的变换规则:
a←a+b
b←a
不难证明,在n次应用这些变换后,a和b将分别等于Fib(n+1)和Fib(n)。因此,我们可以定义下面这两个函数,以迭代方式计算斐波那契数:
![](https://epubservercos.yuewen.com/1E97F9/29686461304596906/epubprivate/OEBPS/Images/48_01.jpg?sign=1739673666-ENQmg4AWO3itVY2z4m1LfDAmdLQR82PG-0-478c8ca696ff0976c783ce7d9b4d5e98)
这种计算Fib(n)的方法是线性迭代。这两种方法在所需步数上差异巨大——后一个相对n为线性的,前一个增长得如Fib(n)一样快——即使不大的输入也可能造成巨大差异。
然而,我们也不应该做出结论说树形递归计算过程根本没用。如果需要考虑操作具有层次结构的数据(而不是操作数值),我们可能发现树形递归计算过程是一种很自然的、威力强大的工具[30]。即使对于数的计算,树形递归计算过程也可能帮助我们理解和设计程序。以计算斐波那契数的程序为例,虽然第一个fib函数远比第二个低效,但它却更直截了当,几乎就是把斐波那契序列的定义直接翻译为JavaScript。而要规划出对应的迭代算法,就需要注意到该计算过程可以重塑为一个采用三个状态变量的迭代。
实例:换零钱方式的统计
要找到迭代的斐波那契算法,只需要不多的智慧。下面这个问题的情况与此不同:我们有一些50美分、25美分、10美分、5美分和1美分的硬币,要把1美元换成零钱,一共有多少种不同的方式?更一般的问题是,给了任意数量的美元现金,我们能写出一个程序,计算出所有换零钱方式的数目吗?
如果采用递归函数,这个问题有一种很简单的解法。假定我们把考虑的可用硬币类按某种顺序排好,于是就有下面的关系。
把总数为a的现金换成n种硬币的不同方式的数目等于
现金a换成除去第一种硬币之外的所有其他硬币的不同方式数目
加上现金a-d换成所有种类的硬币的不同方式数目,其中d是第一种硬币的币值
要弄清为什么这一说法正确,请注意这里把换零钱的方式分成两组,第一组的换法都没用第一种硬币,而第二组都用了第一种硬币。显然,换零钱的全部换法的数目,就等于完全不用第一种硬币的换法数,加上用了第一种硬币的换零钱的换法数。而后一个数目也就等于去掉一个第一种硬币值后,将剩下的现金换零钱的换法数。
这样,我们就把某个给定现金数的换零钱方式的问题,递归地归约为对更少现金数或者更少硬币种类的同一个问题。请仔细考虑上面的归约规则,设法使自己确信,如果采用下面的方法处理各种退化情况,我们就能利用上述规则写出一个算法[31]:
●如果a就是0,应该算作有1种换零钱的方式。
●如果a小于0,应该算作有0种换零钱的方式。
●如果n是0,应该算作有0种换零钱的方式。
我们很容易把这些规则翻译成一个递归函数:
![](https://epubservercos.yuewen.com/1E97F9/29686461304596906/epubprivate/OEBPS/Images/48_02.jpg?sign=1739673666-nxe1TBMUgDfM6CcriE9AWkvb2YuWp3uz-0-ceb6c63fcc9b49734c3c0889595860e4)
![](https://epubservercos.yuewen.com/1E97F9/29686461304596906/epubprivate/OEBPS/Images/49_01.jpg?sign=1739673666-MSHWLngwg3SZaRLvvHYlr2gTwlyeIOvy-0-d43adfb64bc2f40bfe69a7ff1a3e58a6)
(函数first_denomination以可用硬币的种类数作为输入,返回第一种硬币的币值。这里认为硬币已经从最小到最大排好了,其实采用任何顺序都可以。)我们现在就能回答开始的问题了,下面是1美元换硬币的不同方法的数目:
![](https://epubservercos.yuewen.com/1E97F9/29686461304596906/epubprivate/OEBPS/Images/49_02.jpg?sign=1739673666-CkJrNDyBav8uj6UXzar8WlDCpSeyJvzd-0-ed6bcb962f6bc1de7e3a1e748617ee64)
函数count_change产生树形递归的计算过程,其中的冗余计算与前面fib的第一种实现类似。但另一方面,想设计一个能算出同样结果的更好的算法,就不那么明显了。我们把这个问题留给读者作为一个挑战。可以看到,树形递归的计算过程可能极其低效,但常常很容易描述和理解,这导致有人提出能否利用这两个世界里最好的东西设计一种“聪明编译器”,使之能把树形递归的函数翻译为能完成同样计算的更高效的函数[32]。
练习1.11 函数f由如下规则定义:如果n<3,那么f(n)=n;如果n≥3,那么f(n)=f(n-1)+2f(n-2)+3f(n-3)。请写一个JavaScript函数,它通过一个递归计算过程计算f。再写一个函数,通过迭代计算过程计算f。
练习1.12 下面的数值模式称为帕斯卡三角形:
![](https://epubservercos.yuewen.com/1E97F9/29686461304596906/epubprivate/OEBPS/Images/49_03.jpg?sign=1739673666-tBCKwf9yZhTYobiPMxnOk9JdJlihgRe0-0-6127d423caed9db6961f1f213fe92510)
三角形两个斜边上的数都是1,内部的每个数是位于它上面的两个数之和[33]。请写一个函数,它通过一个递归计算过程计算帕斯卡三角形。
练习1.13 证明Fib(n)是最接近的整数,其中
。提示:利用归纳法和斐波那契数的定义(见1.2.2节),证明
,其中
。