递归或迭代?

时间:2008-09-16 13:33:25

标签: performance algorithm language-agnostic recursion

如果我们在算法中使用循环而不是递归,反之亦然,那么两者是否可以达到同样的目的?例如:检查给定的字符串是否是回文。 我已经看到许多程序员使用递归作为一种手段来展示一个简单的迭代算法可以适应账单。 编译器在决定使用什么方面起着至关重要的作用吗?

30 个答案:

答案 0 :(得分:301)

循环可以为您的程序带来性能提升。递归可以为程序员带来性能提升。选择哪种情况对您的情况更重要!

答案 1 :(得分:173)

递归可能会更昂贵,具体取决于递归函数是否为tail recursive(最后一行是递归调用)。尾部递归由编译器识别并优化为其迭代对应物(同时保持代码中简洁明了的实现)。

我会以最有意义的方式编写算法,并且对于那些必须在几个月或几年内维护代码的可怜的傻瓜(无论是你自己还是其他人)来说,它是最清晰的。如果遇到性能问题,那么对代码进行概要分析,然后通过转移到迭代实现来查看优化。您可能需要查看memoizationdynamic programming

答案 2 :(得分:76)

将递归与迭代进行比较就像将十字头螺丝刀与平头螺丝刀进行比较一样。在大多数情况下,您可以移除任何带平头的十字头螺钉,但如果您使用专为该螺钉设计的螺丝刀,那会更容易吗?

有些算法只是因为它们的设计方式(Fibonacci序列,遍历树状结构等)而适合递归。递归使算法更简洁,更易于理解(因此可共享和可重用)。

此外,一些递归算法使用“懒惰评估”,这使得它们比迭代兄弟更有效。这意味着他们只在需要时进行昂贵的计算,而不是每次循环运行。

这应该足以让你入门。我也会为你挖掘一些文章和例子。

链接1: Haskel与PHP(递归与迭代)

以下是程序员必须使用PHP处理大型数据集的示例。他展示了使用递归在Haskel中处理是多么容易,但由于PHP没有简单的方法来完成相同的方法,他被迫使用迭代来获得结果。

  

http://blog.webspecies.co.uk/2011-05-31/lazy-evaluation-with-php.html

链接2:掌握递归

大多数递归的不良声誉来自命令式语言的高成本和低效率。本文作者讨论了如何优化递归算法以使其更快更有效。他还讨论了如何将传统循环转换为递归函数以及使用尾端递归的好处。他的结束语真的总结了我认为的一些关键点:

  

“递归编程为程序员提供了更好的组织方式   代码以可维护和逻辑一致的方式。“

     

https://developer.ibm.com/articles/l-recurs/

链接3:递归是否比循环更快? (答案)

这是一个与您的类似的stackoverflow问题的答案的链接。作者指出,许多与递归或循环相关的基准都是非常特定于语言。使用循环的命令式语言通常更快,而递归则更慢,反之亦然。我想从这个链接中得出的主要观点是,在语言不可知/情境盲目的情况下回答这个问题非常困难。

  

Is recursion ever faster than looping?

答案 3 :(得分:16)

递归在内存中成本更高,因为每次递归调用通常都需要将内存地址推送到堆栈 - 以便稍后程序可以返回到该点。

尽管如此,在很多情况下,递归比循环更自然,更易读 - 就像使用树时一样。在这些情况下,我建议坚持递归。

答案 4 :(得分:11)

通常,人们会认为性能损失是另一个方向。递归调用可以导致额外堆栈帧的构建;对此的处罚有所不同。此外,在某些语言(如Python(更准确地说,在某些语言的某些实现中))中,您可以轻松地对可能以递归方式指定的任务运行堆栈限制,例如在树数据结构中查找最大值。在这些情况下,你真的想坚持循环。

编写好的递归函数可以在某种程度上减少性能损失,假设你有一个优化尾递归等的编译器。(另外要仔细检查以确保函数真的是尾递归的 - 这是很多东西之一人们会犯错误。)

除了“边缘”情况(高性能计算,非常大的递归深度等)之外,最好采用最明确表达您的意图,设计良好且可维护的方法。仅在确定需求后才进行优化。

答案 5 :(得分:8)

对于可以分解为多个,更小的部分的问题,递归优于迭代。

