在我原来的概念里,递归程序是简洁、巧妙、高效的代名词,但是读了《Data Structure And Algorithm Analysis in C》之后,我才发现原来自己之前的认识是错误的,至少递归程序不总是高效的代名词,如果使用不当,将会有很大的性能(效率)问题。

1. 两个例子

(1)使用递归实现阶乘:

long int Factorial(int N)
{
    if ( N <= 1 )
        return 1;
    else
        return N * Factorial(N-1);
}

(2)使用递归求斐波那契数列:

long int Fibonacci(int N)
{
    if ( N <= 1 )
        return 1;
    else
        return Fibonacci( N - 1 ) + Fibonacci( N - 2 );
}

分析:

这两个例子都是使用递归实现,代码也很相似,但是效果却是恰恰相反的。我们分别进行分析:

对于第一个例子,虽然使用了递归,但其实很容易就可以改成一个for循环,循环次数为N,所以第一个函数的复杂度(本文的复杂度都指时间复杂度)和N次for循环是一样的,为O(N)。证明如下:

设Factorial(N)的运行时间为P(N),则由递归可知一次Factorial(N)耗时为第3行的if判断(耗时为1)加上第6行的一次乘法(耗时为1),以及一次函数调用(耗时为P(N-1)),所以有:P(N)=1+1+P(N-1)=P(N-1)+2,注:计算复杂度是我们一般不关注常数,常数系数也不关注。很明显,这是一个等差数列,且P(1)=1,公差为2,所以P(N)=P(1)+(n-1)*2=O(n)。

而第二个例子,却是非常糟糕的。和上例相同,我们来计算一下Fibonacci(N)的复杂度:

设Fibonacci(N)运行时间为T(N),则T(N)=T(N-1)+T(N-2)+2,“2”指的是if判断和后面的一次加法。而Fibonacci(N)=Fibonacci(N-1)+Fibonacci(N-2),因此由归纳法可以证明T(N)≥Fibonacci(N)。而当N>4时,有Fibonacci(N)≥(3/2)N(后面证明)。所以,可以看出这个函数的运行时间以指数的速度增长。但是如果我们使用数组来实现该函数,则其复杂度为O(N)。

为什么第二个递归程序的效率这么差呢?因为它做了很多多余的工作。比如在计算Fibonacci(N-1)的时候实际上已经计算了Fibonacci(N-2),而后面又再计算了一次Fibonacci(N-2).这样递归下来,做这些多余工作所耗费的时间便变得非常的大。这在递归程序中是非常忌讳的。

2. 编写递归程序的四个基本法则

编写递归程序时只要遵守一些规则,便可以很好的使用递归有利的一面。下面列出四个基本法则:

  • 基准情形(base case)——必须总有某些基准情形,它无须递归就能解出。比如上面的两个例子,当N ≤ 1时,无须递归便可计算出结果,这便是它们的基准情形。如果没有这个基准情形,则递归是毫无意义的。
  • 不断推进(making progress)——对于那些需要递归求解的情形,每一次递归调用都必须要使求解状况朝接近基准情形的方向推进。
  • 设计法则(design rule)——假设所有的递归调用都能运行。
  • 合成效益法则(compound interest rule)——在求解一个问题的同一实例时,切勿在不同的递归调用中做重复性的工作。上面第二个例子便是一个很好的证明。

3. 递归与循环逻辑

很多时候,递归都可以被循环替代,但迭代使用的并不是循环逻辑(circular logic):

  • 递归逻辑——用一个函数本身去定义这个函数
  • 循环逻辑——用函数本身去定义该函数一个特定的实例

举例来说,使用Factorial(N)来得到Factorial(N)的值是循环逻辑,但通过Factorial(N-1)来的得到Factorial(N)的值不是循环逻辑,除非Factorial(N-1)又依赖于Factorial(N)。

:循环和循环逻辑不是一个概念,一个循环可能使用的也是迭代逻辑。

4. 函数调用

这里要介绍函数调用是因为递归调用其实也是一种函数调用,没有什么特殊之处。当调用一个新的函数时,主调例程的所有局部变量需要由系统存储起来,否则被调用的新函数将会覆盖调用例程的变量。不仅如此,该主调例程的当前位置也要存储,以便新函数运行完以后知道向哪里转移。这些变量一般由编译器指派给机器的寄存器,但存在某些冲突(通常所有的过程都将某些变量指派给1#寄存器),特别是涉及递归的时候。

当存在函数调用的时候,需要存储所有重要的信息,诸如寄存器的值(对应变量的名字)和返回地址(它可从程序计数器PC得到,典型情况下计数器就是一个寄存器)等,都要以抽象的方式存在“一张纸上”并被置于一个堆(pile)的顶部。然后控制转移到新函数,该函数自由的用它的一些值代替这些寄存器。如果它又进行其他的函数调用,那么它也遵循相同的过程。当该函数返回时,它查看堆(pile)顶部的那张“纸”并复原所有的寄存器。然后它进行返回转移。而所有这些工作均是由栈来完成的。所存储的信息称为活动记录(activation record)或者栈帧(stack frame)。如果同时运行的函数太多或者某个函数使用的栈帧特别多,那将有可能用尽栈空间,从而导致程序崩溃。而且很多系统是不检测栈溢出的。

这里我们看另外一种糟糕的递归:

void PrintList(List L)
{
    if ( L != NULL)
    {
        PrintElement( L->Element );
        PrintList( L->next );
    }
}

这个程序递归的遍历一个链表,程序完全合法且表面上也是无害的,符合前面介绍的四个基本规则。但它却是非常糟糕的一种实现,因为当链表的信息非常多时,在递归调用时我们很可能会将栈空间耗尽。

5. Fibonacci(N)≥(3/2)N(N>4)的证明

这里我们使用归纳法证明该命题,证明之前先回忆一下归纳法的标准步骤:

第一步:证明基准情形(base case),就是确定定理对某个(某些)小的(通常是退化的)值的正确性。这一步一般都是很简单的。

第二步:进行归纳假设(inductive hypothesis),就是假设定理对直到某个有限数k的所有情况都是成立的。然后使用这个假设证明定理对下一个值(通常是k+1)也是成立的。

经过上面两步,便可证明定理是成立的。下面附上证明过程:

formula1

可见,递归是一把双刃剑,我们必须合理使用。最后再次声明一下,递归并不是循环的一种替代品,尽管很多时候二者可以相互转化。