• 博客园logo
  • 会员
  • 周边
  • 新闻
  • 博问
  • 闪存
  • 众包
  • 赞助商
  • Chat2DB
    • 搜索
      所有博客
    • 搜索
      当前博客
  • 写随笔 我的博客 短消息 简洁模式
    用户头像
    我的博客 我的园子 账号设置 会员中心 简洁模式 ... 退出登录
    注册 登录
IcoveJ
博客园    首页    新随笔    联系   管理    订阅  订阅

关于递归

允许程序中的函数或过程自我调用。以C++为例,递归调用就是指某一方法调用自身。这种调用可以是直接的,在函数体中包含一条或多条调用自身的语句;也可以是间接的,某个方法首先调用其他方法,再通过其他方法相互调用,最终调用自己本身。

使用递归,一定要注意stack overflow。

1.1 线性递归

”数组求和“为例:

int sum(int A[], int n){
    if(n < 1) return 0;
    else
        return sum(A, n-1) + A[n-1];
}

1.1.1 递归基

对于任何一个递归,都要有一个递归基,以避免因为无限递归而导致系统溢出。比如上例中的if(n<1) return 0;

什么是递归基呢?就是在实现递归之前,先对平凡情况,比如n=0,做非递归处理。递归基可能覆盖多种平凡情况,但至少要有一种。

一般情况下而言,都是一些特殊情况或者某个变量的特殊取值。

1.1.2 线性递归

在上述的例子中,我们可以发现在每一个sum()函数的实例中对自身只调用了一次。于是对这种递归模式称作“线性递归”,即在每一个层次上至多只有一个实例,且它们构成一个线性的次序关系。

实现线性递归必须满足两个条件:

①应用问题可分为两个子问题:一个对应于待处理数据中的某一特定元素,比如A[n-1];另一个问题对应于剩余部分,其结构与原问题雷同,如sum(A, n-1)。

②由两个子问题的解可以快速合并得到原问题的解。

1.1.3 减而知之

什么是减而知之的算法策略:递归每深入一层,待求解问题的规模都将缩减一个常数,直至最后蜕化为一个很平凡的小问题。

因此,每次递归调用所采用的参数都会小于前一次,而且它是单调的线性递减。所以,无论设置的n有多大,递归调用的总次数总是有限的,算法的执行迟早会终止,既满足了有穷性。当抵达递归基的时候,算法就会执行非递归的计算。

1.2 递归分析

怎么计算递归算法的时间和空间复杂度呢?

1.2.1 递归跟踪

采用递归跟踪来分析:①表示出算法的每一递归实例;②若实例M调用实例N,则在M与N之间添加向联线,指示二者间的调用与被调用关系。

对于上例,首先对参数n进行调用,再转向对参数n-1的调用,再转向对参数n-2的调用,...,直至最终的参数0,抵达递归基后不再继续调用。此时就会将平凡解返回给对参数1的调用;累加上A[0]后,在返回给对参数2的调用;累加上A[1]后,在返回给对参数3的调用;...;直至最终的对参数n的调用。

于此可以看出,递归最终的实现,会有递归实例的创建、执行和销毁,而每一个递归实例至多执行一次“判断n是否为0、累加sum(n-1)与A[n-1]、返回当前的总和”的操作,就可以得到每一个递归实例所需的计算时间应为常熟o(3),从而得到整个递归所需的时间为o(n)。

并且在创建最后一个实例(即抵达递归基后),占用的空间达到最大。此时的空间总量等于所有递归实例各自占有空间量的总和。在这里每个递归实例所存储的数据无非是调用参数以及用于累加总和的临时变量,所以仅仅需要常数规模的空间,所以其空间复杂度也为o(n)。

1.2.2 递推方程

递推方程没有了递归跟踪的一步步循迹,而是通过对递归模式的数学归纳,导出关于复杂度定界函数的递推方程(组)及其边界问题,从而将转化为递归方程(组)求解的问题。

还是上例,为了解决sum(A,n),就只需解决sum(A,n-1),再累加上A[n-1]。所以,设所需总时间为T(n),那么

T(n) = T(n-1) + o(1) = T(n-1) + c1 ,其中c1为常数。

抵达到递归基时,T(0) = o(1) = c2 ,其中c2为常数。

通过递归求解,得到T(n) = c1n + c2。

空间复杂度的计算也是同样的:S(1) = o(1),S(n) = S(n - 1) + o(1)...就得到S(n) = o(n)。

1.3 递归模式

1.3.1 多递归基

递归算法必须设有递归基,必须保证有穷性,且确保对应的语句能够被执行到。

但要注意,对于任何一种平凡情况,都要设置对应的递归基,所以同一算法的递归基不止一个。

void reverse(int* A, int lo, int hi){
    if(lo < hi){
        swap(A[lo], A[hi]);
        reverse(A, lo + 1, hi - 1);
    }
}

这里没有写出else,是因为有两种递归基,都隐式处理了。

两种隐式递归基:原数组长度为奇数时,lo = hi;原数组长度为偶数时,lo = hi + 1。reverse()算法都会终止与这两种平凡情况之一,所以说,它的递归深度就是(n + 1)/2 = o(n);其时间复杂度也应该是o(n)(线性正比于递归深度)。

