八、递归消除
按照递归的思想可使我们得以从宏观上理解和把握应用问题的实质
深入挖掘和洞悉算法过程的主要矛盾和一般性模式
并最终设计和编写出简洁优美且精确紧凑的算法
然而,递归模式并非十全十美,其众多优点的背后也隐含着某些代价
(1)空间成本
首先,从递归跟踪分析的角度不难看出,递归算法所消耗的空间量主要取决于递归深度
故较之同一算法的迭代版,递归版往往需耗费更多空间,并进而影响实际的运行速度
另外,就操作系统而言,为实现递归调用需要花费大量额外的时间以创建、维护和销毁各递归实例,这些也会令计算的负担雪上加霜
有鉴于此,在对运行速度要求极高、存储空间需精打细算的场合,往往应将递归算法改写成等价的非递归版本
(2)尾递归及其消除
在线性递归算法中,若递归调用在递归实例中恰好以最后一步操作的形式出现,则称作尾递归(tail recursion)
比如代码Reverse(num, low, high)算法的最后一步操作,是对去除了首、末元素之后总长缩减两个单元的子数组进行递归倒置,即属于典型的尾递归
实际上,属于尾递归形式的算法,均可以简捷地转换为等效的迭代版本
void Reverse(int* num, int low, int high) { while (low < high) { swap(num[low++], num[high--]); } }
请注意,尾递归的判断应依据对算法实际执行过程的分析,而不仅仅是算法外在的语法形式
比如,递归语句出现在代码体的最后一行,并不见得就是递归
严格的说,只有当该算法(除平凡递归基外)任一实例都终止于这一递归调用时,才属于尾递归
以线性递归版Sum()算法为例,尽管从表面看似乎最后一行是递归调用,但实际上却并非尾递归----实质的最后一次操作是加法运算
有趣的是,此类算法的非递归化转换方法仍与尾递归如出一辙
九、二分递归
(1)分而治之
面对输入规模庞大的应用问题,每每感慨于头绪纷杂而无从下手的你,不妨从先哲孙子的名言中获得灵感----“凡治众如治寡,分数是也”
是的,解决此类问题的有效方法之一,就是将其分解为若干规模更小的子问题,再通过递归机制分别求解
这种分解持续进行,直到子问题规模缩减至平凡情况
这也就是所谓的分而治之(divide - and - conquer)策略
与减而治之策略一样,这里也要求对原问题重新表述,以保证子问题与原问题在接口形式上的一致
既然每一递归实例都可以做多次递归,故称作“多路递归”(multi - way - recursion)
通常都是将原问题一分为二,故称作”二分递归“(binary recursion)
需强调的是,无论是分解为两个还是更大常数个子问题,对算法总体的渐进复杂度并无实质影响
(2)数组求和
以下就采用分而治之的策略,按照二分递归的模式再次解决数组求和问题
新算法的思路是:
以居中的元素为界将数组一分为二,递归地对子数组分别求和,最后,子数组之和相加即为原数组的总和
int Sum(int num[], int low, int high) //数组求和算法(二分递归版) { if (low == high) { return num[low]; //如遇递归基(区间长度已降至1),则直接返回该元素 } else { //否则(一般情况下low < high),则 int mid = (low + high) >> 1; //以居中单元为界,将原区间一分为二 return Sum(num, low, mid) + Sum(num, mid+1, high); //递归对各子数组求和,然后合计 } } //O(high - low - 1),线性正比于区间的长度
为分析其复杂度,不妨只考查n = 2^m形式的长度
算法启动后经连续m = log2n次递归调用,数组区间的长度从最初的n首次缩减至1,并达到第一个递归基
实际上,刚到达任一递归基时,已执行的递归调用总是比递归返回多m =log2n次
更一般地,到达区间长度为2^k的任一递归实例之前,已执行的递归调用总是比递归返回多m-k次
因此,递归深度(即任一时刻的活跃递归实例的总数)不会超过m+1
鉴于每个递归实例仅需常数空间,故除数组本身所占的空间,该算法只需要O(m + 1) = O(logn)的附加空间
线性递归版Sum()算法共需O(n)的附加空间,就这一点而言,新的二分递归版Sum()算法有很大改进
与线性递归版Sum()算法一样,此处每一递归实例中的非递归计算都只需要常数时间
递归实例共2n - 1个,故新算法的运行时间为O(2n - 1) = O(n),与线性递归版相同
此处每个递归实例可向下深入递归两次,故属于多路递归中的二分递归
二分递归与此前介绍的线性递归有很大区别
比如,在线性递归中整个计算过程仅出现一次递归基,而在二分递归过程中递归基的出现相当频繁,总体而言有超过半数的递归实例都是递归基
(3)效率
当然,并非所有问题都适宜于采用分治策略
实际上除了递归,此类算法的计算消耗主要来自两个方面
首先是子问题划分,即把原问题分解为形式相同、规模更小的多个子问题
其次是子解答合并,即由递归所得子问题的解,得到原问题的整体解
为使分治策略真正有效,不仅必须保证以上两方面的计算都能高效地实现,还必须保证子问题之间相互独立
----各子问题可独立求解,而无需借助其它子问题的原始数据或中间结果
否则,或者子问题之间必须传递数据,或者子问题之间需要相互调用,无论如何都会导致时间和空间复杂度的无谓增加
(4)Fibonacci数:二分递归
int Fibonacci(int n) //计算Fibonacci数列的第n项(二分递归版):O(2^n) { if (n < 2) { return n; //若达到递归基,直接取值 } else { return (Fibonacci(n-1) + Fibonacci(n-2)); //否则,递归计算前两项,其和即为正解 } }
基于Fibonacci数列原始定义的这一实现,不仅正确性一目了然,而且简洁自然
然而不幸的是,在这种场合采用二分递归的策略的效率极其低下
实际上,该算法需要运行O(2^n)时间才能计算出第n个Fibonacci数
这一指数复杂度的算法,在实际环境中毫无价值
算法的时间复杂度高达指数量级,究其原因在于,计算过程中所出现的递归实现的重复度极高
(5)优化策略
为消除递归算法中重复的递归实例,一种自然而然的思路和技巧,可以概括为:
借助一定量的辅助空间,在各子问题求解之后,及时记录下其对应的解答
比如,可以从原问题出发自顶而下,每遇到一个子问题,都首先查验它是否已经计算过,以期通过直接调阅记录解答,从而避免重新计算
也可以从递归基出发,自底而上递推地得出各子问题的解,直至最终原问题的解
前者即所谓的制表(tabulation)或记忆(memoization)策略
后者即所谓的动态规划(dynamic programming)策略
(6)Fibonacci数:线性递归
int pre; int Fibonacci(int n, int& pre) //计算Fibonacci数列的第n项(线性递归版) { if (n == 0) { //若到达递归基,则 pre = 1; //直接取值:Fibonacci(-1) = 1,Fibonacci(0) = 0 return 0; } else { //否则 int t = pre; pre = Fibonacci(n-1, t); //递归计算前两项 return (t + pre); //其和即为正解 } } //用辅助变量记录前一项 //Fibonacci(7, pre) = 13
请注意,原二分递归版本中对应于Fibonacci(n - 2)的另一次递归,在这里被省略掉了
其对应的解答,可借助形式参数的几只,通过pre“调阅”此前的记录直接获得
该算法呈线性递归模式,递归的深度线性正比于输入n,前后共计仅出现O(n)个递归实例,累计耗时不超过O(n)
该算法共需使用O(n)规模的附加空间
(7)Fibonacci数:迭代
反观以上线性递归版Fibonacci()算法可见,其中所记录的每一个子问题的解答,只会用到一次
在该算法抵达递归基之后的逐层返回过程中,每向上返回一层,以下各层的解答均不必继续保留
若将以上逐层返回的过程,等效地视作从递归基出发,按规模自小而大求解各子问题的过程,即可采用动态规划的策略
int Fibonacci(int n) //计算Fibonacci数列的第n项(迭代版):O(n) { int pre = 1, ret = 0; //初始化:Fibonacci(-1),Fibonacci(0) while (n > 0) { ret += pre; pre = ret - pre; --n; } return ret; }
这里仅使用了两个中间变量,记录当前的一对相邻Fibonacci数
整个算法仅需线性步的迭代,时间复杂度为O(n)
更重要的是,该版本仅需常熟规模的附加空间,空间效率也有了极大提高
(8)
void max2(int A[], int low, int high, int& x1, int& x2) //递归+分治 { if (low+2 == high) { x1 = low; x2 = low+1; if (A[x1] < A[x2]) { swap(x1, x2); } if (A[x2] < A[high]) { x2 = high; if (A[x2] > A[x1]) { swap(x2, x1); } } return; } else if (low+3 == high) { x1 = low; x2 = low+1; if (A[x1] < A[x2]) { swap(x1, x2); } for (int i=low+2; i<=high; ++i) { if (A[i] > A[x2]) { x2 = i; if (A[x2] > A[x1]) { swap(x1, x2); } } } return; } int mid = (low + high) >> 1; int x1L, x2L; max2(A, low, mid, x1L, x2L); int x1R, x2R; max2(A, mid, high, x1R, x2R); if (A[x1L] > A[x1R]) { x1 = x1L; x2 = (A[x2L] > A[x1R]) ? x2L : x1R; } else { x1 = x1R; x2 = (A[x2R] > A[x1L]) ? x2R : x1L; } }
选自:
《数据结构(C++语言版)(第三版)》邓俊辉
略有改动