递归比循环更快吗?

Car*_*ers 270 iteration recursion performance loops

我知道递归有时比循环更清晰,而且我不会询问何时应该使用递归迭代,我知道有很多问题已经存在.

我要问的是,递归是否比循环更快?对我来说,似乎总是能够改进循环并让它比递归函数更快地执行,因为循环不会不断地设置新的堆栈帧.

我特别关注在递归是处理数据的正确方法的应用程序中递归是否更快,例如在一些排序函数,二叉树等中.

Die*_*Epp 338

这取决于使用的语言.你写了'语言不可知',所以我举一些例子.

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

在函数式编程语言实现中,有时,迭代可能非常昂贵,并且递归可能非常便宜.在许多情况下,递归转换为简单的跳转,但是更改循环变量(这是可变的)有时需要一些相对繁重的操作,尤其是在支持多个执行线程的实现上.由于mutator和垃圾收集器之间的交互(如果两者可能同时运行),在某些环境中突变是昂贵的.

我知道在一些Scheme实现中,递归通常比循环更快.

简而言之,答案取决于代码和实现.使用您喜欢的任何风格.如果您使用的是函数式语言,则递归可能会更快.如果您使用命令式语言,迭代可能会更快.在某些环境中,这两种方法都会导致生成相同的程序集(将其放入管道并对其进行抽吸).

附录:在某些环境中,最好的选择既不是递归也不是迭代,而是高阶函数.这些包括"map","filter"和"reduce"(也称为"fold").这些不仅是首选样式,不仅通常更清晰,而且在某些环境中,这些函数是第一个(或唯一)从自动并行化中获得提升的功能 - 因此它们可以比迭代或递归快得多.Data Parallel Haskell就是这种环境的一个例子.

列表推导是另一种选择,但这些通常只是迭代,递归或更高阶函数的语法糖.

  • 我对此+1,并且想评论"递归"和"循环"正是人们为其代码命名的内容.对性能至关重要的不是你如何*命名*事物,而是如何编译/解释它们.根据定义,递归是一种数学概念,与堆栈框架和装配东西几乎没有关系. (46认同)
  • 通常递归是_compiled_到循环,循环是较低级别的构造.为什么?因为递归通常建立在一些数据结构之上,因此引入[初始F-代数](http://en.wikipedia.org/wiki/F-algebra#Initial_F-algebra)并允许您证明关于终止的一些属性关于(递归)计算结构的归纳论证.将递归编译为循环的过程是尾调用优化. (4认同)
  • 此外,一般来说,递归在函数式语言中是更自然的方法,而迭代在命令式语言中通常更直观。性能差异不太可能显而易见,因此只需使用对该特定语言来说更自然的语言即可。例如,当递归更简单时,您可能不想在 Haskell 中使用迭代。 (2认同)

Luc*_*ato 47

递归比循环更快?

不,迭代总是比递归更快.(在冯诺依曼建筑中)

说明:

如果从头开始构建通用计算机的最小操作,"迭代"首先作为构建块,并且比"递归"的资源密集程度更低,因此ergo更快.

从头开始构建伪计算机:

问自己:你需要什么来计算一个值,即遵循算法并达到结果?

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

  1. 第一个概念:记忆细胞,存储,状态.做一些你需要的地方来存储最终和中间结果值.假设我们有一个无限的"整数"单元格,称为Memory,M [0..Infinite].

  2. 说明:做一些事情 - 转换一个单元格,改变它的价值.改变国家.每条有趣的指令都会执行转换.基本说明如下:

    a)设置和移动存储单元

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

    b)逻辑和算术

    • 而且,或者,xor,不是
    • add,sub,mul,div.例如加m [7] m [8]
  3. 执行代理:现代CPU中的核心."代理"是可以执行指令的东西.一个代理,也可以在纸上的算法以下的人.

  4. 步骤顺序:一系列指令:即:首先执行此操作,然后执行此操作,等等.命令序列的指令.甚至一行表达式也是"命令式指令序列".如果您的表达式具有特定的"评估顺序",那么您就有了步骤.这意味着甚至一个组合表达式都有隐含的"步骤",并且还有一个隐式局部变量(让我们称之为"结果").例如:

    4 + 3 * 2 - 5
    (- (+ (* 3 2) 4 ) 5)
    (sub (add (mul 3 2) 4 ) 5)  
    
    Run Code Online (Sandbox Code Playgroud)

    上面的表达式暗示了具有隐式"结果"变量的3个步骤.

    // pseudocode
    
           1. result = (mul 3 2)
           2. result = (add 4 result)
           3. result = (sub result 5)
    
    Run Code Online (Sandbox Code Playgroud)

    因此,即使是中缀表达式,因为您有一个特定的评估顺序,也是必不可少的指令序列.表达式意味着要按特定顺序进行一系列操作,并且由于存在步骤,因此还存在隐式"结果"中间变量.

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

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

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

  7. 无限迭代:通过跳回,现在你可以让代理"重复"一定数量的步骤.此时我们有无限的迭代.

                       1. mov 1000 m[30]
                       2. sub m[30] 1
                       3. jmp-to 2  // infinite loop
    
    Run Code Online (Sandbox Code Playgroud)
  8. 条件 - 指令的有条件执行.使用"conditional"子句,您可以根据当前状态(可以使用上一条指令设置)有条件地执行多条指令之一.

  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.
    
    Run Code Online (Sandbox Code Playgroud)
  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  
    
    Run Code Online (Sandbox Code Playgroud)
  11. 一级子程序:假设您需要经常执行一系列步骤.您可以将步骤存储在内存中的命名位置,然后在需要执行它们(调用)时跳转到该位置.在序列结束时,您需要返回调用以继续执行的程度.使用此机制,您可以通过编写核心指令来创建新指令(子例程).

    实施:(无需新概念)

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

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

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

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

  13. Subroutines Now that we have a stack we can implement proper subroutines allowing nested calls. The implementation is similar, but instead of storing the Instruction Pointer in a predefined memory position, we "push" the value of the IP in the stack. At the end of the subroutine, we just "pop" the value from the stack, effectively jumping back to the instruction after the original call. This implementation, having a "stack" allows calling a subroutine from another subroutine. With this implementation we can create several levels of abstraction when defining new instructions as subroutines, by using core instructions or other subroutines as building blocks.

  14. 递归:当子程序调用自身时会发生什么?这称为"递归".

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

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

