有哪些方法可以避免递归算法中的堆栈溢出?


44

解决由递归算法引起的堆栈溢出的可能方法是什么?

我正在尝试解决Project Euler问题14,并决定尝试使用递归算法。但是,该程序以java.lang.StackOverflowError停止。可以理解 该算法确实使堆栈溢出,因为我尝试为大量生成Collat​​z序列。

解决方案

所以我想知道:假设您的递归算法编写正确,并且最终会导致堆栈溢出,那么有什么标准方法可以解决堆栈溢出的问题呢?我想到了两个概念:

  1. 尾递归
  2. 迭代

想法(1)和(2)是否正确?还有其他选择吗?

编辑

最好查看一些代码,最好使用Java,C#,Groovy或Scala。

也许不要使用上面提到的Project Euler问题,这样它就不会被其他人宠坏,而是采用其他算法。阶乘可能,或类似的东西。


3
迭代。Memoisation
詹姆斯

2
显然,记忆化时,有实际只能重复计算。
约尔格W¯¯米塔格

2
还值得注意的是,并非所有的语言实现可以做尾递归优化反正
JK。

2
使用corecursion可能比使用递归更好。
约尔格W¯¯米塔格

3
如果您从小于1,000,000的数字开始工作,然后转为1,则此问题的答案涉及到达到1的大约500个步骤。这在给定较小堆栈框架的情况下不应对递归征税。---如果您尝试求解从1开始的问题,然后将其跟随到2,4,8,16,{5,32},然后从那里开始,那您做错了。

Answers:


35

尾调用优化存在于许多语言和编译器中。在这种情况下,编译器会识别以下形式的函数:

int foo(n) {
  ...
  return bar(n);
}

在这里,该语言能够识别出返回的结果是来自另一个函数的结果,并将具有新堆栈帧的函数调用更改为跳转。

实现经典析因方法:

int factorial(n) {
  if(n == 0) return 1;
  if(n == 1) return 1;
  return n * factorial(n - 1);
}

不是因为回报必要的检查尾调用optimizatable。(示例源代码和编译输出

为了使该尾调用可优化,

int _fact(int n, int acc) {
    if(n == 1) return acc;
    return _fact(n - 1, acc * n);
}

int factorial(int n) {
    if(n == 0) return 1;
    return _fact(n, 1);
}

使用编译此代码gcc -O2 -S fact.c(-O2是启用编译器中的优化所必需的,但是-O3的更多优化会使人难以阅读...)

_fact(int, int):
    cmpl    $1, %edi
    movl    %esi, %eax
    je  .L2
.L3:
    imull   %edi, %eax
    subl    $1, %edi
    cmpl    $1, %edi
    jne .L3
.L2:
    rep ret

示例源代码和编译输出

可以在segment中看到.L3jne而不是a call(它使用新的堆栈框架进行子例程调用)。

请注意,这是使用C完成的。Java中的尾调用优化非常困难,并且取决于JVM的实现(也就是说,我还没有看到任何实现该功能的方法,因为这很困难,并且需要堆栈框架的Java安全模型也有其含义。 -TCO避免了这种情况)-tail-recursion + javatail-recursion +优化是浏览的好标记集。您可能会发现其他JVM语言可以优化尾递归更好(TRY的Clojure(这需要RECUR到尾调用优化),或斯卡拉)。

那就是

知道您写的东西是对的 -这是一种可以实现的理想方式,这一定使您感到高兴。
现在,我要弄些苏格兰威士忌,戴上一些德国电子琴 ...


对于“在递归算法中避免堆栈溢出的方法”这一普遍问题,...

另一种方法是包括递归计数器。这更多地用于检测由无法控制的情况(以及不良的编码)导致的无限循环。

递归计数器的形式为

int foo(arg, counter) {
  if(counter > RECURSION_MAX) { return -1; }
  ...
  return foo(arg, counter + 1);
}

每次拨打电话时,您都会增加计数器。如果计数器太大,则会出错(在这里,仅返回-1,但是在其他语言中,您可能更喜欢抛出异常)。这样做的目的是防止进行比预期深得多的循环(可能是无限循环)时,发生更糟的事情(内存不足错误)。

从理论上讲,您不需要此。在实践中,由于大量的小错误和不良的编码实践(多线程并发问题,其中方法外的某些更改使另一个线程进入了递归调用的无限循环),我见过写得不好的代码。


使用正确的算法并解决正确的问题。专门针对Collat​​z猜想,您似乎正在尝试以xkcd的方式解决它:

XKCD#710

您从一个数字开始并且正在遍历树。这迅速导致非常大的搜索空间。快速运行以计算出正确答案的迭代次数大约需要500步。对于具有小堆栈框架的递归来说,这不应该成为问题。

虽然知道递归解决方案不是一件坏事,但人们还应该认识到,迭代解决方案通常会更好从递归到迭代的方式在堆栈溢出中可以看到多种将递归算法转换为迭代算法的方法。


1
今天,我在上网时碰到了那个xkcd卡通。:-) Randall Munroe的卡通作品非常有趣。
Lernkurve

