递归、尾递归与迭代

很久以前写过一篇《递归与迭代》,写得不是很好。可能是我自己也没把思路理清楚,现在就有了个重新整理思路炒冷饭的机会,也算是一个新的开始吧。

首先解释一个术语叫“尾调用”。直接从wiki的“尾调用”条目抄:尾调用是指一个函数里的最后一个动作是一个函数调用的情形:即这个调用的返回值直接被当前函数返回的情形。这种情形下称该调用位置为尾位置。若这个函数在尾位置调用本身(或是一个尾调用本身的其他函数等等),则称这种情况为尾递归,是递归的一种特殊情形。

比如,下面这个是尾调用:

return f();

而这个不是:

return 1 + f();

关于尾调用,有个重要的优化叫做尾调用消除。经过尾调用消除优化后的程序在执行尾调用时不会导致调用栈的增长。其实,这个优化可能不算是优化。因为在某些计算模型中,尾调用天然不会导致调用栈的增长。在EOPL提到过一个原理:

导致调用栈增长的,是对参数的计算,而不是函数调用。

由于尾调用不会导致调用栈的增长,所以尾调用事实上不像函数调用,而更接近GOTO。尾递归则等同于迭代,只是写法上的不同罢了。

下面以计算幂为例,分别用递归、尾递归和迭代三种方法实现程序。其中尾递归方法是递归方法的改进,而迭代方法纯粹是从尾递归方法翻译而来。

问题

已知数(整数,或浮点数,这里假设是整数)\(b\) 和非负整数\(n\) ,求\(b\)的\(n\)次幂\(b^n\)。

一个简单的解法是循环\(n\)次,不断的乘\(b\),得到答案。这个方法是低效的。虽然它的时间复杂度是\(O(n)\) ,但这是个相对于输入规模为指数级的算法。

一个启发是,当\(n\)为\(2\)的幂时,假设\(n=2^k\)。那么将\(b\)平方\(k\)次,就能得到\(b^n\),时间复杂度是\(O(log n)\),是线性算法。

将这个启发推广到\(n\)不是\(2\)的幂的情况,我们就得到了这个问题的一个算法(貌似叫“模重复平方算法”)。

递归方法