例如,要制作递归Fibonnaci算法,将fib(n)分解为fib(n-1)和fib(n-2)并计算两个部分。迭代只允许您反复重复单个函数。

然而,Fibonacci实际上是一个破碎的例子,我认为迭代实际上更有效率。注意,fib(n)= fib(n-1)+ fib(n-2)和fib(n-1)= fib(n-2)+ fib(n-3)。 fib(n-1)计算两次!

更好的例子是树的递归算法。分析父节点的问题可以分解为分析每个子节点的多个小问题。与Fibonacci示例不同,较小的问题彼此独立。

所以是的 - 对于可以分解为多个,更小,独立,类似问题的问题,递归优于迭代。

答案 6 :(得分:7)

使用递归时性能会下降,因为调用任何语言的方法都需要进行大量准备:调用代码发布返回地址,调用参数,某些其他上下文信息(如处理器寄存器)可能会保存在某处,并且返回时间被调用的方法发布一个返回值,然后由调用者检索该返回值,并且将恢复先前保存的任何上下文信息。迭代和递归方法之间的性能差异在于这些操作所花费的时间。

从实现的角度来看,当处理调用上下文所花费的时间与执行方法所花费的时间相当时,您真正开始注意到差异。如果您的递归方法需要更长的时间来执行,那么调用上下文管理部分,采用递归方式,因为代码通常更易读且易于理解,您不会注意到性能损失。否则,出于效率原因进行迭代。

答案 7 :(得分:6)

我相信java中的尾递归目前尚未优化。有关LtU和相关链接的this讨论中详细介绍了详细信息。 可能是即将发布的版本7中的一个功能,但显然它与Stack Inspection相结合会带来一定的困难,因为某些帧会丢失。自Java 2以来,Stack Inspection已被用于实现其细粒度的安全模型。

http://lambda-the-ultimate.org/node/1333

答案 8 :(得分:5)

在许多情况下,由于缓存,递归更快,从而提高了性能。例如,这是使用传统合并例程的合并排序的迭代版本。由于缓存改进的性能,它将比递归实现运行得慢。

迭代实现

public static void sort(Comparable[] a)
{
    int N = a.length;
    aux = new Comparable[N];
    for (int sz = 1; sz < N; sz = sz+sz)
        for (int lo = 0; lo < N-sz; lo += sz+sz)
            merge(a, lo, lo+sz-1, Math.min(lo+sz+sz-1, N-1));
}

递归实施

private static void sort(Comparable[] a, Comparable[] aux, int lo, int hi)
{
    if (hi <= lo) return;
    int mid = lo + (hi - lo) / 2;
    sort(a, aux, lo, mid);
    sort(a, aux, mid+1, hi);
    merge(a, aux, lo, mid, hi);
}

PS - 凯文韦恩教授(普林斯顿大学)就Coursera上的算法课程讲述了这一点。

答案 9 :(得分:5)

递归在某些情况下非常有用。例如,考虑用于查找阶乘

的代码
int factorial ( int input )
{
  int x, fact = 1;
  for ( x = input; x > 1; x--)
     fact *= x;
  return fact;
}

现在通过使用递归函数

来考虑它
int factorial ( int input )
{
  if (input == 0)
  {
     return 1;
  }
  return input * factorial(input - 1);
}

通过观察这两个,我们可以看到递归很容易理解。 但如果不小心使用它也会非常容易出错。 假设如果我们错过if (input == 0),则代码将执行一段时间并以堆栈溢出结束。

答案 10 :(得分:5)

在许多情况下,它为迭代方法提供了更优雅的解决方案,常见的例子是遍历二叉树,因此维护不一定更困难。通常,迭代版本通常更快一些(并且在优化期间可以很好地替换递归版本),但递归版本更容易理解和正确实现。

答案 11 :(得分:4)

使用递归,每次“迭代”都会产生函数调用的成本,而使用循环时,通常只需要增加/减少。因此,如果循环的代码并不比递归解的代码复杂得多,则循环通常优于递归。

答案 12 :(得分:4)

递归和迭代取决于您要实现的业务逻辑,但在大多数情况下,它可以互换使用。大多数开发人员都会进行递归,因为它更容易理解。

答案 13 :(得分:4)

这取决于语言。在Java中,您应该使用循环。函数式语言优化递归。

