再帰アルゴリズムでスタックオーバーフローを回避するための方法は何ですか?


44

質問

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

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

解決策

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

  1. 末尾再帰
  2. 繰り返し

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

編集

できればJava、C#、Groovy、またはScalaでコードを表示すると役立ちます。

おそらく上記のプロジェクトオイラーの問題を使用しないでください。そうすれば、他の人にとっては甘やかされることはありませんが、他のアルゴリズムを使用できます。階乗かもしれない、または似たようなもの。


3
反復。メモ
ジェームズ

2
明らかに、メモ化実際に計算繰り返される場合にのみ機能します。
ヨルグWミットタグ

2
また、すべての言語実装がテール再帰最適化を実行できるわけではないことに注意してください
jk。

2
これはおそらく、再帰よりもコアカーソルで解決した方がよいでしょう。
ヨルグWミットタグ

3
1,000,000未満の数値から作業して1に移行する場合、この質問に対する答えには、1に到達するための約500ステップが含まれます。これは、小さなスタックフレームの場合、再帰に負担をかけるべきではありません。--- 1から始めて解決しようとすると、2、4、8、16、{5,32}に進み、そこから上に行くと、間違っています。

回答:


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 .L3で見ることができます。jnecall

これはCで行われたことに注意してください。Javaでのテールコールの最適化は難しく、JVM実装に依存します(つまり、それを行うものは見たことがありません。 -これはTCOが回避するものです)- 末尾再帰+ javaおよび末尾再帰+最適化は、閲覧するのに適したタグセットです。あなたは他のJVM言語が最適化末尾再帰よりよい(必要とトライClojureの(することができます見つけることができRECUR末尾呼び出しの最適化への)、またはスカラ)。

とはいえ、

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


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

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

再帰カウンターの形式は次のとおりです。

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

電話をかけるたびに、カウンターを増やします。カウンターが大きくなりすぎると、エラーが発生します(ここでは、-1が返されますが、他の言語では例外をスローすることもできます)。考えは、予想よりもはるかに深く、おそらく無限ループである再帰を実行するときに、最悪の事態(メモリ不足エラー)を防ぐことです。

理論的には、これは必要ありません。実際には、多数の小さなエラーと悪いコーディングプラクティス(別のスレッドが再帰呼び出しの無限ループに入るメソッドの外側で何かが変更されるマルチスレッド同時実行性の問題)が原因で、これにぶつかるコードの記述が不十分です。


適切なアルゴリズムを使用して、適切な問題を解決してください。具体的にコラッツの問題のために、表示され、あなたがそれを解決しようとしていることをXKCD方法:

XKCD#710

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

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


1
今日、ウェブをサーフィンしているときにそのxkcd漫画に出会った。:-)ランドールマンローの漫画は大喜びです。
レルンクルブ

@Lernkurveこれを書き始めた(そして投稿した)後、コード編集の追加に気付きました。これには他のコードサンプルが必要ですか?

いいえ、まったくありません。それは完璧だ。質問してくれてありがとう!
レルンクルブ

この漫画も追加することをお勧めします:imgs.xkcd.com/comics/functional.png
エレンスペルタス

@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]

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


1
メモ化の非常にわかりやすい説明。何よりも、コードスニペットで説明してくれてありがとう。また、「シーケンスの最後に多くの繰り返しが行われる」ことで、物事が明確になりました。ありがとうございました。
レルンクルブ

10

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

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


これもすぐに考えました。トランポリンは、テールコールの最適化を実行するための方法であるため、人々は(おそらくほぼ)それを言っています。+1特定の参照用。
スティーブンエバーズ

6

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

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

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


1
メモ化を指摘するための+1は、反復アプローチでも役立ちます。
カールビーレフェルト

すべての関数型プログラミング言語には、末尾呼び出しの最適化があります。

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");
       }
    }

ファカルティクラスで* 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;

        }
    }
}

5
技術的には、再帰を完全に削除したため、これは反復です
ラチェットフリーク

それは:-)、それは各計算ステップとの間のメソッド変数内の結果をmemoizesこと
インゴ

2
私は学部(100)が最初に呼び出されたときであるあなた誤解メモ化は、それがハッシュに結果を格納それを計算考えると返され、それが再び呼び出されたときに保存された結果が返される
ラチェットフリーク

@jk。彼の功績として、彼は実際にこれが再帰的だとは決して言いません。
ニール

これはメモ化でない場合でも、この方法は、あなたは、スタックオーバーフロー無効になります
インゴ・

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

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

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


0

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

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.