递归比循环快吗?


286

我知道递归有时比循环要干净得多,并且我什么时候不应该在迭代中使用递归就没有任何疑问,我知道已经有很多问题了。

我要问的是,递归是否比循环快?在我看来,与循环函数相比,您总是能够完善一个循环并使其执行得更快,因为没有循环不断地建立新的堆栈框架。

我专门在寻找在应用程序中正确使用递归的应用程序中递归是否更快,例如某些排序函数,二进制树等。


3
有时,某些过程的迭代过程或闭式公式需要几个世纪才能出现。我认为只有在那个时候递归才更快:)大声笑
Pratik Deoghare 2010年

23
对于我自己来说,我更喜欢迭代。;-)
Iterator



@PratikDeoghare不,问题不在于选择完全不同的算法。您始终可以将递归函数转换为使用循环的功能相同的方法。例如,此答案在递归和循环格式中都具有相同的算法。通常,您会将递归函数的参数元组放入堆栈中,推入堆栈以进行调用,从堆栈中丢弃以从函数中返回。
TamaMcGlinn

Answers:


356

这取决于所使用的语言。您写了“与语言无关”,因此我将举一些例子。

在Java,C和Python中,与迭代相比,递归的开销相当大(通常),因为它需要分配新的堆栈框架。在某些C编译器中,可以使用编译器标志消除这种开销,该开销将某些类型的递归(实际上是某些类型的尾部调用)转换为跳转而不是函数调用。

在函数式编程语言实现中,有时迭代可能会非常昂贵,而递归可能会非常便宜。在许多情况下,递归转换为简单的跳转,但是更改循环变量(可变的)有时需要进行一些相对繁重的操作,尤其是在支持多个执行线程的实现上。在某些这样的环境中,如果变量和垃圾回收器可能同时运行,则由于它们之间的交互作用,导致突变的代价很高。

我知道在某些Scheme实现中,递归通常比循环快。

简而言之,答案取决于代码和实现。使用您喜欢的任何样式。如果您使用的是功能语言,则递归可能会更快。如果您使用命令式语言,则迭代速度可能会更快。在某些环境中,这两种方法都会导致生成相同的装配(将其放入管道中并抽烟)。

附录:在某些环境中,最好的选择既不是递归也不是迭代,而是高阶函数。这些包括“地图”,“过滤器”和“减少”(也称为“折叠”)。这些功能不仅是首选的样式,而且不仅通常更干净,而且在某些环境中,这些功能是第一个(或唯一)从自动并行化中获得提升的功能-因此,它们可以比迭代或递归快得多。数据并行Haskell是这种环境的一个示例。

列表理解是另一种选择,但是它们通常只是用于迭代,递归或更高阶函数的语法糖。


48
我对此+1,并想评论一下“递归”和“循环”正是人们命名其代码的意思。对于性能而言,重要的不是您如何命名事物,而是如何编译/解释它们。顾名思义,递归是一个数学概念,与堆栈框架和组装无关。
P Shved

1
同样,在函数式语言中,递归通常是更自然的方法,而在命令式语言中,迭代通常更直观。性能差异不太可能引起注意,因此请仅使用对该特定语言更自然的感觉。例如,当递归更加简单时,您可能不想在Haskell中使用迭代。
Sasha Chedygov

4
通常,将递归编译为循环,其中循环是较低级别的构造。为什么?由于递归通常在某些数据结构上有很好的基础,因此可以归纳出一个初始F代数,并允许您证明一些有关终止的属性以及关于(递归)计算结构的归纳参数。将递归编译为循环的过程是尾调用优化。
Kristopher Micinski 2012年

最重要的是不执行操作。您的“ IO”越多,您处理的内容就越多。Un-IOing数据(aka索引)始终是任何系统的最大性能提升,因为您不必首先进行处理。
杰夫·菲舍尔

53

递归比循环快吗?

不,迭代永远比递归快。(在冯·诺依曼架构中)

说明:

如果您从头开始构建通用计算机的最少操作,则“迭代”首先作为构建块,并且比“递归”占用的资源更少,因此ergo更快。