递归方法实际上是数学归纳法。基础情况:当\(n=0\)时,\(b^0=1\)。假设我们已经知道对于所有\(k

如果\(n\)是偶数,则\(b^n=(b*b)^{n/2}\);

如果\(n\)是奇数,则\(b^n=b*b^{n-1}\)。

代码是这样:

int expt0(int b, int n) {
	// assert(n >= 0)
	if (n == 0) {
		return 1;
	}
	if (n % 2) {  // n is odd
		return b * expt0(b, n - 1);
	} else {  // n is even
		return expt0(b * b, n / 2);
	}
}

递归方法有一个不好,那就是会导致调用栈增加。在这个例子中,计算\(b^n\)将占用额外\(O(log n)\)的空间。

尾递归方法

使用尾递归,需要对递归方法做一点改动。引入一个新变量\(a\),\(a\)的初始值是\(1\)。\(a\)的用处是保存计算结果。另外再引入一个计算过程\(S(a, b, n)=a*b^n\),根据下面的描述计算\(S(a, b, n)\):

如果\(n=0\),\(S(a, b, n)=a*b^0=a\);

如果\(n\)是偶数,\(S(a, b, n)=a*b^n=a*(b*b)^{n/2}=S(a, b*b, n/2)\);

如果\(n\)是奇数,\(S(a, b, n)=a*b^n=(a*b)*(b^{n-1})=S(a*b, b, n-1)\)。

这个\(S\)我们称为循环不变量,因为在整个计算过程中,S的返回值都是不变的。很多尾递归/迭代算法的设计关键就在于循环不变量的构造。

根据\(S\)的不变性质我们可以证明这个算法的正确性:在开始时\(a = 1\),有\(S(a, b, n) = b^n\);结束时\(n = 0\),有\(S(a, b, n) = a\)。所以结束时的\(a\)等于开始时的\(b^n\)。

我们定义一个计算\(S\)的函数:

int expt_iter(int a, int b, int n) {
	if (n == 0) {
		return a;
	}
	if (n % 2) {  // n is odd
		return expt_iter(a * b, b, n - 1);
	} else {  // n is even
		return expt_iter(a, b * b, n / 2);
	}
}

计算\(b^n\)的函数就是计算\(S(1, b, n)\):

int expt1(int b, int n) {
	// assert(n >= 0)
	return expt_iter(1, b, n);
}

迭代方法

最后是迭代方法。迭代方法很自然的从尾递归方法翻译过来。从另一个角度看\(S\)的计算过程:

如果\(n=0\),循环结束,返回a;

如果\(n\)是偶数,\(S(a, b, n)=S(a, b*b, n/2)\),相当于\(b \leftarrow b*b, n \leftarrow n/2\);

如果\(n\)是奇数,\(S(a, b, n)=S(a*b, b, n-1)\),相当于\(a \leftarrow a*b, n \leftarrow n-1\)。

代码如下:

int expt2(int b, int n) {
	// assert(n >= 0)
	int a = 1;
	while (n != 0) {
		if (n % 2) {  // n is odd
			a *= b;
			--n;
		} else {  // n is even
			b *= b;
			n /= 2;
		}
	}
	return a;
}

我第一次见到这段代码是在数论课上。我当时惊叹这段代码的简洁。后来才知道,代码是可以推理出来的。而不是拍脑子想出来的。

一般称尾递归方式的代码为函数式编程。迭代方法的代码为命令式编程。这两种方法虽然本质相同。但代码的可读性却大相径庭。

最后讲讲尾调用消除的重要性吧

我曾经说,任何一个有理智的编译器/解释器都会实现尾调用消除。然后我被打脸了。我发现,CPython解释器就没支持,而且以后也不会支持尾调用消除。

Python的作者Guido专门写了两篇文章来说明为什么不给Python支持尾调用消除:

http://neopythonic.blogspot.com.au/2009/04/tail-recursion-elimination.html

http://neopythonic.blogspot.com.au/2009/04/final-words-on-tail-calls.html

既然迭代方法可以直接从尾递归方法翻译,为什么还需要有尾调用消除呢?原因大概有几个:1、某些函数式编程语言没有循环结构;2、像SICP第四章写的那种元求值器,虽然大部分是尾调用,但是没法手工翻译;3、为什么机器能自动翻译的东西非要人去手工翻译,很容易出错的。

再举个例子吧。

求最大公约数,用辗转相除法可以快速求出。尾递归代码可以很快写出:

def gcd0(a, b):
    if a == 0:
        return b
    else:
        return gcd0(b % a, a)

接下来手工翻译。

首先是很容易翻译出错的一种情况:

def gcd1_wrong(a, b):
    while a != 0:
        a = b % a
        b = a
    return b

由于在迭代方法中,我们需要对循环的状态量赋值,赋值的引入导致表达式的计算结果依赖于执行顺序。在尾递归的方法中,\(a \leftarrow b%a\)和 $b \leftarrow a\)是同时执行的,而在迭代方法中,必须引入额外的变量tmp来实现这个赋值:

def gcd1(a, b):
    while a != 0:
        tmp = a
        a = b % a
        b = tmp
    return b

这里状态量比较少(两个)。如果状态量比较多,或许需要保守地对每个状态量临时保存个副本:

def gcd1_another(a, b):
    while a != 0:
        tmpa = a
        tmpb = b
        a = tmpb % tmpa
        b = tmpa
    return b

另外可以巧用swap来避免临时变量的使用:

def swap(a, b):
    return b, a


def gcd2(a, b):
    while a != 0:
        a, b = swap(a, b)  # 我就想表达一下简单swap两个变量这个意思,别纠结这句话。
        a = a % b
    return b

 

但是代码是越来越反人类了。

最后,给出用Python一个比较好的写法:

def gcd3(a, b):
    while a != 0:
        a, b = b % a, a
    return b

 

这种写法当状态量很多的时候也不是很好使,因为赋值那句会变得很长。

总结下手工翻译的缺点:

1、会翻错;

2、可能会增加临时变量(编译器寄存器分配算法或许会把临时变量优化掉);

3、代码可读性下降。

记得是谁(《编程珠玑》的作者?)说过来着:当递归与迭代不知道用哪个好时,用递归。可能是因为:1)程序是写给人看的,2)可读性好能节约程序员的时间,3)节约程序员的时间比节约机器时间重要。

你可能感兴趣的:(递归、尾递归与迭代)