Loading

递归介绍

   在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  
}

 

  

  

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

posted @ 2019-08-26 10:05  三只猫-  阅读(639)  评论(0编辑  收藏  举报