递归、尾递归与迭代
很久以前写过一篇《递归与迭代》,写得不是很好。可能是我自己也没把思路理清楚,现在就有了个重新整理思路炒冷饭的机会,也算是一个新的开始吧。
首先解释一个术语叫“尾调用”。直接从wiki的“尾调用”条目抄:尾调用是指一个函数里的最后一个动作是一个函数调用的情形:即这个调用的返回值直接被当前函数返回的情形。这种情形下称该调用位置为尾位置。若这个函数在尾位置调用本身(或是一个尾调用本身的其他函数等等),则称这种情况为尾递归,是递归的一种特殊情形。
比如,下面这个是尾调用:
1 | return f(); |
而这个不是:
1 | return 1 + f(); |
关于尾调用,有个重要的优化叫做尾调用消除。经过尾调用消除优化后的程序在执行尾调用时不会导致调用栈的增长。其实,这个优化可能不算是优化。因为在某些计算模型中,尾调用天然不会导致调用栈的增长。在EOPL提到过一个原理:
导致调用栈增长的,是对参数的计算,而不是函数调用。
由于尾调用不会导致调用栈的增长,所以尾调用事实上不像函数调用,而更接近GOTO。尾递归则等同于迭代,只是写法上的不同罢了。
下面以计算幂为例,分别用递归、尾递归和迭代三种方法实现程序。其中尾递归方法是递归方法的改进,而迭代方法纯粹是从尾递归方法翻译而来。
问题
已知数(整数,或浮点数,这里假设是整数)b 和非负整数n ,求b的n次幂bn。
一个简单的解法是循环n次,不断的乘b,得到答案。这个方法是低效的。虽然它的时间复杂度是O(n) ,但这是个相对于输入规模为指数级的算法。
一个启发是,当n为2的幂时,假设n=2k。那么将b平方k次,就能得到bn,时间复杂度是O(logn),是线性算法。
将这个启发推广到n不是2的幂的情况,我们就得到了这个问题的一个算法(貌似叫“模重复平方算法”)。
递归方法
递归方法实际上是数学归纳法。基础情况:当n=0时,b0=1。假设我们已经知道对于所有k<n的k,该怎么求bk。那么这样求bn:
如果n是偶数,则bn=(b∗b)n/2;
如果n是奇数,则bn=b∗bn−1。
代码是这样:
1 2 3 4 5 6 7 8 9 10 11 | 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); } } |
递归方法有一个不好,那就是会导致调用栈增加。在这个例子中,计算bn将占用额外O(logn)的空间。
尾递归方法
使用尾递归,需要对递归方法做一点改动。引入一个新变量a,a的初始值是1。a的用处是保存计算结果。另外再引入一个计算过程S(a,b,n)=a∗bn,根据下面的描述计算S(a,b,n):
如果n=0,S(a,b,n)=a∗b0=a;
如果n是偶数,S(a,b,n)=a∗bn=a∗(b∗b)n/2=S(a,b∗b,n/2);
如果n是奇数,S(a,b,n)=a∗bn=(a∗b)∗(bn−1)=S(a∗b,b,n−1)。
这个S我们称为循环不变量,因为在整个计算过程中,S的返回值都是不变的。很多尾递归/迭代算法的设计关键就在于循环不变量的构造。
根据S的不变性质我们可以证明这个算法的正确性:在开始时a=1,有S(a,b,n)=bn;结束时n=0,有S(a,b,n)=a。所以结束时的a等于开始时的bn。
我们定义一个计算S的函数:
1 2 3 4 5 6 7 8 9 10 | 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); } } |
计算bn的函数就是计算S(1,b,n):
1 2 3 4 | 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←b∗b,n←n/2;
如果n是奇数,S(a,b,n)=S(a∗b,b,n−1),相当于a←a∗b,n←n−1。
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 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、为什么机器能自动翻译的东西非要人去手工翻译,很容易出错的。
再举个例子吧。
求最大公约数,用辗转相除法可以快速求出。尾递归代码可以很快写出:
1 2 3 4 5 | def gcd0(a, b): if a = = 0 : return b else : return gcd0(b % a, a) |
接下来手工翻译。
首先是很容易翻译出错的一种情况:
1 2 3 4 5 | def gcd1_wrong(a, b): while a ! = 0 : a = b % a b = a return b |
由于在迭代方法中,我们需要对循环的状态量赋值,赋值的引入导致表达式的计算结果依赖于执行顺序。在尾递归的方法中,a←b和 $b \leftarrow a\)是同时执行的,而在迭代方法中,必须引入额外的变量tmp来实现这个赋值:
1 2 3 4 5 6 | def gcd1(a, b): while a ! = 0 : tmp = a a = b % a b = tmp return b |
这里状态量比较少(两个)。如果状态量比较多,或许需要保守地对每个状态量临时保存个副本:
1 2 3 4 5 6 7 | def gcd1_another(a, b): while a ! = 0 : tmpa = a tmpb = b a = tmpb % tmpa b = tmpa return b |
另外可以巧用swap来避免临时变量的使用:
1 2 3 4 5 6 7 8 9 | 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一个比较好的写法:
1 2 3 4 | def gcd3(a, b): while a ! = 0 : a, b = b % a, a return b |
这种写法当状态量很多的时候也不是很好使,因为赋值那句会变得很长。
总结下手工翻译的缺点:
1、会翻错;
2、可能会增加临时变量(编译器寄存器分配算法或许会把临时变量优化掉);
3、代码可读性下降。
记得是谁(《编程珠玑》的作者?)说过来着:当递归与迭代不知道用哪个好时,用递归。可能是因为:1)程序是写给人看的,2)可读性好能节约程序员的时间,3)节约程序员的时间比节约机器时间重要。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· winform 绘制太阳,地球,月球 运作规律
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人