递归与迭代_2 2016.4.22

来源:互联网 发布:8080端口怎么开 编辑:程序博客网 时间:2024/06/06 16:48

八、递归消除

按照递归的思想可使我们得以从宏观上理解和把握应用问题的实质

深入挖掘和洞悉算法过程的主要矛盾和一般性模式

并最终设计和编写出简洁优美且精确紧凑的算法


然而,递归模式并非十全十美,其众多优点的背后也隐含着某些代价


(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++语言版)(第三版)》邓俊辉
略有改动


0 0
原创粉丝点击