为什么循环比递归快?


18

在实践中,我知道任何递归都可以写成一个循环(反之亦然(?)),如果我们用实际的计算机进行测量,我们发现对于相同的问题,循环比递归要快。但是,有什么理论可以使这种区别发生或者它主要是实证的?


9
在实现效果不佳的语言中,外观仅比递归快。在具有适当的Tail Recursion的语言中,可以将递归程序转换为后台的循环,在这种情况下,它们是相同的,因此不会有任何区别。
jmite '16

3
是的,如果您使用支持该语言的语言,则可以使用(尾)递归,而不会产生任何负面的性能影响。
jmite '16

1
@jmite,实际上可以优化为循环的尾部递回极为罕见,比您想像的要少得多。尤其是在具有托管类型(如引用计数变量)的语言中。
约翰-恢复莫妮卡

1
由于您包含了标记时间复杂度,因此我觉得我应该补充一点:循环算法与递归算法具有相同的时间复杂度,但是对于后者,所花费的时间将增加一些常数,具体取决于递归的开销量。
Lieuwe Vinkhuijzen

2
嘿,由于您为悬赏金加上了很多不错的答案,几乎耗尽了所有可能性,因此,您是否还需要其他东西,还是觉得应该澄清一些东西?我没有太多要添加的内容,我可以编辑一些答案或发表评论,所以这是一般性(非个人性)问题。
Evil

Answers:


17

循环比递归快的原因很容易。
汇编中的循环看起来像这样。

mov loopcounter,i
dowork:/do work
dec loopcounter
jmp_if_not_zero dowork

一个条件跳转和一些记数器,用于循环计数器。

递归(当编译器未对其进行优化或无法对其进行优化时)如下所示:

start_subroutine:
pop parameter1
pop parameter2
dowork://dowork
test something
jmp_if_true done
push parameter1
push parameter2
call start_subroutine
done:ret

它要复杂得多,您至少要跳3次(进行1次测试,看是否完成,一次调用,一次返回)。
同样在递归中,需要设置和获取参数。
循环中不需要这些东西,因为已经设置了所有参数。

从理论上讲,这些参数也可以通过递归保持不变,但是据我所知,没有哪个编译器实际上在优化上走得那么远。

呼叫和jmp之间的区别
呼叫返回对并不比jmp贵很多。该对需要2个周期,而jmp需要1个周期;几乎没有引起注意。
在支持寄存器参数的调用约定中,参数的开销最小,但只要CPU的缓冲区不溢出,即使是堆栈参数也很便宜。
调用约定和使用中的参数处理决定了调用建立的开销,从而降低了递归速度。
这在很大程度上取决于实现。

递归处理效果不佳的 示例例如,如果传递了一个对引用计数进行计数的参数(例如,非const管理的类型参数),它将添加100个周期来对引用计数进行锁定调整,从而完全破坏了性能,而导致循环。
在调整为递归的语言中,不会发生这种不良行为。

CPU优化
递归较慢的另一个原因是它与CPU的优化机制相反。
只有在连续的行中没有太多的情况下才能正确预测收益。CPU具有一个返回堆栈缓冲区,其中包含少量条目。一旦这些钱用完了,所有额外的回报将被错误地预测,从而导致巨大的延误。
在使用堆栈返回缓冲区调用的递归超过缓冲区大小的任何CPU上,都最好避免。

关于使用递归的平凡代码示例
如果使用像Fibonacci数字生成这样的简单递归示例,则不会发生这些影响,因为任何“了解”递归的编译器都会将其转换为循环,就像任何值得他精打细算的程序员一样将。
如果您在无法适当优化的环境中运行这些琐碎的示例,则调用堆栈将(不必要)超出范围。

关于尾部递归
请注意,有时编译器通过将其更改为循环来优化尾部递归。最好仅依靠在此方面具有良好记录的语言中的这种行为。
许多语言会在最终返回值之前插入隐藏的清理代码,从而无法优化尾递归。

真正递归与伪递归之间的混淆
如果您的编程环境将递归源代码变成一个循环,那么可以说不是正在执行的真正递归。
真正的递归需要存储面包屑,以便递归例程可以在退出后追溯其步骤。
正是这种跟踪的处理使得递归比使用循环要慢。如上所述,当前的CPU实现会放大这种影响。

编程环境的影响
如果您的语言针对递归优化进行了调整,那么一定要继续使用并在任何机会上使用递归。在大多数情况下,该语言会将您的递归转换为某种循环。
在那些无法做到的情况下,程序员也会受到压力。如果您的编程语言不适合递归,则应避免使用该语言,除非该域适合递归。
不幸的是,许多语言不能很好地处理递归。