答案 14 :(得分:3)

如果您只是在列表上进行迭代,那么请确保迭代。

其他几个答案提到了(深度优先)树遍历。它确实是一个很好的例子,因为它对于一个非常常见的数据结构来说是非常常见的事情。递归对于这个问题非常直观。

在这里查看“查找”方法: http://penguin.ewu.edu/cscd300/Topic/BSTintro/index.html

答案 15 :(得分:3)

递归比任何可能的迭代定义更简单(因此更基本)。您可以定义仅具有pair of combinators的图灵完备系统(是的,即使递归本身也是这种系统中的衍生概念)。 Lambda微积分是一个同样强大的基本系统,具有递归函数。但是如果你想正确定义一个迭代,你需要更多的原语来开始。

至于代码 - 否,递归代码实际上比纯迭代代码更容易理解和维护,因为大多数数据结构都是递归的。当然,为了使它正确,人们需要一种支持高阶函数和闭包的语言,至少 - 以一种简洁的方式获得所有标准的组合器和迭代器。当然,在C ++中,复杂的递归解决方案看起来有点难看,除非你是FC++等的核心用户。

答案 16 :(得分:2)

它取决于“递归深度”。 它取决于函数调用开销将影响总执行时间的程度。

例如,由于以下原因,以递归方式计算经典因子是非常低效的: - 数据溢出的风险 - 堆栈溢出的风险 - 函数调用开销占执行时间的80%

在国际象棋游戏中开发一种用于位置分析的最小 - 最大算法,将分析随后的N个移动,可以在“分析深度”的递归中实现(正如我正在做的那样^ _ ^)

答案 17 :(得分:2)

递归?我从哪里开始,维基会告诉你“这是以自我相似的方式重复项目的过程”

在我做C的那天,C ++递归是一个神派,像“Tail recursion”之类的东西。您还会发现许多排序算法都使用递归。快速排序示例:http://alienryderflex.com/quicksort/

递归就像任何其他对特定问题有用的算法一样。也许您可能不会立即或经常使用,但会有问题,您会很高兴它可用。

答案 18 :(得分:2)

我认为在(非尾部)递归中,每次调用函数时都会有分配新堆栈的性能损失(当然取决于语言)。

答案 19 :(得分:2)

在C ++中,如果递归函数是模板函数,则编译器有更多机会对其进行优化,因为所有类型推导和函数实例化都将在编译时发生。如果可能,现代编译器也可以内联函数。因此,如果在-O3中使用-O2g++等优化标记,则递归可能比迭代更快。在迭代代码中,编译器获得优化的机会较少,因为它已经处于或多或少的最佳状态(如果编写得足够好)。

在我的例子中,我试图通过使用Armadillo矩阵对象以递归和迭代方式平方来实现矩阵求幂。该算法可以在这里找到...... https://en.wikipedia.org/wiki/Exponentiation_by_squaring。 我的函数是模板化的,我已经计算了1,000,000 12x12矩阵,它被提升到幂10。我得到了以下结果:

iterative + optimisation flag -O3 -> 2.79.. sec
recursive + optimisation flag -O3 -> 1.32.. sec

iterative + No-optimisation flag  -> 2.83.. sec
recursive + No-optimisation flag  -> 4.15.. sec

这些结果是使用带有c ++ 11标志(-std=c++11)的gcc-4.8和带有Intel mkl的Armadillo 6.1获得的。英特尔编译器也显示了类似的结果。

答案 20 :(得分:1)

迈克是对的。尾部递归由Java编译器或JVM 优化。你将总是得到一个像这样的堆栈溢出:

int count(int i) {
  return i >= 100000000 ? i : count(i+1);
}

答案 21 :(得分:1)

将其编写为递归或练习可能会很有趣。

但是,如果要在生产中使用该代码,则需要考虑堆栈溢出的可能性。

尾部递归优化可以消除堆栈溢出,但是您是否想要解决它的麻烦,并且您需要知道可以依靠它在您的环境中进行优化。

算法每次重复执行,数据大小或n减少多少?

