递归与迭代_2 2016.4.22

八、递归消除

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

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

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

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

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

略有改动

时间: 2024-10-10 22:57:29

递归与迭代_2 2016.4.22的相关文章

递归与迭代_1 2016.4.21

迭代乃人工,递归方神通 To interate is human,to recurse,divine 一.定义 (1) 迭代 是重复反馈过程的活动,其目的通常是为了逼近所需目标或结果 每一次对过程的重复称为一次"迭代",而每一次迭代得到的结果会作为下一次迭代的初始值 (2) ① 程序调用自身的编程技巧称为递归( recursion) 一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量

[官方软件] Easy Sysprep v4.3.29.602 【系统封装部署利器】(2016.01.22)--skyfree大神

[官方软件] Easy Sysprep v4.3.29.602 [系统封装部署利器](2016.01.22) Skyfree 发表于 2016-1-22 13:55:55 https://www.itsk.com/forum.php?mod=viewthread&tid=362766&highlight=Easy%2BSysprep [官方软件] Easy Sysprep v4.3.29.602 [系统封装部署利器](2016.01.22) [Easy Sysprep]概述:Easy Sy

“耐撕”团队 2016.3.22 站立会议

时间:2016.03.22 ① :18:00--18:25   ②18:40--19:00   总计45分钟. 成员: Z 郑蕊 * 组长 (博客:http://www.cnblogs.com/zhengrui0452/), P 濮成林(博客:http://www.cnblogs.com/charliePU/), Q 齐嘉亮(博客:http://www.cnblogs.com/dendroaspis-polylepis/), L  刘伟硕(博客:http://www.cnblogs.com/We

递归和迭代两种方式实现归并排序(Java版)

递归版 package MergeSort; import Utils.SortUtils; /** * 归并排序递归版 * @author liguodong */ public class Demo02 { public static void mergeSort(int[] a){ mSort(a, a, 0, a.length-1); } /** * * @param SR为待排序的数据 * @param TR1为排序之后的数据 * @param s * @param t */ publ

递归和迭代(Recursion and Iteration)

递归 特点:简而言之,递归就是应用程序调用自身.所以,存在预期收敛,才能使用递归(因为不能无限期递归调用下去). 优点:程序看着比较简单,比较容易实现. 缺点:递归要占用额外的栈空间,如果递归的深度比较大,那么占用的栈比较多,而且调用函数的时间也比较多,时空性都不好. 所以选择递归要考虑好处和缺点之间的权衡. 迭代 特点:通过步号寻找需要的信息,经典的例子比如C++中的for循环语句(迭代遍历程序). 优点:开销只因循环的增加而相应增加,没有额外的空间开销和时间开销. 缺点:编写复杂问题时可能程

递归与迭代【转】

1 递归的基本概念:程序调用自身的编程技巧称为递归,是函数自己调用自己. 一个函数在其定义中直接或间接调用自身的一种方法,它通常把一个大型的复杂的问题转化为一个与原问题相似的规模较小的问题来解决,可以极大的减少代码量.递归的能力在于用有限的语句来定义对象的无限集合. 1.1 使用递归要注意的有两点: 1)递归就是在过程或函数里面调用自身: 2)在使用递归时,必须有一个明确的递归结束条件,称为递归出口. 1.2 递归分为两个阶段: 1)递推:把复杂的问题的求解推到比原问题简单一些的问题的求解: 2

剑指offer (9) 递归和迭代 斐波那契数列

通常基于递归实现的代码比基于循环实现的代码要简洁很多 比如 二叉树遍历以及 二叉树的许多操作 递归由于是函数调用自身,每一次函数调用,都需要在内存栈中分配空间以保存参数.返回地址以及临时变量 而每个进程的栈容量是有限的,当递归调用的层级太多时,就会导致 调用栈溢出 递归有时伴随大量重复的计算, 二叉树遍历的递归操作不存在重复计算,因为每个结点的左右子树是严格区分开的 例如求解 斐波那契数列: 解题分析 int fib(int n) { assert(n >= 0); int prevTwo =

【万字博文】分析与设计:插入排序和分治算法、递归和迭代的探讨

插入排序及其解决思路 算法的作用自然不用多说,无论是在校学生,还是已经工作多年,只要想在计算机这条道路走得更远,算法都是必不可少的. 就像编程语言中的"Hello World!"程序一般,学习算法一开始学的便是排序算法.排序问题在日常生活中也是很常见的,说得专业点: 输入是:n个数的一个序列<a1,a2,...,an?1,an> 输出是:这n个数的一个全新的序列<a,1,a,2,...,a,n?1,a,n>,其特征是a,1≤a,2≤...≤a,n?1≤a,n 举

递归与迭代

头文件 #include <stdlib.h> #include <stdio.h> #include <string.h> #pragma once 代码文件 #include "myH.h" //深度理解递归与迭代方法 //递归与迭代方法的区别: //递归使用函数和条件语句(if和else语句) //迭代法使用循环语句(for和while语句) /**************************************************