递归的滥用
不需要使用递归来计算斐波那契数列,实际上这是一个病理例子。
递归最适合在显式支持它的语言中或在递归非常有用的域中使用,例如处理存储在树中的数据。

我知道任何递归都可以写成一个循环

是的,如果您愿意将购物车放在马匹前面。
所有递归实例都可以写成一个循环,其中一些实例要求您使用显式堆栈(例如存储)。
如果您需要滚动自己的堆栈只是为了将递归代码转换成循环,则不妨使用纯递归。
除非您当然有特殊的需求,例如在树结构中使用枚举器,并且没有适当的语言支持。


16

这些其他答案有些令人误解。我同意他们陈述了可以解释这种差异的实施细节,但是他们夸大了这一情况。正如jmite正确建议的那样,它们是面向实现的,指向函数调用/递归的破碎实现。许多语言都是通过递归实现循环的,因此循环在这些语言中显然不会更快。从理论上讲,递归绝不会比循环(在两者都适用时)效率低。让我引用盖伊·斯蒂尔(Guy Steele)1977年论文中的摘要:揭穿“昂贵的程序调用”神话,或者认为程序实现有害,或者说Lambda:终极GOTO

Folklore指出,GOTO语句“便宜”,而过程调用则“昂贵”。这个神话很大程度上是由于语言实现设计不良而导致的。考虑了这个神话的历史发展。讨论了理论思想和现有的实现方式,这颠覆了这个神话。结果表明,过程调用的无限制使用允许很大的样式自由。特别是,任何流程图都可以编写为“结构化”程序,而无需引入额外的变量。GOTO语句和过程调用的困难在于抽象编程概念和具体语言构造之间的冲突。

在“抽象编程的概念和具体的语言结构之间的冲突”,可以从一个事实,即大部分的理论模型,例如,无类型可以看出演算没有一个堆栈。当然,如上面的论文所示,这种冲突不是必需的,并且诸如Haskell之类的除了递归以外没有迭代机制的语言也证明了这种冲突。

让我示范一下。为简单起见,我将使用数字和和布尔值的“应用”演算,我会假设我们有一个不动点组合子fix,满足fix f x = f (fix f) x。所有这些都可以简化为无类型的λ演算,而无需更改我的论点。理解lambda演算评估的原型方式是通过术语重写和beta减少的中心重写规则,即,其中表示“替换所有自由的出现在与 “和[ Ñ / X ] X 中号Ñ (λx.M)NM[N/x][N/x]xMN表示“重写为”。这只是将函数调用的参数替换为函数主体的形式化。

现在举个例子。定义fact

fact = fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1

这是的评估fact 3,为了紧凑起见,我将其g用作的同义词fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)),即fact = g 1。这不影响我的论点。

fact 3 
~> g 1 3
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1 3 
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 1 3
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 1 3
~> (λn.if n == 0 then 1 else g (1*n) (n-1)) 3
~> if 3 == 0 then 1 else g (1*3) (3-1)
~> g (1*3) (3-1)
~> g 3 2
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 3 2
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 3 2
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 3 2
~> (λn.if n == 0 then 3 else g (3*n) (n-1)) 2
~> if 2 == 0 then 3 else g (3*2) (2-1)
~> g (3*2) (2-1)
~> g 6 1
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 1
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 1
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 1
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 1
~> if 1 == 0 then 6 else g (6*1) (1-1)
~> g (6*1) (1-1)
~> g 6 0
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 0
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 0
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 0
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 0
~> if 0 == 0 then 6 else g (6*0) (0-1)
~> 6

您可以从形状中看到,甚至无需查看细节即没有增长,并且每次迭代都需要相同的空间量。(从技术上讲,数字结果会不可避免地增长,就像while循环一样。)我无视您指出这里无限增长的“堆栈”。

看来lambda演算的原型语义已经做了通常被误称为“尾调用优化”的事情。当然,这里没有发生“优化”。相对于“普通”呼叫,此处没有针对“尾”呼叫的特殊规则。因此,很难对尾部调用“优化”的工作进行“抽象”描述,因为在许多函数调用语义的抽象表征中,尾部调用“优化”没有任何作用!

fact许多语言中类似的定义“堆栈溢出”,是这些语言无法正确实现函数调用语义的失败。(某些语言有借口。)这种情况大致类似于使用语言实现实现具有链接列表的数组的语言。索引到此类“数组”中的操作将是不符合数组期望的O(n)操作。如果我使用该语言的单独实现,即使用实际数组而不是链接列表,则不会说我已经实现了“数组访问优化”,而是会说我修复了数组的坏实现。

