web-dev-qa-db-ja.com

再帰的アルゴリズムでスタックオーバーフローを回避する方法はありますか?

質問

再帰的アルゴリズムによって引き起こされるスタックオーバーフローを解決するために可能な方法は何ですか?

私は Project Eulerの問題14 を解決しようとしていて、再帰的アルゴリズムで試すことにしました。ただし、プログラムはJava.lang.StackOverflowErrorで停止します。当然です。非常に大きな数のCollat​​zシーケンスを生成しようとしたため、アルゴリズムは実際にスタックをオーバーフローしました。

ソリューション

だから私は疑問に思っていました:再帰アルゴリズムが正しく記述されていて、常にスタックがオーバーフローしてしまうと仮定して、スタックオーバーフローを解決する標準的な方法は何ですか?頭に浮かんだ2つの概念は次のとおりです。

  1. 末尾再帰
  2. 反復

アイデア(1)と(2)は正しいですか?他のオプションはありますか?

編集

できればJava、C#、Groovy、Scalaのコードを確認すると便利です。

おそらく、上記のProject Eulerの問題を使用しないでください。他の人のために台無しになることはありませんが、他のアルゴリズムを使用してください。多分階乗、または同様の何か。

44
Lernkurve

末尾呼び出しの最適化 は、多くの言語およびコンパイラに存在します。この状況では、コンパイラーは次の形式の関数を認識します。

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

ここで、言語は、返されている結果が別の関数からの結果であることを認識し、新しいスタックフレームでの関数呼び出しをジャンプに変更できます。

古典的な階乗法を理解してください:

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

返品には検査が必要なため、notテールコールは最適化可能です。 ( サンプルのソースコードとコンパイルされた出力

この末尾呼び出しを最適化するには、

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

サンプルのソースコードとコンパイルされた出力

セグメント.L3では、jneではなくcall(新しいスタックフレームでサブルーチン呼び出しを行う)を確認できます。

これはCで行われたことに注意してください。Javaでの末尾呼び出しの最適化は困難であり、JVM実装に依存します(それが難しいことと影響があるため、私はそれを実行するものを見たことはありません)必要なJavaスタックフレームを必要とするセキュリティモデル-これはTCOが回避するものです)- tail-recursion + Java および tail-recursion +最適化 は、参照するのに適したタグセットです。他のJVM言語は、末尾再帰をより適切に最適化できる場合があります(clojureを試してください( recur (末尾呼び出し最適化)、またはscala)。

とはいえ、

あなたが何かを書いたことを知ることには確かな喜びがありますright-それを行うことができる理想的な方法で。
そして今、 私はスコッチを手に入れて、ドイツのエレクトロニカを装着します ...


「再帰的アルゴリズムでスタックオーバーフローを回避する方法」という一般的な質問に...

別のアプローチは、再帰カウンターを含めることです。これは、自分の制御が及ばない状況(およびコーディングが不十分)によって引き起こされる無限ループを検出するためのものです。

再帰カウンターは次の形式を取ります

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

コールを発信するたびに、カウンタが増加します。カウンターが大きくなりすぎると、エラーが発生します(ここでは、-1が返されますが、他の言語では例外をスローすることもできます)。アイデアは、予想よりもはるかに深い再帰、おそらく無限ループを実行するときに、(メモリ不足による)悪いことが起こらないようにすることです。

理論的には、これは必要ありません。実際には、大量の小さなエラーと不適切なコーディング手法(マルチスレッドの同時実行性の問題により、別のスレッドが再帰呼び出しの無限ループに入るメソッドの外側で何かが変更されるという問題)のために、これをヒットした不適切に記述されたコードが見られました。


適切なアルゴリズムを使用して、適切な問題を解決します。 Collat​​z予想の場合は特に、それが xkcd の方法で解決しようとしていることappearsです。

XKCD #710

あなたは数から始めて、ツリーのトラバーサルを行っています。これにより、検索スペースが非常に大きくなります。正解の反復回数を計算するクイックランは、約500ステップになります。これは、小さなスタックフレームでの再帰では問題になりません。

再帰的な解決策は悪いことではないことを知っている一方で、反復的な解決策 の方が何度も優れていることも認識しておく必要があります 。再帰アルゴリズムを反復アルゴリズムに変換するアプローチのいくつかの方法は、スタックオーバーフローの 再帰から反復に移行する方法 で確認できます。

35
user40980

言語の実装は末尾再帰の最適化をサポートする必要があることに注意してください。メジャー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]

100万未満のすべてのシーケンスを計算する場合、シーケンスの最後に多くの繰り返しがあります。メモ化により、スタックをさらに深くする必要がなく、以前の値のハッシュテーブルをすばやく検索できます。

17
Karl Bielefeldt

トランポリン についてまだ誰も言及していないことに驚いています。トランポリン(この意味で)は、サンクを返す関数(継続渡しスタイル)を繰り返し呼び出すループであり、スタック指向のプログラミング言語で末尾再帰関数呼び出しを実装するために使用できます。

このStackOverflowの質問では、Javaでのトランポリンのさまざまな実装についてかなり詳しく説明します。 StackOverflowの処理Java Trampolineの場合

10
Rein Henrichs

末尾再帰関数を認識して それらを適切に処理する (つまり、「呼び出し元を呼び出し先で置き換える」)の言語とコンパイラを使用している場合は、スタックが制御不能になってはいけません。この最適化は本質的に、再帰的な方法を反復的な方法に減らします。 Javaはこれを行うとは思わないが、ラケットが行うことは知っている。

再帰的なアプローチではなく、反復的なアプローチを採用する場合、呼び出しがどこから来ているのかを覚える必要性の多くが取り除かれ、(とにかく再帰的な呼び出しから)スタックオーバーフローの可能性が実質的に排除されます。

メモ化は優れており、以前の計算結果をキャッシュで検索することにより、メソッド呼び出しの総数を減らすことができます。これは、全体の計算で多数のより小さな繰り返し計算が発生する場合です。このアイデアは素晴らしいです-反復的アプローチを使用しているか、再帰的アプローチを使用しているかに依存しません。

6

再帰を置き換える列挙を作成することができます...これは、それを行う教員を計算するための例です...(私はこの例で長く使用しただけなので、大きな数では機能しません:-)

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つの静的変数「インスタンス」をシングルトンストアに宣言します。このようにして、プログラムが実行されている限り、クラスの "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;

        }
    }
}
3
Ingo

Scalaについては、@tailrec再帰メソッドへのアノテーション。このようにして、コンパイラensuresが末尾呼び出しの最適化を実際に実行しました。

したがって、これはコンパイルされません(階乗)。

@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)
}

コンパイルし、末尾呼び出しの最適化が行われました。

2
Beryllium

まだ言及されていない可能性の1つは、再帰を使用することですが、システムスタックを使用しません。もちろん、ヒープをオーバーフローさせることもできますが、アルゴリズムで実際に何らかの形でバックトラックが必要な場合は(なぜ、再帰をまったく使用しないのですか?)、選択の余地はありません。

一部の言語のスタックレス実装があります。 Stackless Python

1
SK-logic

別の解決策は、コンパイラー+ランタイムの実装に依存せずに、独自のスタックをシミュレートすることです。これは単純な解決策でも高速な解決策でもありませんが、理論的にはメモリ不足の場合にのみStackOverflowを取得します。

0
m3th0dman