如果每次递归时都将数据或n的大小减少一半,那么通常不必担心堆栈溢出。假设,如果要使程序堆栈溢出需要深度为4000或10000级,则程序堆栈的数据大小大约需要为2 4000 。从角度来看,最近最大的存储设备可以容纳2 61 个字节,如果您有2 61 个这样的设备,则只能处理2 122 数据大小。如果您查看宇宙中的所有原子,则估计它可能小于2 84 。如果您需要自估计诞生140亿年前的宇宙诞生以来的每一毫秒内处理宇宙及其状态中的所有数据,则可能只有2 153 。因此,如果您的程序可以处理2 4000 数据单位或n,则可以处理Universe中的所有数据,并且程序不会堆栈溢出。如果您不需要处理2 4000 (4000位整数)那么大的数字,那么通常不必担心堆栈溢出。

但是,如果每次递归都将数据或n的大小减小一定数量,那么当n变成20000时,您可能会遇到堆栈溢出的情况。也就是说,当n1000时程序运行良好,并且您认为程序很好,然后在将来的某个时间n为{{ 1}}或5000

因此,如果您有可能发生堆栈溢出,请尝试使其成为迭代解决方案。

答案 22 :(得分:1)

您必须记住,利用过于递归的深度,您将遇到Stack Overflow,具体取决于允许的堆栈大小。为了防止这种情况,请确保提供一些结束递归的基本案例。

答案 23 :(得分:1)

递归的缺点是使用递归编写的算法具有O(n)空间复杂度。 虽然迭代aproach的空间复杂度为O(1)。这是使用迭代而不是递归的优势。 那为什么我们使用递归呢?

见下文。

有时使用递归编写算法更容易,而使用迭代编写相同算法稍微困难一些。在这种情况下,如果您选择遵循迭代方法,则必须自己处理堆栈。

答案 24 :(得分:0)

据我所知,Perl不会优化尾递归调用,但你可以伪造它。

sub f{
  my($l,$r) = @_;

  if( $l >= $r ){
    return $l;
  } else {

    # return f( $l+1, $r );

    @_ = ( $l+1, $r );
    goto &f;

  }
}

首次调用时,它将在堆栈上分配空间。然后它将更改其参数,并重新启动子例程,而不向堆栈添加任何其他内容。因此,它会假装它从不称呼它自己,将它改变为一个迭代过程。

请注意,没有“my @_;”或“local @_;”,如果您这样做将不再有效。

答案 25 :(得分:0)

我发现这些方法之间的另一个区别。 它看起来简单而无关紧要,但是在您准备面试时它起着非常重要的作用,因此这个话题出现了,因此请仔细观察。

简而言之: 1)迭代后遍历并不容易-这使DFT更加复杂 2)循环可以更轻松地进行递归检查

详细信息:

在递归情况下,创建前后遍历很容易:

想象一个非常标准的问题:“当任务依赖于其他任务时,打印应执行以执行任务5的所有任务”

示例:

    //key-task, value-list of tasks the key task depends on
    //"adjacency map":
    Map<Integer, List<Integer>> tasksMap = new HashMap<>();
    tasksMap.put(0, new ArrayList<>());
    tasksMap.put(1, new ArrayList<>());

    List<Integer> t2 = new ArrayList<>();
    t2.add(0);
    t2.add(1);
    tasksMap.put(2, t2);

    List<Integer> t3 = new ArrayList<>();
    t3.add(2);
    t3.add(10);
    tasksMap.put(3, t3);

    List<Integer> t4 = new ArrayList<>();
    t4.add(3);
    tasksMap.put(4, t4);

    List<Integer> t5 = new ArrayList<>();
    t5.add(3);
    tasksMap.put(5, t5);

    tasksMap.put(6, new ArrayList<>());
    tasksMap.put(7, new ArrayList<>());

    List<Integer> t8 = new ArrayList<>();
    t8.add(5);
    tasksMap.put(8, t8);

    List<Integer> t9 = new ArrayList<>();
    t9.add(4);
    tasksMap.put(9, t9);

    tasksMap.put(10, new ArrayList<>());

    //task to analyze:
    int task = 5;


    List<Integer> res11 = getTasksInOrderDftReqPostOrder(tasksMap, task);
    System.out.println(res11);**//note, no reverse required**

    List<Integer> res12 = getTasksInOrderDftReqPreOrder(tasksMap, task);
    Collections.reverse(res12);//note reverse!
    System.out.println(res12);

    private static List<Integer> getTasksInOrderDftReqPreOrder(Map<Integer, List<Integer>> tasksMap, int task) {
         List<Integer> result = new ArrayList<>();
         Set<Integer> visited = new HashSet<>();
         reqPreOrder(tasksMap,task,result, visited);
         return result;
    }