因此,回应Veedrac的回答。 不是递归的“基础”。就评估过程中发生“类堆栈”行为而言,这仅在循环(无辅助数据结构)最初不适用的情况下才会发生!换句话说,我可以实现具有完全相同的性能特征的递归循环。确实,Scheme和SML都包含循环构造,但是它们都以递归的方式定义了它们(并且至少在Scheme中,do通常将其实现为扩展为递归调用的宏。)类似地,对于Johan的回答,没有人说编译器必须发出递归描述的程序集Johan。确实,无论使用循环还是递归,程序集都完全相同。编译器唯一有义务像Johan所描述的那样发出程序集的时间是,当您执行某种无法通过循环表达的操作时。正如斯蒂尔(Steele)的论文中概述的那样,并通过Haskell,Scheme和SML等语言的实际实践进行了证明,尾部调用可以被“优化”并不是“非常罕见”,它们可以始终被“优化”。递归的特定用途是否将在恒定的空间中运行取决于它的编写方式,但是要使之成为可能,需要应用的限制是使问题适合于循环形状所需的限制。(实际上,它们不那么严格。存在一些问题,例如对状态机进行编码,这些问题可以通过尾部调用更加干净有效地进行处理,而不是需要辅助变量的循环。)同样,递归唯一需要做更多工作的时间是无论如何您的代码都不是循环。

我的猜测是Johan指的是C编译器,它对何时执行尾部调用“优化”具有任意限制。Johan在谈论“具有托管类型的语言”时,大概还指的是C ++和Rust之类的语言。来自C ++且存在于Rust中的RAII习惯用法也使表面看起来像尾部调用,而不是尾部调用(因为仍然需要调用“析构函数”)。有人建议使用不同的语法来选择稍有不同的语义,以允许尾递归(即在之前调用析构函数最后的尾声,并且显然不允许访问“被破坏”的对象)。(垃圾回收没有这样的问题,所有Haskell,SML和Scheme都是垃圾回收语言。)与此完全不同的是,在这些语言中,某些语言(例如Smalltalk)将“堆栈”公开为一流对象。在这种情况下,“堆栈”不再是实现细节,尽管这并不排除使用不同语义的不同类型的调用。(Java表示不能由于它处理安全性某些方面的方式,但这实际上是错误的。)

在实践中,函数调用的实现中断的普遍性来自三个主要因素。首先,许多语言都从其实现语言(通常为C)继承了残破的实现。其次,确定性资源管理非常好,而且确实使问题变得更加复杂,尽管只有少数几种语言提供了此功能。第三,根据我的经验,大多数人关心的原因是,当发生错误时,他们希望堆栈跟踪用于调试目的。仅第二个原因是可以在理论上潜在地激发的原因。


我用“基本的”来指称该说法正确的最基本原因,而不是逻辑上是否必须这样(显然,事实并非如此,因为两个程序可证明是相同的)。但我不同意您的整体看法。使用lambda演算不会完全消除堆栈。
Veedrac '16

您的主张“只有在您执行某种循环无法表达的操作时,编译器才(某种程度上)有义务像Johan所描述的那样发出程序集。” 也很奇怪 编译器(通常)能够产生任何产生相同输出的代码,因此您的注释基本上是重言式。但是实际上,编译器的确为不同的等效程序生成了不同的代码,而问题是为什么。
Veedrac '16

实际上,堆栈是指从外部函数的框架捕获的那些变量,而区别在于标记。的确,这些“堆栈”上的广义归约具有一些合意的属性,但是几乎没有使典型语言的堆栈损坏,甚至因为没有预先加上导致矢量损坏。鉴于尾递归并不能优化使用更一般的归约优化的所有情况,因此尤其如此。O(1)
Veedrac '16

举个比喻,回答一个问题,为什么在循环中添加不可变字符串会花二次时间(不一定是),这是完全合理的,但继续声称实现被这样破坏是不会的。
Veedrac '16

非常有趣的答案。即使听起来有点像咆哮:-)。由于我学到了一些新东西,因此表示支持。
约翰-恢复莫妮卡

2

从根本上来说,区别在于递归包括堆栈,这是您可能不希望使用的辅助数据结构,而循环不会自动这样做。只有在极少数情况下,典型的编译器才能推断出您实际上根本不需要堆栈。

相反,如果您比较循环在分配的堆栈上手动操作(例如,通过指向堆内存的指针),则通常不会发现它们比使用硬件堆栈快甚至慢。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.