...

到达递归我们就到此为止.

结论:

在Von Neumann架构中,显然"迭代"是比"递归"更简单/基本的概念.我们在7级有一个"迭代"形式,而"递归"在概念层次的14级.

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

哪一个更好"?

  • 在处理简单的顺序数据结构时,应该使用"迭代",并且在"简单循环"的任何地方都可以使用"迭代".

  • 当您需要处理递归数据结构时(我喜欢称之为"分形数据结构"),或者当递归解决方案明显更"优雅"时,您应该使用"递归".

建议:使用最好的工具,但要了解每个工具的内部工作方式,以便明智地选择.

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

  • 你假设你的进步是 1) 必要的和 2) 它在你做的时候停止了。但是 1)它没有必要(例如,递归可以变成跳转,正如公认的答案所解释的那样,所以不需要堆栈),并且 2)它不必停在那里(例如,最终你将达到并发处理,如果您在第二步中引入了可变状态,则可能需要锁定,因此一切都会变慢;而像函数/递归解决方案这样的不可变解决方案将避免锁定,因此可以更快/更并行) . (3认同)
  • “递归可以变成跳跃”是错误的。真正有用的递归不能转换为跳转。尾调用“递归”是一种特殊情况,其中您将“作为递归”编码为可以由编译器简化为循环的某种形式。另外,您将“不可变”与“递归”混为一谈,这些是正交的概念。 (2认同)

sta*_*lue 34

在替代方法是显式管理堆栈时,递归可能会更快,就像您提到的排序或二叉树算法一样.

我有一个案例,用Java重写递归算法使它变慢.

因此,正确的方法是首先以最自然的方式编写它,只有在分析显示它是关键的时候进行优化,然后测量所谓的改进.

  • +1表示“ _首先以最自然的方式写入它_”,尤其是“ _仅在分析显示为关键时才优化_” (2认同)
  • +1用于确认硬件堆栈可能比软件更快,手动实现堆内堆栈.有效地显示所有"否"答案都是不正确的. (2认同)