@Lernkurve在开始编写(并发布)之后,我注意到添加了代码编辑。您是否需要其他代码示例?

一点都不。这是完美的。感谢一群问!
Lernkurve

我也可以建议添加此动画片:imgs.xkcd.com/comics/functional.png
Ellen Spertus

@espertus谢谢。我已经添加了它(清理了一些源代码,并增加了一些内容)

17

请记住,语言实现必须支持尾递归优化。我不认为主要的Java编译器会这样做。

备忘意味着您可以记住计算的结果,而不必每次都重新计算,例如:

collatz(i):
    if i in memoized:
        return memoized[i]

    if i == 1:
        memoized[i] = 1
    else if odd(i):
        memoized[i] = 1 + collatz(3*i + 1)
    else
        memoized[i] = 1 + collatz(i / 2)

    return memoized[i]

当您计算每个小于一百万的序列时,序列的末尾将会有很多重复。备注使它可以快速查找哈希表中的先前值,而不必使堆栈越来越深。


1
关于记忆的非常容易理解的解释。最重要的是,感谢您使用代码片段对其进行说明。另外,“在序列末尾将会有很多重复”对我来说很清楚。谢谢。
Lernkurve 2013年

10

令我惊讶的是,还没有人提到蹦床。蹦床(从这个意义上来说)是一个循环,它反复调用thunk返回函数(连续传递样式),并且可以用于在面向堆栈的编程语言中实现尾递归函数调用。

这个StackOverflow问题在Java中关于蹦床的各种实现的更多细节:在Java中为Trampoline处理StackOverflow


我也马上想到了这一点。蹦床是执行尾声优化的一种方法,因此人们(几乎可以说)说了这一点。+1有关具体参考。
史蒂文·埃弗斯

6

如果您使用的语言和编译器能够识别尾部递归函数并正确处理它们(即“将被调用者替换为调用者”),那么是的,堆栈不应失去控制。这种优化实质上将递归方法简化为迭代方法。我不认为Java可以做到这一点,但我知道Racket可以做到。

如果您使用的是迭代方法,而不是递归方法,那么您将无需记住记住调用的来源,并且实际上消除了堆栈溢出(无论如何都是递归调用)的机会。

记忆很大,可以通过在缓存中查找先前计算的结果来减少方法调用的总数,因为您的整体计算将产生许多较小的重复计算。这个想法很棒-它也与您使用的是迭代方法还是递归方法无关。


1
+1用于指出记忆,在迭代方法中也很有用。
Karl Bielefeldt 2013年

所有功能编程语言都具有尾调用优化功能。

3

您可以创建一个将替换递归的枚举...这是一个计算此操作的示例的示例...(不适用于大数,因为我在示例中只使用了很长时间:-))

public class Faculty
{

    public static IEnumerable<long> Faculties(long n)
    {
        long stopat = n;

        long x = 1;
        long result = 1;

        while (x <= n)
        {
            result = result * x;
            yield return result;
            x++;
        }
    }
}

即使这不是备忘录,这样您也将使堆栈溢出无效


编辑


如果我让你们有些不高兴,我很抱歉。我唯一的目的是展示一种如何避免堆栈溢出的方法。我可能应该已经编写了完整的代码示例,而不是快速编写的粗略代码摘录中的一小部分。

以下代码

  • 避免递归,因为我使用迭代方式计算所需的值。
  • 包括备忘录,因为已计算的值将被存储并在已计算的情况下进行检索
  • 还包括一个秒表,因此您可以看到备忘录正常运行

...嗯...如果运行它,请确保将命令外壳窗口设置为具有9999行的缓冲区...通常的300不足以运行以下程序的结果...

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Numerics;
using System.Text;
using System.Threading.Tasks;
using System.Timers;

