递归比循环快吗?

Posted

技术标签:

【中文标题】递归比循环快吗?【英文标题】:Is recursion ever faster than looping? 【发布时间】:2011-02-08 17:11:19 【问题描述】:

我知道递归有时比循环干净得多,而且我并没有问什么时候应该使用递归而不是迭代,我知道已经有很多问题了。

我要问的是,递归永远比循环快吗?在我看来,你总是能够优化循环并让它比递归函数执行得更快,因为循环不存在不断设置新的堆栈帧。

我正在寻找在递归是处理数据的正确方法的应用程序中递归是否更快,例如在某些排序函数、二叉树等中。

【问题讨论】:

有时迭代过程或某些重复的封闭式公式需要几个世纪才能出现。我认为只有在那些时候递归更快:) lol 就我自己而言,我更喜欢迭代。 ;-) Recursion or Iteration? 的可能重复项 见***.com/questions/2651112/… @PratikDeoghare 不,问题不在于选择完全不同的算法。您始终可以将递归函数转换为使用循环的功能相同的方法。例如,this answer has the same algorithm in both recursive and looping format。通常,您会将递归函数的参数元组放入堆栈,压入堆栈以调用,从堆栈中丢弃以从函数返回。 【参考方案1】:

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

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

【讨论】:

【参考方案2】:

递归比循环快吗?

不,迭代总是比递归快。 (在冯诺依曼架构中)

说明:

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

从零开始构建伪计算机:

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

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

    第一个概念:内存单元、存储、状态。要执行某些操作,您需要 places 来存储最终和中间结果值。假设我们有一个无限的“整数”单元数组,称为 Memory,M[0..Infinite]。

    说明:做点什么——变换一个单元格,改变它的值。 改变状态。每条有趣的指令都会执行一次转换。基本说明是:

    a) 设置和移动记忆单元

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

    b) 逻辑和算术

    and, or, xor, not 添加、子、mul、div。例如添加 m[7] m[8]

    执行代理:现代 CPU 中的 核心。 “代理”是可以执行指令的东西。 Agent 也可以是遵循纸上算法的人。

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

    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)
    

    因此,即使是中缀表达式,由于您有特定的求值顺序,也是命令式指令序列。表达式暗示要按特定顺序进行的一系列操作,并且因为有步骤,所以也有一个隐含的“结果”中间变量。

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

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

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

    无限迭代:通过跳回来,现在您可以让代理“重复”一定数量的步骤。此时我们有无限迭代。

                       1. mov 1000 m[30]
                       2. sub m[30] 1
                       3. jmp-to 2  // infinite loop
    

    有条件的 - 有条件地执行指令。使用“条件”子句,您可以根据当前状态(可以使用前一条指令设置)有条件地执行几条指令之一。

    正确迭代:现在有了conditional子句,我们可以摆脱jump back指令的无限循环。我们现在有一个条件循环,然后是适当的迭代

    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.
    

    命名:为保存数据或保存步骤的特定内存位置命名。这只是一个“方便”。我们不会通过定义内存位置的“名称”来添加任何新指令。 “命名”不是给代理的指令,只是给我们方便。 命名使代码(此时)更易于阅读和更改。

       #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  
    

    一级子程序:假设有一系列步骤需要经常执行。您可以将步骤存储在内存中的指定位置,然后在需要执行它们(调用)时跳转到该位置。在序列结束时,您需要返回调用 点以继续执行。使用这种机制,您可以通过组合核心指令创建新指令(子程序)。

    实施:(无需新概念)

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

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

    更好地实现子例程:您需要一个堆栈

    堆栈:您将内存空间定义为“堆栈”,您可以在堆栈上“推送”值,也可以“弹出”最后一个“推送”的值。要实现堆栈,您需要一个堆栈指针(类似于指令指针),它指向堆栈的实际“头部”。当你“push”一个值时,堆栈指针递减并且你存储这个值。当你“弹出”时,你会得到实际堆栈指针的值,然后堆栈指针会递增。

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

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

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

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

...

已经达到递归我们在这里停下来。

结论:

在冯诺依曼架构中,显然 “迭代” 是比 “递归” 更简单/基本的概念>。我们在第 7 级有一个 “Iteration” 形式,而 “Recursion” 在概念层次结构的第 14 级。

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

哪个“更好”?

当您处理简单的顺序数据结构时,您应该使用“迭代”,并且在任何地方都可以使用“简单循环”。

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

建议:使用最适合工作的工具,但要了解每个工具的内部工作原理,以便明智地选择。

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

【讨论】:

您假设您的进步是 1) 必要的,并且 2) 如果您这样做了,它就会停止。但是 1)没有必要(例如,递归可以变成跳转,正如接受的答案所解释的那样,因此不需要堆栈),以及 2)它不必停在那里(例如,最终你'将达到并发处理,如果您在第二步中引入的可变状态可能需要锁定,因此一切都会变慢;而像函数/递归这样的不可变解决方案将避免锁定,因此可能更快/更并行) . “递归可以变成跳转”为假。真正有用的递归不能变成跳转。尾调用“递归”是一种特殊情况,您将“递归”编码为编译器可以将其简化为循环的东西。此外,您将“不可变”与“递归”混为一谈,这些是正交概念。 “真正有用的递归不能变成跳转” -> 所以尾调用优化在某种程度上是无用的?此外,不可变和递归可能是正交的,但您确实将循环与可变计数器联系起来 - 看看您的第 9 步。在我看来,您认为循环和递归是完全不同的概念;他们不是。 ***.com/questions/2651112/… @hmijail 我认为比“有用”更好的词是“真实”。尾递归不是真正的递归,因为它只是使用函数调用语法来伪装无条件分支,即迭代。真正的递归为我们提供了一个回溯堆栈。然而,尾递归仍然具有表现力,这使得它很有用。当使用尾调用表示迭代代码时,递归的特性使分析代码的正确性变得容易或更容易。虽然这有时会被尾部版本中的额外复杂性(如额外参数)略微抵消。 如果编译器真的将尾递归转化为迭代,那么在讨论其计算复杂性的情况下称其为“递归”是相当愚蠢的。您可能正在谈论您当时输入的键盘品牌。【参考方案3】:

函数式编程更多的是关于“what”而不是“how”。

如果我们不尝试使其比需要的优化程度更高,语言实现者将找到优化代码底层工作方式的方法。递归也可以在支持尾调用优化的语言中进行优化。

程序员的角度来看,更重要的是可读性和可维护性,而不是优化。再说一遍,“过早的优化是万恶之源”。

【讨论】:

【参考方案4】:

这里的大多数答案都是错误。正确答案是视情况而定。例如,这里有两个遍历树的 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,将节点推送到该堆栈上,然后弹出和操作。

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

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

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

通过仔细优化,例如内联st_pushst_pop,我可以大致达到与递归方法相当的水平。但至少在我的电脑上,访问堆内存的开销要比递归调用的开销大。

但是这个讨论主要是没有实际意义的,因为递归树遍历是不正确。如果你有足够大的树,你会用完调用堆栈空间,这就是为什么必须使用迭代算法的原因。

【讨论】:

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

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

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

所以正确的方法是首先以最自然的方式编写它,只有在分析表明它很关键时才进行优化,然后衡量预期的改进。

【讨论】:

+1 表示“首先以最自然的方式编写它”,尤其是“只有在分析表明它很关键时才进行优化 +1 表示承认硬件堆栈可能比手动实现的软件堆内堆栈更快。有效地表明所有“否”的答案都是错误的。 这假设您编写的算法实际上是最快的迭代算法。糟糕的重构/优化也可能在这里。【参考方案6】:

根据理论它是相同的东西。 具有相同 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;
  

【讨论】:

大 O 与“成比例”。所以两者都是O(n),但是对于所有n,一个可能比另一个花费x的时间长。【参考方案7】:

这取决于所使用的语言。你写了“语言不可知论”,所以我会举一些例子。

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

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

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

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

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

列表推导是另一种选择,但它们通常只是迭代、递归或高阶函数的语法糖。

【讨论】:

我 +1,并想评论一下“递归”和“循环”正是人类对其代码的命名。对性能重要的不是你如何命名事物,而是如何编译/解释它们。递归,顾名思义,是一个数学概念,与栈帧和汇编的东西没什么关系。 此外,递归通常是函数式语言中更自然的方法,而迭代通常在命令式语言中更直观。性能差异不太可能很明显,因此只需使用对该特定语言感觉更自然的任何内容。例如,当递归要简单得多时,您可能不想在 Haskell 中使用迭代。 一般递归编译为循环,循环是较低级别的结构。为什么?因为递归通常建立在某些数据结构上,因此引入了Initial F-algebra,并允许您证明一些关于终止的属性以及关于(递归)计算结构的归纳论证。递归编译成循环的过程就是尾调用优化。 最重要的是未执行的操作。您的“IO”越多,您必须处理的越多。 Un-IOing 数据(又称索引)始终是对任何系统的最大性能提升,因为您不必首先处理它。 我已经检查过很多次了,在 Leetcode.com 上解决问题的 Java(主要是命令式语言)中最快的代码是递归代码。这让我很惊讶。【参考方案8】:

考虑一下每个迭代和递归都必须做什么。

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

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

(我假设递归是尾调用并且编译器知道该优化)。

【讨论】:

【参考方案9】:

一般来说,不,在任何实际使用中递归都不会比循环快,这两种形式都有可行的实现。我的意思是,当然,您可以编写需要很长时间的循环,但是有更好的方法来实现相同的循环,通过递归可以胜过任何相同问题的实现。

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

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

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

【讨论】:

这也是为什么在经常使用递归的语言中,编译器通常会优化几种递归情况。例如,在 F# 中,除了使用 .tail 操作码完全支持尾递归函数外,您还经常看到将递归函数编译为循环。 是的。尾递归有时可能是两全其美 - 实现递归任务的功能“适当”方式,以及使用循环的性能。 这通常是不正确的。在某些环境中,突变(与 GC 交互)比尾递归更昂贵,尾递归在输出中转换为更简单的循环,不使用额外的堆栈帧。【参考方案10】:

这是一个猜测。一般来说,如果两者都使用非常好的算法(不计算实现难度),递归可能不会经常或永远不会在相当大的问题上击败循环,如果使用带有tail call recursion的语言(和尾递归算法)可能会有所不同并且循环也作为语言的一部分)——这可能非常相似,有时甚至可能更喜欢递归。

【讨论】:

【参考方案11】:

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

【讨论】:

【参考方案12】:

Tail recursion 与循环一样快。许多函数式语言都实现了尾递归。

【讨论】:

尾递归 可以 与循环一样快,当实现尾调用优化时:c2.com/cgi/wiki?TailCallOptimization

以上是关于递归比循环快吗?的主要内容,如果未能解决你的问题,请参考以下文章

java8 stream 比for循环快吗

倒数比倒数快吗?

比较C++在for循环中的i++和++i以及i++的O2优化的效率:++i真的比i++快吗

比较C++在for循环中的i++和++i以及i++的O2优化的效率:++i真的比i++快吗

比较C++在for循环中的i++和++i以及i++的O2优化的效率:++i真的比i++快吗

比 GetPixel() 快吗?