从头开始构建伪计算机:

问自己:您需要什么来计算值,即遵循算法并得出结果?

我们将建立概念的层次结构,从头开始,首先定义基本的核心概念,然后使用这些概念构建第二级概念,依此类推。

  1. 第一个概念:存储单元,存储,状态。为此,您需要放置最终和中间结果值的位置。假设我们有一个无限的“整数”单元格数组,称为Memory,M [0..Infinite]。

  2. 说明:做某事-变换单元格,更改其值。改变状态。每个有趣的指令都会执行一个转换。基本说明是:

    a)设置和移动存储单元

    • 将值存储到内存中,例如:存储5 m [4]
    • 将值复制到另一个位置:例如:存储m [4] m [8]

    b)逻辑与算术

    • 和,或
    • 加,减,mul,div 例如加m [7] m [8]
  3. 执行代理:现代CPU中的核心。“代理”是可以执行指令的东西。一个代理,也可以在纸上的算法以下的人。

  4. 步骤顺序:一系列指令:即:首先执行,之后执行,依此类推。即使是一行表达式也是“命令的命令性序列”。如果您有一个带有特定“评估顺序”的表达式,那么您就有步骤。这意味着甚至单个组成的表达式也具有隐式的“步骤”,也具有隐式的局部变量(我们称其为“结果”)。例如:

    4 + 3 * 2 - 5
    (- (+ (* 3 2) 4 ) 5)
    (sub (add (mul 3 2) 4 ) 5)  
    

    上面的表达式包含3个带有隐式“结果”变量的步骤。

    // pseudocode
    
           1. result = (mul 3 2)
           2. result = (add 4 result)
           3. result = (sub result 5)
    

    因此,由于您具有特定的求值顺序,所以即使是infix表达式也是命令的必要命令。该表达式表示要按特定顺序执行的一系列操作,并且由于存在步骤,因此还存在一个隐式“结果”中间变量。

  5. 指令指针:如果有一系列步骤,则还有一个隐式的“指令指针”。指令指针标记下一条指令,并在读取指令之后但在执行指令之前前进。

    在此伪计算机中,指令指针是Memory的一部分。(注意:通常,指令指针将是CPU内核中的“特殊寄存器”,但是在这里,我们将简化概念并假定所有数据(包括寄存器)都是“内存”的一部分)

  6. 跳转 -一旦您获得了预定数量的步骤和一个指令指针,就可以应用“ store ”指令来更改指令指针本身的值。我们将使用新名称调用存储指令的这种特定用法:Jump。我们使用新名称是因为更容易将其视为新概念。通过更改指令指针,我们指示代理“转到步骤x”。

  7. 无限迭代:现在通过跳回,您可以使代理“重复”一定数量的步骤。在这一点上,我们有无限的迭代。

                       1. mov 1000 m[30]
                       2. sub m[30] 1
                       3. jmp-to 2  // infinite loop
    
  8. 有条件的 -有条件地执行指令。使用“条件”子句,您可以基于当前状态(可以使用上一条指令设置)有条件地执行几条指令之一。

  9. 正确的迭代:现在有了条件子句,我们可以逃避跳转指令的无限循环。现在我们有一个条件循环,然后进行适当的迭代

    1. mov 1000 m[30]
    2. sub m[30] 1
    3. (if not-zero) jump 2  // jump only if the previous 
                            // sub instruction did not result in 0
    
    // this loop will be repeated 1000 times
    // here we have proper ***iteration***, a conditional loop.
    
  10. 命名:为保存数据或保存步骤的特定存储位置命名。这只是一个“便利”。我们没有通过定义存储器位置的“名称”来添加任何新指令。“命名”不是对代理的指示,对我们来说只是一种方便。命名使代码(此时)更易于阅读和更改。

       #define counter m[30]   // name a memory location
       mov 1000 counter
    loop:                      // name a instruction pointer location
        sub counter 1
        (if not-zero) jmp-to loop  
    
  11. 一级子例程:假设您需要频繁执行一系列步骤。您可以将步骤存储在内存中的指定位置,然后在需要执行这些步骤(调用)时跳到该位置。在序列结束时,您需要返回调用点以继续执行。使用这种机制,您可以通过编写核心指令来创建新的指令(子例程)。

    实施:(无需新概念)

    • 将当前指令指针存储在预定义的存储位置中
    • 跳到子程序
    • 在子例程的末尾,您将从预定义的内存位置中检索指令指针,从而有效地跳回到原始调用的以下指令

    问题的一个层面实现:你不能从一个子程序调用另一个子程序。如果这样做,您将覆盖返回地址(全局变量),因此无法嵌套调用。

    为子程序提供更好的实现:您需要一个堆栈

  12. 堆栈:您可以定义一个存储空间以用作“堆栈”,您可以在堆栈上“推送”值,也可以“弹出”最后一个“推送”的值。要实现堆栈,您需要一个堆栈指针(类似于指令指针),该指针指向堆栈的实际“头”。当您“推送”一个值时,堆栈指针会递减并存储该值。当您“弹出”时,您会在实际的堆栈指针处获得该值,然后堆栈指针将递增。

  13. 子例程现在有了堆栈,我们可以实现允许嵌套调用的适当子例程。实现方式相似,但不是将指令指针存储在预定义的内存位置中,而是将IP的值“推送”到堆栈中。在子例程的末尾,我们只是从堆栈中“弹出”值,有效地跳回到原始调用之后的指令。具有“堆栈”的此实现允许从另一个子例程中调用一个子例程。通过此实现,当将新指令定义为子例程时,我们可以使用核心指令或其他子例程作为构建块来创建多个抽象级别。

  14. 递归:子例程调用自身时会发生什么?这称为“递归”。

    问题:覆盖本地中间结果可将子例程存储在内存中。由于您正在调用/重用相同的步骤,因此如果中间结果存储在预定义的存储位置(全局变量)中,则在嵌套调用中它们将被覆盖。

    解决方案:为了允许递归,子例程应将本地中间结果存储在堆栈中,因此,在每次递归调用(直接或间接)上,中间结果都存储在不同的内存位置中。