1.3.2 多向递归

不仅递归基可能有多个,递归调用也可能也可能有多种可供选择的分支。比如,幂函数的计算问题

设n的r次方,按照线性递归的构思,r = 0时,power_n(x) = 1;else power_n(x) = n * power_n(r - 1)。不难看出这种递归算法其时间复杂度和空间复杂度都是o(r)。

当n = 2时,我们就会有其他的递归算法,用位运算

int sqr(int a){return a * a;}
int power_2(int r){
    if(r == 0) return 1;//递归基
    else if(r & 1) return sqr(power_2(r >> 1)) << 1;//r为奇数
    else return sqr(power_2(r >> 1));//r为偶数
}

相比于不用递归的暴力求解:

int No_rec_power_2(int r){
    int pow = 1;
    while (0 < r--) pow <<= 1;
    return pow;
}

时间复杂度从o(2^r)到o(r),计算效率也是明显的提高。

1.4 递归消除

1.4.1 空间成本

从幂运算的两种算法,虽然从时间上而言,递归的效率的确很高,但是也会消耗更多的空间。当遇到在递归需要深入很多层时,空间复杂度更会急剧上升。所以递归并非最好,究竟选递归还是非递归,要视要求而定。

1.4.2 尾递归及其消除

尾递归不是单纯的尾调用,即不是单纯的递归调用的语句出现在代码体的最后一行。严格地说,判断是否是尾递归,要看函数运行的最后一步是不是调用了自身,并且在调用自身的时候不在需要上一个函数环境。例如:

//Fibonacci的尾递归版本
int fibonacci(int n, int ret1, int ret2){
    if(n == 1) return ret1;
    else
        return fibonacci(n - 1, ret2, ret1 + ret2);
}

//Fibonacci的线性递归
int fibonacci(int n){
    if(n == 0) return 0;
    else if(n == 1) return 1;
    else
        return fibonacci(n - 1) + fibonacci(n - 2);
}

尾递归有什么好处呢?它很好的融合了递归和循环的特点,既有递归运行快速的特点,又有循环占用内存小的特点。

比如我要计算n = 6的Fibonacci值,对于线性递归,算到最后内存中需要存储6 + 5 + 4 + 3 + 2 + fibonacci(1),一共是六个数据;而尾递归则只有20 + fibonacci(1),只有两个数据。

这样做就很好地避免了使用线性递归过程中可能出现的由于深入层数过多而造成的栈溢出(stack overflow)。

1.5 分而治之

“分而治之”的策略主要是用于求解大型问题,将一个大型问题分解成多个小问题,然后利用递归的机制分别求解,即多路递归。例如:

//数组求和-二分递归
int sum(int A[], int lo, int hi){
    if(lo == hi) return A[0];
    else{
        int mi = (lo + hi) >> 1;//找到居中元素
        return sum(A, lo, hi) + sum(A, mi + 1, hi);
    }
}

设定lo = 0,hi = 7,第一次递归分为(0 ,3)(4 , 7),第二次递归分为(0, 1)(2, 3)(4, 5)(6, 7),第三次递归分为(0, 0)(1, 1)(2, 2)(3, 3)(4, 4)(5, 5)(6 , 6)(7, 7)。所以递归调用m = log(2)n,n为开始区间长度。每次递归,n就减半,直到到达递归基。

在线性递归中,递归及的使用只有最后一次,而在”分而治之“策略下的递归使用中,递归及的出现会很频繁。

但时间和空间复杂度呢?

//二分递归实现Fibonacci
int Fibonacci(int n){
    return
        (n < 2)?
        n
        :Fibonacci(n - 1) + Fibonacci(n - 2);
}

我们以二分递归下的Fibonacci数列为例:

在线性递归求Fibonacci数列时,其时间和空间复杂度都是o(n);在二分递归下,T(n) = 1 (n <= 1)或者T(n - 1) + T(n - 2) + 1。令S(n) = [T(n) + 1]/2,则简化为S(n) = 1 (n <= 1) 或者 S(n - 1) + S(n - 2)。通过计算就可以得到其时间复杂度为o(2^n),其原因是计过程中所出现的重复度极高:对任一整数1 <= k <= n,形如Fibonacci(k)的递归实例,在算法执行中都会先后重复出现Fibonacci(n - k -+1)次。

所以分而治之策略下的递归实现,无论如何,其时间和空间复杂度都会增加。

1.6 动态规划

从上述中我们会发现,Fibonacci数列的实现使用递归算法,至少都会使用o(n)的附加空间。基于此,采用动态规划,使用较小的辅助空间,在计算过程中记录下已处理过的子问题,从而通过查阅记录而获得结果而解决大问题。

int fib(int n) {
	int fib_1 = 0, fib_2 = 1;
	while (n-- > 1) {
		int temp = fib_2;
		fib_2 += fib_1;
		fib_1 = temp;
	}
	return fib_1;
}

此时,先行迭代的时间复杂度就是o(n)。

posted @ 2020-11-09 14:04  IcoveJ  阅读(179)  评论(0)    收藏  举报
刷新页面返回顶部
博客园  ©  2004-2025
浙公网安备 33010602011771号 浙ICP备2021040463号-3