private static void reqPreOrder(Map<Integer, List<Integer>> tasksMap, int task, List<Integer> result, Set<Integer> visited) {

    if(!visited.contains(task)) {
        visited.add(task);
        result.add(task);//pre order!
        List<Integer> children = tasksMap.get(task);
        if (children != null && children.size() > 0) {
            for (Integer child : children) {
                reqPreOrder(tasksMap,child,result, visited);
            }
        }
    }
}

private static List<Integer> getTasksInOrderDftReqPostOrder(Map<Integer, List<Integer>> tasksMap, int task) {
    List<Integer> result = new ArrayList<>();
    Set<Integer> visited = new HashSet<>();
    reqPostOrder(tasksMap,task,result, visited);
    return result;
}

private static void reqPostOrder(Map<Integer, List<Integer>> tasksMap, int task, List<Integer> result, Set<Integer> visited) {
    if(!visited.contains(task)) {
        visited.add(task);
        List<Integer> children = tasksMap.get(task);
        if (children != null && children.size() > 0) {
            for (Integer child : children) {
                reqPostOrder(tasksMap,child,result, visited);
            }
        }
        result.add(task);//post order!
    }
}

请注意,递归后顺序遍历不需要后续结果反转。儿童先打印,问题中的任务打印最后。一切都很好。您可以进行递归的预遍历(也如上所示),并且需要对结果列表进行翻转。

使用迭代方法不是那么简单!在迭代(单栈)方法中,您只能进行预排序遍历,因此必须在最后反转结果数组:

    List<Integer> res1 = getTasksInOrderDftStack(tasksMap, task);
    Collections.reverse(res1);//note reverse!
    System.out.println(res1);

    private static List<Integer> getTasksInOrderDftStack(Map<Integer, List<Integer>> tasksMap, int task) {
    List<Integer> result = new ArrayList<>();
    Set<Integer> visited = new HashSet<>();
    Stack<Integer> st = new Stack<>();


    st.add(task);
    visited.add(task);

    while(!st.isEmpty()){
        Integer node = st.pop();
        List<Integer> children = tasksMap.get(node);
        result.add(node);
        if(children!=null && children.size() > 0){
            for(Integer child:children){
                if(!visited.contains(child)){
                    st.add(child);
                    visited.add(child);
                }
            }
        }
        //If you put it here - it does not matter - it is anyway a pre-order
        //result.add(node);
    }
    return result;
}

看起来很简单,不是吗?

但这是一些采访中的陷阱。

这表示以下含义:使用递归方法,您可以实现“深度优先遍历”,然后选择所需的顺序(仅通过更改“打印”的位置即可,在本例中为“添加至”)结果列表”)。使用迭代(单栈)方法,您可以轻松仅进行预遍历,因此在需要先打印子项的情况下(几乎所有需要从底部节点开始打印的情况,向上)-您遇到了麻烦。如果遇到这种麻烦,可以稍后再撤消,但这将是算法的补充。如果面试官正在看他的手表,那对您来说可能是个问题。虽然存在很多复杂的方法来进行迭代的后遍历遍历,但是它们不简单。示例:https://www.geeksforgeeks.org/iterative-postorder-traversal-using-stack/

因此,最重要的是:我将在采访中使用递归,它更易于管理和解释。在任何紧急情况下,您都可以轻松地进行从订单遍历到后遍历。有了迭代,您就没有那么灵活了。

我将使用递归,然后说:“好吧,但是迭代可以为我提供对已用内存的更直接控制,我可以轻松地测量堆栈大小并禁止某些危险的溢出。”

另一种递归-避免/注意图中的循环更简单。

示例(伪代码):

dft(n){
    mark(n)
    for(child: n.children){
        if(marked(child)) 
            explode - cycle found!!!
        dft(child)
    }
    unmark(n)
}

答案 26 :(得分:0)

仅使用Chrome 45.0.2454.85 m,递归似乎更快。

以下是代码:

