在C++语言中,递归调用(recursive call)就是某一方法调用自身。这种自我调用通常是直接的,即在函数体中包含一条或多条调用自身的语句。递归也可能以间接的形式出现,即某个方法首先调用其它方法,再辗转通过其它方法的相互调用,最终调用起始的方法自身。
递归的价值在于,许多应用问题都可简洁而准确地描述为递归形式。以操作系统为例,多数文件系统的目录结构都是递归定义的。具体地,每个文件系统都有一个最顶层的目录,其中可以包含若干文件和下一层的子目录;而在每一子目录中,也同样可能包含若干文件和再下一层的子目录;如此递推,直至不含任何下层的子目录。通过如此的递归定义,文件系统中的目录就可以根据实际应用的需要嵌套任意多层(只要系统的存储资源足以支持)。
以下将从递归的基本模式入手,循序渐进地介绍如何选择和应用(线性递归、二分递归和多分支递归等)不同的递归形式,以实现(遍历、分治等)算法策略,以及如何利用递归跟踪和递推方程等方法分析递归算法的复杂度。
一、线性递归
数组求和问题:
1 sum(int A[],int n) //传入的参数是数组长度
2 {
3 if (n < 1) //平凡情况:递归基
4 return 0; //直接计算(非递归式)
5 else //一般情况
6 return sum(A,n - 1) + A[n-1] ; //递归:前n-1项之和,再累计第n-1项
7 } //O(1)*递归深度 = O(1)*(n+1) = O(n)
由此实例,可以看出保证递归算法有穷性的基本技巧:首先判断并处理n = 0之类的平凡情况,以免因无限递归而导致系统溢出。这类平凡情况统称“递归基”(base case of recursion)。平凡情况可能有多种,但至少要有一种(比如此处),且迟早必然会出现。
线性递归:
算法sum()可能朝着更深一层进行自我调用,且每一递归实例对自身的调用至多一次。于是,每一层次上至多只有一个实例,且它们构成一个线性的次序关系。此类递归模式因而称作“线性递归”(linear recursion),它也是递归的最基本形式。这种形式中,应用问题总可分解为两个独立的子问题:其一对应于单独的某个元素,故可直接求解(比如A[n - 1]);另一个对应于剩余部分,且其结构与原问题相同(比如A[0, n - 1))。另外,子问题的解经简单的合并(比如整数相加)之后,即可得到原问题的解。
线性递归的模式,往往对应于所谓减而治之(decrease-and-conquer)的算法策略:递归每深入一层,待求解问题的规模都缩减一个常数,直至最终蜕化为平凡的小(简单)问题。按照减而治之策略,此处随着递归的深入,调用参数将单调地线性递减。因此无论最初输入的n有多大,递归调用的总次数都是有限的,故算法的执行迟早会终止,即满足有穷性。当抵达递归基时,算法将执行非递归的计算(这里是返回0)。
递归算法时间和空间复杂度的分析与常规算法很不一样,有其自身的规律和特定的技巧,以下介绍递归跟踪与递推方程这两种主要的方法。
递归跟踪(recursion trace) 可以用来分析递归算法的总体运行时间和空间。就是按照如下原则,将递归算法的执行过程整理成图的形式:
- 算法的每个实例都表示为一个方框,其中注明了该实例的调用参数。
- 若实例M调用实例N,则在M与N对应的方框之间添加一条有向连接线。
该图清晰地给出了算法执行的整个过程:首先对参数n进行调用,再转向对参数n - 1的调用,再转向对参数n - 2的调用,...,直至最终的参数0。在抵达递归基后不再递归,而是将平凡的解(长度为0数组的总和0)返回给对参数1的调用;累加上A[0]之后,再返回给对参数2的调用;累加上A[1]之后,继续返回给对参数3的调用;...;如此依次返回,直到最终返回给对参数n的调用,此时,只需累加A[n - 1]即得到整个数组的总和。
从上图可以看出,整个算法所需的计算时间应该等于所有递归实例的创建、执行和销毁所需的时间总和。递归实例的创建、销毁均由操作系统负责完成,其对应的时间成本通常可以近似为常数,不会超过递归实例中实质计算步骤所需的时间成本,故往往均予忽略。为便于估算,启动各实例的每一条递归调用语句所需的时间,也可以计入被创建的递归实例的账上,如此我们只需统计各递归实例中非递归调用部分所需的时间。
此我们只需统计各递归实例中非递归调用部分所需的时间。具体地,就以上的sum()算法而言,每一递归实例中非递归部分所涉及的计算无非三类(判
断n是否为0、累加sum(n - 1)与A[n - 1]、返回当前总和),且至多各执行一次。鉴于它们均属于基本操作,每个递归实例实际所需的计算时间都应为常数O(3)。由图1.6还可以看出,对于长度为n的输入数组,递归深度应为n + 1,故整个sum()算法的运行时间为:(n + 1) * O(3) = O(n)
在创建了最后一个递归实例(即到达递归基)时,占用的空间量达到最大,准确地说,等于所有递归实例各自所占空间量的总和。这里每一递归实例所需存放的数据,无非是调用参数(数组A的起始地址和长度n)以及用于累加总和的临时变量。这些数据各自只需常数规模的空间,其总量也应为常数。故此可知,sum()算法的空间复杂度线性正比于其递归的深度,亦即O(n)。
递推方程:
将该算法处理长度为n的数组所需的时间成本记作T(n)。求解sum(A, n)所需的时间,应该等于求解sum(A, n - 1)所需的时间,另加一次整数加法运算所需的时间。
根据以上分析,可以得到关于T(n)的如下一般性的递推关系:
T(n) = T(n - 1) + O(1) = T(n - 1) + c1, 其中c1为常数
另一方面,当递归过程抵达递归基时,求解平凡问题sum(A, 0)只需(用于直接返回0的)常数时间。如此,即可获得如下边界条件:
T(0) = O(1) = c2, 其中c2为常数
联立以上两个方程,最终可以解得:
T(n) = c1n + c2 = O(n)
这一结论与递归跟踪分析殊途同归。另外,运用以上方法,同样也可以界定sum()算法的空间复杂度(习题[1-18])。
二、递归模式:
为保证有穷性,递归算法都必须设置递归基,且确保总能执行。为此,针对每一类可能出现的平凡情况,都需设置对应的递归基,故同一算法的递归基可能(显式或隐式地)不止一个。
以下考查数组倒置问题,也就是将数组中各元素的次序前后翻转。
将数组A[ ] = { 3, 1, 4, 1, 5, 9, 2, 6 }倒置为:A[ ] = { 6, 2, 9, 5, 1, 4, 1, 3 }
为得到整个数组的倒置,可以先对换其首、末元素,然后递归地倒置除这两个元素以外的部分。按照这一思路,可实现如 :
1 void reverse(int *A,int lo,int hi){
2 if(lo < hi){
3 swap(A[lo],A[hi]); //交换A[lo]和A[hi]
4 reservse(A,lo+1,hi - 1); //递归倒置A[lo,hi]
5 }//else 隐含了两种递归基
6 };
三、递归消除
首先,从递归跟踪分析的角度不难看出,递归算法所消耗的空间量主要取决于递归深度(习题[1-17]),故较之同一算法的迭代版,递归版往往需耗费更多空间,并进而影响实际的运行速度。另外,就操作系统而言,为实现递归调用需要花费大量额外的时间以创建、维护和销毁各递归实例,这些也会令计算的负担雪上加霜。有鉴于此,在对运行速度要求极高、存储空间需精打细算的场合,往往应将递归算法改写成等价的非递归版本。
一般的转换思路,无非是利用栈结构(第4章)模拟操作系统的工作过程。这类的通用方法已超出本书的范围,以下仅针对一种简单而常见的情况,略作介绍。
尾递归及其消除:
在线性递归算法中,若递归调用在递归实例中恰好以最后一步操作的形式出现,则称作尾递归(tail recursion)。比如代码1.7中reverse(A, lo, hi)算法的最后一步操作,是对去除了首、末元素之后总长缩减两个单元的子数组进行递归倒置,即属于典型的尾递归。实际上,属于尾递归形式的算法,均可以简捷地转换为等效的迭代版本。
首先在起始位置添加一个跳转标志:然后将尾递归语句调用替换为一个指向next标志的跳转语句。
1 void reverse(int *A,int lo,int hi){ 2 next: 3 if(lo < hi) { 4 swap(A[lo] , A[hi]); //交换A[lo]和A[hi] 5 lo++; hi--; //收缩待倒置区间 6 goto next; //跳转到算法体的起始位置,迭代的倒置A(lo,hi) 7 } //else 隐含了迭代的终止 8 } //O(hi - lo + 1)
用如下算法替代:
vod reverse(int *A,int lo,int hi) { while(lo < hi) //用while替代if和next跳转标志,完全等效 swap(A[lo++],A[hi--]); //交换A[lo]和A[hi] } //O(hi-lo+1)
出现在尾部的语句并不一定是尾递归语句,只有当该算法(除了平凡递归以外),都终止于这一递归调用时,才属于尾递归。
四、斐波那契(Fibonacci)数:二分递归
考察Fibonacci数列的第n项fib(n)的计算问题,该数列递归形式的定义如下:
为消除递归算法中重复的递归实例,一种自然而然的思路和技巧,可以概括为:借助一定量的辅助空间,在各子问题求解之后,及时记录下其对应的解答。可以从原问题出发自顶而下,每当遇到一个子问题,都首先查验它是否已经计算过,以期通过直接调阅记录获得解答,从而避免重新计算。也可以从递归基出发,自底而上递推地得出各子问题的解,直至最终原问题的解。前者即所谓的制表(tabulation)或记忆(memoization)策略,后者即所谓的动态规划(dynamic programming)策略。
反观以上线性递归版fib()算法可见,其中所记录的每一个子问题的解答,只会用到一次。在该算法抵达递归基之后的逐层返回过程中,每向上返回一层,以下各层的解答均不必继续保留。若将以上逐层返回的过程,等效地视作从递归基出发,按规模自小而大求解各子问题的过程,即可采用动态规划的策略,将以上算法进一步改写为如代码1.14所示的迭代版。
_int64 fibI(int n) { //计算Fibonacci数列的第n项:O(n) _int 64 f = 0,g = 1; //初始化:fib(0) = 0, fib(1) = 1 while(0 < n--) { g += f;f = g -f ;} //依据原始定义,通过n次减法和加法计算fib(n) return f }