递归比循环更快吗?

时间:2010-04-16 06:42:43

标签: performance loops recursion iteration

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

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

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

12 个答案:

答案 0 :(得分:323)

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

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

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

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

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

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

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

答案 1 :(得分:47)

  

递归比循环快吗?

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

说明:

如果从头开始构建通用计算机的最小操作,"迭代"首先是作为一个构建块,并且资源密集度低于#34;递归",ergo更快。

从头开始构建伪计算机:

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

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

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

  2. 说明:执行某项操作 - 转换单元格,更改其值。 改变状态。每条有趣的指令都会执行转换。基本说明如下:

    a)设置&移动记忆细胞

    • 将值存储到内存中,例如: 存储5米[4]
    • 将值复制到其他位置:例如: 存储m [4] m [8]

    b)逻辑与算术

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

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

    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)
    

    所以即使是中缀表达式,因为你有一个特定的评估顺序,是一个命令性的指令序列。表达式暗示以特定顺序进行的操作序列,并且因为有步骤,所以还有一个隐含的"结果"中间变量。

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

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

  6. 跳转 - 一旦您有一个有序的步数和一个指令指针,您就可以应用" 商店"指令改变指令指针本身的值。我们将使用新名称跳转来使用商店指令。我们使用一个新名称,因为更容易将其视为一个新概念。通过改变指令指针,我们指示代理“转到步骤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. 递归:当子程序调用自身时会发生什么?这被称为"递归"。

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

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

  15. ...

    到达 递归 后,我们就此止步。

    结论:

    在Von Neumann架构中,显然 &#34;迭代&#34; 是一个比 “递归&#34;更简单/基本的概念; 即可。我们在第7级有一种&#34;迭代&#34; ,而&#34;递归&#34; 在概念层次结构的第14级。

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

    哪一个更好&#34;?

    • 您应该使用&#34;迭代&#34;当您处理简单的,顺序的数据结构时,以及“简单循环”所处的任何地方。

    • 你应该使用&#34;递归&#34;当你需要处理一个递归数据结构时(我喜欢称之为“分形数据结构”),或者当递归解决方案明显更“优雅”时。

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

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

答案 2 :(得分:33)

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

我有一个案例,用Java重写递归算法会让它变慢。

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

答案 3 :(得分:12)

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

答案 4 :(得分:12)

考虑每个,迭代和递归绝对必须做的事情。

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

你知道这里差异不大。

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

答案 5 :(得分:8)

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

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

答案 6 :(得分: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

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

通过仔细优化,例如内联st_pushst_pop,我可以与递归方法大致相同。但至少在我的计算机上,访问堆内存的成本要高于递归调用的成本。

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

答案 7 :(得分:2)

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

答案 8 :(得分:1)

功能性编程更多的是关于&#34; 什么&#34;而不是&#34; 如何&#34;。

语言实现者会找到一种优化代码在其下工作方式的方法,如果我们不尝试使其更优化而不是它需要的话。递归也可以在支持尾调用优化的语言中进行优化。

从程序员的角度来看,更重要的是可读性和可维护性,而不是首先进行优化。再次,&#34;过早优化是所有邪恶的根源&#34;。

答案 9 :(得分:0)

通常,不,递归不会比任何实际用法中的循环更快,这两种形式都有可行的实现。我的意思是,当然,你可以编写永远占用的循环,但是有更好的方法来实现同一个循环,它可以通过递归优于同一问题的任何实现。

关于原因,你在头上钉了一针;创建和销毁堆栈帧比简单的跳转更昂贵。

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

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

答案 10 :(得分:0)

这是猜测。一般来说,如果两者都使用了非常好的算法(不计算实现难度),那么递归可能不会经常或者经常出现大小问题的循环,如果使用带有tail call recursion的语言(和尾部),它可能会有所不同递归算法和循环也作为语言的一部分) - 它可能会非常相似,甚至可能在某些时候更喜欢递归。

答案 11 :(得分: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;
  }