...

到达递归后,我们在这里停止。

结论:

在冯·诺依曼体系结构中,“迭代”显然比“递归”更简单/基本,我们在第7层具有“迭代”的形式,而“递归”在概念层次的第14层。

机器代码中的迭代总是更快,因为它意味着更少的指令,因此需要更少的CPU周期。

哪一个更好”?

  • 在处理简单的顺序数据结构时,以及在每个“简单循环”都可以使用的地方,都应使用“迭代”。

  • 当您需要处理递归数据结构(我喜欢将它们称为“分形数据结构”)时,或者当递归解决方案显然更“优雅”时,应使用“递归”。

忠告:使用最好的工具来工作,但要明智地选择每个工具的内部运作方式。

最后,请注意您有很多使用递归的机会。您到处都有递归数据结构,现在正在看一看:支持您所读取内容的DOM部分是RDS,JSON表达式是RDS,计算机中的分层文件系统是RDS,即:根目录,包含文件和目录,每个目录包含文件和目录,其中每个目录包含文件和目录...


2
您假设您的进度是1)必要的,2)进度在那里停止了。但是1)没有必要(例如,递归可以转换为跳转,如已接受的答案所述,因此不需要堆栈),2)它不必在那里停止(例如,最终您'将达到并发处理,如果您在第二步中引入了可变状态,则可能需要锁定,因此一切都会变慢;而不变的解决方案(例如功能/递归解决方案)将避免锁定,因此可以更快/更并行) 。
hmijail哀悼辞职者

2
“递归可以变成跳跃”是错误的。真正有用的递归不能转换为跳转。尾调用“递归”是一种特殊情况,其中您将“作为递归”编码为可以由编译器简化为循环的东西。另外,您将“不可变”与“递归”混为一谈,这些是正交的概念。
Lucio M. Tato

“无法将真正有用的递归转换为跳转”->因此,尾部调用优化毫无用处?同样,不可变和递归可能是正交的,但是您确实使用可变计数器进行链接循环-请看您的第9步。在我看来,您正在考虑循环和递归是根本不同的概念。他们不是。stackoverflow.com/questions/2651112/...
hmijail哀悼辞职者

@hmijail我认为比“有用”更好的词是“ true”。尾递归不是真正的递归,因为它只是使用函数调用语法来掩盖无条件分支,即迭代。真正的递归为我们提供了回溯堆栈。但是,尾递归仍然是可表达的,这使其很有用。当使用尾部调用表示迭代代码时,递归的属性使迭代代码的分析变得容易或容易,这些属性被赋予迭代代码。尽管有时由于尾部版本中的额外复杂性(例如额外的参数)而略有抵消。
哈兹

34

在替代方法是显式管理堆栈的情况下,递归可能会更快,例如您提到的排序或二叉树算法。

我曾经遇到过用Java重写递归算法使它变慢的情况。

因此,正确的方法是首先以最自然的方式编写它,仅在概要分析显示它很关键时才进行优化,然后评估所谓的改进。


2
+1表示“ 首先以最自然的方式编写它 ”,尤其是“ 仅在分析表明它很关键时才进行优化
TripeHound 2014年

2
+1表示硬件堆栈可能比手动实施的堆中软件更快。有效地表明所有“否”答案都是错误的。
2015年


12

考虑每次迭代和递归绝对必须执行的操作。

  • 迭代:跳转到循环的开始
  • 递归:跳转到被调用函数的开头

您会发现这里没有太大的差异空间。

(我认为递归是一个尾部调用,而编译器知道该优化)。


9

这里的大多数答案都忘记了一个显而易见的罪魁祸首:为什么递归通常比迭代解决方案要慢。它与堆栈框架的建立和拆除有关,但不完全是。对于每次递归,自动变量的存储通常存在很大差异。在带有循环的迭代算法中,变量通常保存在寄存器中,即使它们溢出,它们也将驻留在1级缓存中。在递归算法中,变量的所有中间状态都存储在堆栈中,这意味着它们将引起更多的溢出到内存中。这意味着,即使它执行相同数量的操作,它在热循环中也会有大量的内存访问,而更糟糕的是,这些内存操作的糟糕重用率使缓存的效率降低。

TL; DR递归算法通常比迭代算法具有更差的缓存行为。


6

这里的大多数答案都是错误的。正确的答案取决于情况。例如,这里有两个遍历树的C函数。首先是递归的:

static
void mm_scan_black(mm_rc *m, ptr p) {
    SET_COL(p, COL_BLACK);
    P_FOR_EACH_CHILD(p, {
        INC_RC(p_child);
        if (GET_COL(p_child) != COL_BLACK) {
            mm_scan_black(m, p_child);
        }
    });
}

这是使用迭代实现的相同功能:

static
void mm_scan_black(mm_rc *m, ptr p) {
    stack *st = m->black_stack;
    SET_COL(p, COL_BLACK);
    st_push(st, p);
    while (st->used != 0) {
        p = st_pop(st);
        P_FOR_EACH_CHILD(p, {
            INC_RC(p_child);
            if (GET_COL(p_child) != COL_BLACK) {
                SET_COL(p_child, COL_BLACK);
                st_push(st, p_child);
            }
        });
    }
}

了解代码的细节并不重要。只是那p是节点P_FOR_EACH_CHILD而已。在迭代版本中,我们需要一个显式堆栈,st将节点压入该堆栈,然后弹出并对其进行操作。

递归函数的运行速度比迭代函数快得多。原因是因为在后者中,对于每个项目,都需要一个CALL功能st_push,然后再另一个st_pop

在前者中,CALL每个节点只有递归。

另外,在调用堆栈上访问变量的速度非常快。这意味着您正在从内存中读取数据,而该内存可能始终位于最内部的缓存中。另一方面,显式堆栈必须由malloc堆中的:ed内存支持,这要慢得多。

通过精心优化,例如inline st_pushst_pop,我可以与递归方法大致达到同等水平。但是至少在我的计算机上,访问堆内存的成本大于递归调用的成本。

但是,由于递归树遍历是不正确的,因此此讨论主要是没有意义的。如果您有足够大的树,则将耗尽调用栈空间,这就是为什么必须使用迭代算法的原因。


我可以确认自己也遇到过类似情况,并且在某些情况下,递归比堆上的手动堆栈要快。特别是在编译器中打开优化以避免调用函数的一些开销时。
while1fork

1
对7个节点的二叉树进行了10 ^ 8次预遍历。递归25ns。显式堆栈(是否经过边界检查-差别不大)〜15ns。除了推和跳之外,递归还需要做更多的事情(寄存器保存和恢复+(通常)更严格的帧对齐)。(在动态链接的库中使用PLT会变得更糟。)您无需对显式堆栈进行堆分配。您可以创建一个obstack,其第一帧位于常规调用堆栈上,因此在不超过第一个块的最常见情况下,您不会牺牲高速缓存的局部性。
PSkocik

3

通常,不行,递归不会比在两种形式下都可行的现实使用中的循环快。我的意思是,当然,您可以编写永久性的循环,但是会有更好的方法来实现相同的循环,该循环可能会通过递归方式胜过对相同问题的任何实现。

关于原因,您打在了头上。创建和销毁堆栈帧比简单的跳转要昂贵得多。

但是,请注意,我说过“两种形式都可行”。对于诸如排序算法之类的事情,由于子“任务”的产生是进程固有的一部分,因此往往没有一种可行的方法来实现它们而无法有效地建立自己的堆栈版本。因此,递归可能与尝试通过循环实现算法一样快。

编辑:此答案假设使用非功能性语言,其中大多数基本数据类型都是可变的。它不适用于功能语言。


这也是为什么编译器经常使用经常使用递归的语言来优化递归的几种情况的原因。例如,在F#中,除了使用.tail操作码完全支持尾部递归函数外,您还经常看到递归函数编译为循环。
em70

是的 尾递归有时可能是两全其美的-实现递归任务的功能上“适当”的方式以及使用循环的性能。
琥珀色

1
通常,这是不正确的。在某些环境中,变异(与GC交互)比尾递归更昂贵,后者在输出中转换为更简单的循环,而无需使用额外的堆栈框架。
Dietrich Epp 2010年

2

在任何现实的系统中,没有,创建堆栈框架总是比INC和JMP昂贵。这就是为什么真正优秀的编译器会自动将尾递归转换为对同一帧的调用,即没有开销,因此您获得了更具可读性的源版本和更高效的编译版本。一个非常非常好的编译器甚至应该能够将普通递归转换为尾递归。


1

函数式编程更多地是关于“ 什么 ”而不是“ 如何 ”。

如果我们不尝试使代码的优化程度超出实际需要,则语言实现者将找到一种优化其下层代码工作方式的方法。还可以在支持尾部调用优化的语言中优化递归。

从程序员的角度来看,更重要的是可读性和可维护性,而不是首先进行优化。同样,“过早的优化是万恶之源”。


0

这是一个猜测。如果两个都使用非常好的算法(不计算实现难度),那么通常递归可能不会经常解决循环大小问题(如果不算实现难度),如果与带尾调用递归(和尾递归算法)的语言一起使用,则递归可能会有所不同并且循环也是语言的一部分),这可能非常相似,有时甚至更喜欢递归。


0

根据理论,它是一样的东西。具有相同O()复杂度的递归和循环将以相同的理论速度工作,但当然实际速度取决于语言,编译器和处理器。可以使用O(ln(n))以迭代方式对具有幂数的示例进行编码:

  int power(int t, int k) {
  int res = 1;
  while (k) {
    if (k & 1) res *= t;
    t *= t;
    k >>= 1;
  }
  return res;
  }

1
大O与“成正比”。两者都是如此O(n),但对于所有人而言,一个人可能x要比另一人花费更长的时间n
ctrl-alt-delor
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.