mko*_*ela 12

尾递归和循环一样快.许多函数式语言都在其中实现了尾递归.

  • 当尾部调用优化实现时,尾递归*可以像循环一样快:http://c2.com/cgi/wiki?TickCallOptimization (35认同)

Pas*_*nen 12

考虑每个必须要做的事情,迭代和递归.

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

你看,这里差异不大.

(我假设递归是一个尾调用,编译器知道这个优化).


Pat*_*ter 8

这里的大多数答案都忘记了为什么递归通常比迭代解决方案慢的明显罪魁祸首.它与堆栈帧的构建和拆除相关联,但并非如此.对于每次递归,自动变量的存储通常有很大差异.在具有循环的迭代算法中,变量通常保存在寄存器中,即使它们溢出,它们也将驻留在1级缓存中.在递归算法中,变量的所有中间状态都存储在堆栈中,这意味着它们会在内存中产生更多溢出.这意味着即使它进行相同数量的操作,它也会在热循环中进行大量内存访问,更糟糕的是,这些内存操作的重用率很低,使缓存效率降低.

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


Bjö*_*ist 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);
        }
    });
}
Run Code Online (Sandbox Code Playgroud)

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

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);
            }
        });
    }
}
Run Code Online (Sandbox Code Playgroud)

理解代码的细节并不重要.只是这p是节点,P_FOR_EACH_CHILD并且行走.在迭代版本中,我们需要一个显式堆栈,st在该堆栈上推送节点然后弹出和操作.

递归函数比迭代函数运行得快得多.原因是因为在后者中,对于每个项目,需要a CALL到函数st_push,然后是另一个st_pop.

在前者中,您只CALL对每个节点进行递归.

另外,访问callstack上的变量非常快.这意味着您正在从内存中读取,这可能始终位于最内层缓存中.另一方面,显式堆栈必须由malloc以下内容支持:来自堆的ed内存,访问速度要慢得多.

经过精心优化,如内联st_pushst_pop,我可以达到与递归方法大致均等.但至少在我的计算机上,访问堆内存的成本要高于递归调用的成本.

但是这种讨论大多没有实际意义,因为递归树行走是不正确的.如果你有足够大的树,你将用完callstack空间,这就是必须使用迭代算法的原因.

  • 我可以确认我遇到过类似的情况,并且在某些情况下递归可以比堆上的手动堆栈更快。特别是当编译器中打开优化以避免调用函数的一些开销时。 (2认同)
  • 对 7 节点二叉树进行了 10^8 次前序遍历。递归25ns。显式堆栈(是否进行绑定检查——没有太大区别)~ 15ns。除了推送和跳转之外,递归还需要做更多的事情(寄存器保存和恢复+(通常)更严格的帧对齐)。(动态链接库中的 PLT 会使情况变得更糟。)您不需要堆分配显式堆栈。您可以创建一个 obstack,其第一帧位于常规调用堆栈上,这样您就不会在不超过第一个块的最常见情况下牺牲缓存局部性。 (2认同)
  • 感谢您的回答。我在 leet 代码树比较中遇到了这个问题,并且无法弄清楚为什么我的迭代解决方案比 95% 的递归解决方案慢。堆栈内存和多次调用非常有意义,特别是因为我使用的是 Java 及其混乱的内存管理。 (2认同)

Amb*_*ber 5

一般来说,不,在任何具有两种形式的可行实现的实际用法中,递归不会比循环更快。我的意思是,当然,您可以编写永远需要的循环,但是会有更好的方法来实现相同的循环,该循环可以优于通过递归实现相同问题的任何实现。

关于原因,你一语中的;创建和销毁堆栈帧比简单的跳转更昂贵。

但是,请注意我说“两种形式都有可行的实现”。对于像许多排序算法这样的事情,往往没有一种非常可行的方法来实现它们,而不能有效地建立自己的堆栈版本,因为子“任务”的产生本质上是该过程的一部分。因此,递归可能与尝试通过循环实现算法一样快。

编辑:这个答案假设非函数式语言,其中大多数基本数据类型是可变的。它不适用于函数式语言。