namespace ConsoleApplication1
{
    class Program
    {
        static Stopwatch w = new Stopwatch();
        static Faculty f = Faculty.GetInstance();

        static void Main(string[] args)
        {
            Out(5);
            Out(10);
            Out(-5);
            Out(0);
            Out(1);
            Out(4);
            Out(29);
            Out(30);
            Out(20);
            Out(10000);
            Out(20000);
            Out(19999);
            Console.ReadKey();
        }

        static void Out(BigInteger n)
        {
             try
            {
                w.Reset();
                w.Start();
                var x = f.Calculate(n);
                w.Stop();
                var time = w.ElapsedMilliseconds;
                Console.WriteLine(String.Format("{0} ({2}ms): {1}", n, x, time));
            }
            catch (ArgumentException e)
            {
                Console.WriteLine(e.Message);
            }

            Console.WriteLine("\n\n");
       }
    }

我在Faculty类中将* 1静态变量“ instance”声明为存储单例。只要您的程序正在运行,就可以通过这种方式,只要您对类的“ GetInstance()”进行操作,就会获得实例,该实例存储了已经计算出的所有值。* 1个静态SortedList,它将保存所有已计算的值

在构造函数中,我还为输入0和1添加了列表1的2个特殊值。

    public class Faculty
    {
        private static SortedList<BigInteger, BigInteger> _values; 
        private static Faculty _faculty {get; set;}

        private Faculty ()
        {
            _values = new SortedList<BigInteger, BigInteger>();
            _values.Add(0, 1);
            _values.Add(1, 1);
        }

        public static Faculty GetInstance() {
            _faculty = _faculty ?? new Faculty();
            return _faculty;
        }

        public BigInteger Calculate(BigInteger n) 
        {
            // check if input is smaller 0
            if (n < 0)
                throw new ArgumentException(" !!! Faculty is not defined for values < 0 !!!");

            // if value is not already calculated => do so
            if(!_values.ContainsKey(n))
                Faculties(n);

            // retrieve n! from Sorted List
            return _values[n];
        }

        private static void Faculties(BigInteger n)
        {
            // get the last calculated values and continue calculating if the calculation for a bigger n is required
            BigInteger i = _values.Max(x => x.Key),
                           result = _values[i];

            while (++i <= n)
            {
                CalculateNext(ref result, i);
                // add value to the SortedList if not already done
                if (!_values.ContainsKey(i))
                    _values.Add(i, result);
            }
        }

        private static void CalculateNext(ref BigInteger lastresult, BigInteger i) {

            // put in whatever iterative calculation step you want to do
            lastresult = lastresult * i;

        }
    }
}

5
从技术上讲,这是迭代,因为您完全删除了所有递归
棘手怪胎

它是:-),并在每个计算步骤之间的方法变量中记录结果
Ingo

2
我认为您会误解记忆,即第一次调用faculties(100)时会计算结果并将其存储在哈希中并返回,然后再次调用时会返回存储的结果
棘手怪胎

@jk。值得称赞的是,他从未真正说过这是递归的。
尼尔

即使这不是备忘录,这样您也将使堆栈溢出无效
Ingo 2013年

2

至于Scala,您可以将@tailrec注释添加到递归方法中。这样,编译器可确保实际上进行了尾部调用优化:

所以这不会编译(阶乘):

@tailrec
def fak1(n: Int): Int = {
  n match {
    case 0 => 1
    case _ => n * fak1(n - 1)
  }
}

错误消息是:

scala:无法优化@tailrec注释方法fak1:它包含不在尾部位置的递归调用

另一方面:

def fak3(n: Int): Int = {
  @tailrec
  def fak3(n: Int, result: Int): Int = {
    n match {
      case 0 => result
      case _ => fak3(n - 1, n * result)
    }
  }

  fak3(n, 1)
}

编译,并且进行了尾调用优化。


1

尚未提及的一种可能性是递归,但不使用系统堆栈。当然,您也可能会溢出堆,但是如果您的算法确实需要以一种或另一种形式回溯(为什么不使用递归?),您别无选择。

有一些语言的无堆栈实现,例如Stackless Python


0

另一个解决方案是模拟自己的堆栈,而不依赖于编译器+运行时的实现。这不是一个简单的解决方案,也不是一个快速的解决方案,但是从理论上讲,只有在内存不足时,您才会获得StackOverflow。

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.