(function recursionVsForLoop(global) {
    "use strict";

    // Perf test
    function perfTest() {}

    perfTest.prototype.do = function(ns, fn) {
        console.time(ns);
        fn();
        console.timeEnd(ns);
    };

    // Recursion method
    (function recur() {
        var count = 0;
        global.recurFn = function recurFn(fn, cycles) {
            fn();
            count = count + 1;
            if (count !== cycles) recurFn(fn, cycles);
        };
    })();

    // Looped method
    function loopFn(fn, cycles) {
        for (var i = 0; i < cycles; i++) {
            fn();
        }
    }

    // Tests
    var curTest = new perfTest(),
        testsToRun = 100;

    curTest.do('recursion', function() {
        recurFn(function() {
            console.log('a recur run.');
        }, testsToRun);
    });

    curTest.do('loop', function() {
        loopFn(function() {
            console.log('a loop run.');
        }, testsToRun);
    });

})(window);

<强>结果

// 100使用标准for循环

运行

循环运行100x。 完成时间: 7.683ms

// 100运行使用函数递归方法w /尾递归

100x递归运行。 完成时间: 4.841ms

在下面的屏幕截图中,每次测试以300个周期运行时,递归再次以更大的优势获胜

Recursion wins again!

答案 27 :(得分:0)

如果迭代是原子的,并且比推送新的堆栈框架创建新的线程更昂贵,那么你有多个内核您的运行时环境可以使用所有这些,然后当与多线程结合使用时,递归方法可以产生巨大的性能提升。如果平均迭代次数不可预测,那么最好使用一个线程池来控制线程分配并防止你的进程创建太多线程并占用系统。

例如,在某些语言中,存在递归多线程合并排序实现。

但同样,多线程可以与循环而不是递归一起使用,因此这种组合的工作效果取决于更多因素,包括操作系统及其线程分配机制。

答案 28 :(得分:-1)

我将通过“归纳”设计一个Haskell数据结构来回答你的问题,这是一种递归的“双重”。然后我将展示这种二元性如何导致美好的事物。

我们为简单的树引入一种类型:

data Tree a = Branch (Tree a) (Tree a)
            | Leaf a
            deriving (Eq)

我们可以将这个定义读作“树是一个分支(包含两棵树)或是一个叶子(包含一个数据值)”。叶子是一种极小的情况。如果树不是叶子,那么它必须是包含两棵树的复合树。这是唯一的案例。

让我们做一棵树:

example :: Tree Int
example = Branch (Leaf 1) 
                 (Branch (Leaf 2) 
                         (Leaf 3))

现在,我们假设我们要为树中的每个值添加1。我们可以通过致电:

来做到这一点
addOne :: Tree Int -> Tree Int
addOne (Branch a b) = Branch (addOne a) (addOne b)
addOne (Leaf a)     = Leaf (a + 1)

首先,请注意这实际上是一个递归定义。它将数据构造函数Branch和Leaf作为案例(因为Leaf是最小的,这是唯一可能的情况),我们确信该函数将终止。

以迭代风格编写addOne需要什么?什么会循环到任意数量的分支看起来像?

此外,就“仿函数”而言,这种递归通常可以被考虑在内。我们可以通过定义:

将树变成Functors
instance Functor Tree where fmap f (Leaf a)     = Leaf (f a)
                            fmap f (Branch a b) = Branch (fmap f a) (fmap f b)

并定义:

addOne' = fmap (+1)

我们可以分解其他递归方案,例如代数数据类型的catamorphism(或fold)。使用catamorphism,我们可以写:

addOne'' = cata go where
           go (Leaf a) = Leaf (a + 1)
           go (Branch a b) = Branch a b

答案 29 :(得分:-2)

如果您使用内置内存管理中没有的语言进行编程,则只会发生堆栈溢出....否则,请确保您的函数中有某些内容(或函数调用,STDLbs)等)。如果没有递归,就不可能有像Google或SQL这样的东西,或任何必须有效地对大型数据结构(类)或数据库进行排序的地方。

如果你想迭代文件,递归就是你要去的方式,非常确定&#39;如何找到* | ?grep *&#39;作品。有点双重递归,特别是管道(但不要像许多人一样做很多喜欢的系统调用,如果你有什么东西可以放在那里供其他人使用)。 / p>

高级语言甚至clang / cpp可能会在后台实现它。