末尾再帰はどの程度正確に機能しますか?


121

末尾再帰がどのように機能するか、および通常の再帰との違いをほぼ理解しています。私は唯一それが理由を理解していないしないそのリターンアドレスを覚えてスタックを必要としています。

// tail recursion
int fac_times (int n, int acc) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

int factorial (int n) {
    return fac_times (n, 1);
}

// normal recursion
int factorial (int n) {
    if (n == 0) return 1;
    else return n * factorial(n - 1);
}

末尾再帰関数で関数自体を呼び出した後は何もする必要はありませんが、私には意味がありません。


16
テール再帰「通常の」再帰です。これは、関数の最後で再帰が発生することを意味するだけです。
ピートベッカー

7
...しかし、それはILレベルで通常の再帰とは異なる方法で実装でき、スタックの深さを減らします。
KeithS 2013年

2
ところで、gccは、ここでの「通常の」例で末尾再帰の削除を実行できます。
dmckee ---元モデレーターの子猫

1
@Geek-私はC#開発者なので、私の「アセンブリ言語」はMSILまたは単にILです。C / C ++の場合、ILをASMに置き換えます。
KeithS 2013年

1
@ShannonSeverance私はgccがなしで出力されたアセンブリコードを調べる簡単な方法でそれを行っていることを発見しました-O3。リンクは、非常に類似した根拠をカバーし、この最適化を実装するために何が必要かを議論する以前の議論のためのものです。
dmckee ---元モデレーターの子猫2013

回答:


169

コンパイラはこれを単純に変換できます

int fac_times (int n, int acc) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

このようなものに:

int fac_times (int n, int acc) {
label:
    if (n == 0) return acc;
    acc *= n--;
    goto label;
}

2
@ Mr.32質問がわかりません。関数を同等の関数に変換しましたが、明示的な再帰はありません(つまり、明示的な関数呼び出しはありません)。ロジックを同等ではないものに変更すると、実際に一部またはすべてのケースで関数が永久にループする可能性があります。
Alexey Frunze

18
では、コンパイラが最適化するためだけに、末尾再帰は効果的ですか?さもなければ、それはスタックメモリに関しては通常の再帰と同じでしょうか?
アランCoromano 2013年

34
うん。コンパイラーが再帰をループに削減できない場合、再帰が発生します。全部かゼロか。
Alexey Frunze

3
@AlanDert:正解です。また、末尾再帰は "末尾呼び出しの最適化"の特別なケースであると考えることもできます。これは、末尾呼び出しが偶然同じ関数に対して行われるためです。一般に、コンパイラーが呼び出しを行うことができる場合は、末尾呼び出し(末尾再帰に適用されるのと同じ要件がないため、末尾呼び出しの戻り値が直接返される場所)を最適化できます。呼び出された関数の戻りアドレスを、末尾呼び出しが行われたアドレスではなく、末尾呼び出しを行う関数の戻りアドレスに設定する方法。
スティーブジェソップ

1
Cの@AlanDertは、これは標準によって強制されない最適化にすぎないため、移植可能なコードはそれに依存すべきではありません。しかし、言語によっては(Schemeは1つの例です)、末尾再帰の最適化が標準で実施されているため、一部の環境でスタックオーバーフローが発生することを心配する必要はありません。
Jan Wrobel 2013年

57

「スタックに戻りアドレスを覚えておく必要がない」理由を尋ねます。

これを裏返したいと思います。それはないリターンアドレスを覚えておくことは、スタックを使用しています。トリックは、末尾再帰が発生する関数には、スタック上に独自の戻りアドレスがあり、呼び出された関数にジャンプすると、これは独自の戻りアドレスとして扱われます。

具体的には、末尾呼び出しの最適化なし:

f: ...
   CALL g
   RET
g:
   ...
   RET

この場合、gが呼び出されると、スタックは次のようになります。

   SP ->  Return address of "g"
          Return address of "f"

一方、末尾呼び出しの最適化では:

f: ...
   JUMP g
g:
   ...
   RET

この場合、gが呼び出されると、スタックは次のようになります。

   SP ->  Return address of "f"

明らかに、g戻るときは、f呼び出し元の場所に戻ります。

編集:上記の例では、ある関数が別の関数を呼び出すケースを使用しています。メカニズムは、関数がそれ自体を呼び出す場合も同じです。


8
これは他の答えよりもはるかに良い答えです。コンパイラには、末尾再帰コードを変換するための魔法のような特別なケースはほとんどありません。それはたまたま同じ関数に行く通常のラストコール最適化を実行するだけです。
アート

12

特にアキュムレータが使用される場合、テール再帰は通常コンパイラによってループに変換されます。

// tail recursion
int fac_times (int n, int acc = 1) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

のようなものにコンパイルされます

// accumulator
int fac_times (int n) {
    int acc = 1;
    while (n > 0) {
        acc *= n;
        n -= 1;
    }
    return acc;
}

3
Alexeyの実装ほど賢くはありません...はい、それは褒め言葉です。
Matthieu M.

1
実際、結果はより単純に見えますが、この変換を実装するコードは、label / gotoまたは単なる末尾呼び出しの除去よりも「賢い」と思います(Lindydancerの回答を参照)。
フォブ

これがすべての末尾再帰である場合、なぜ人々はそれについてそれほど興奮するのですか?whileループに興奮している人は誰もいない。
Buh Buh 2013年

@BuhBuh:これにはスタックオーバーフローがなく、パラメーターのスタックプッシュ/ポップを回避します。このようなタイトなループの場合、それは違いをもたらすことができます。それ以外の人は興奮してはいけません。
Mooing Duck

11

再帰関数には2つの要素が必要です。

  1. 再帰呼び出し
  2. 戻り値を数える場所。

「通常の」再帰関数は、スタックフレームに(2)を保持します。

通常の再帰関数の戻り値は、2種類の値で構成されています。

  • その他の戻り値
  • 自身の関数の計算結果

あなたの例を見てみましょう:

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

たとえば、フレームf(5)は、それ自体の計算結果(5)とf(4)の値を「格納」します。factorial(5)を呼び出すと、スタックコールが折りたたみ始める直前に、

 [Stack_f(5): return 5 * [Stack_f(4): 4 * [Stack_f(3): 3 * ... [1[1]]

各スタックには、前述の値に加えて、関数のスコープ全体が格納されることに注意してください。したがって、再帰関数fのメモリ使用量はO(x)です。ここで、xは、実行する必要がある再帰呼び出しの数です。したがって、factorial(1)またはfactorial(2)を計算するために1kbのRAMが必要な場合、factorial(100)を計算するために〜100kが必要になります。

Tail Recursive関数は、引数に(2)を入れます。

テール再帰では、各再帰フレームの部分計算の結果を、パラメーターを使用して次のフレームに渡します。階乗の例であるTail Recursiveを見てみましょう。

int階乗(int n){intヘルパー(int num、int累積){num == 0の場合は戻り値を返しますelse else helper(num-1、累計*番号)} return helper(n、1)
}

factorial(4)のフレームを見てみましょう。

[Stack f(4, 5): Stack f(3, 20): [Stack f(2,60): [Stack f(1, 120): 120]]]]

違いを見ますか?「通常の」再帰呼び出しでは、戻り関数が最終値を再帰的に構成します。Tail Recursionでは、ベースケース(最後に評価されたもの)のみを参照します。古い値を追跡する引数をアキュムレータと呼びます。

再帰テンプレート

通常の再帰関数は次のようになります。

type regular(n)
    base_case
    computation
    return (result of computation) combined with (regular(n towards base case))

Tail再帰でそれを変換するには、次のようにします。

  • アキュムレータを運ぶヘルパー関数を導入する
  • アキュムレータをベースケースに設定して、メイン関数内でヘルパー関数を実行します。

見て:

type tail(n):
    type helper(n, accumulator):
        if n == base case
            return accumulator
        computation
        accumulator = computation combined with accumulator
        return helper(n towards base case, accumulator)
    helper(n, base case)

違いを見ます?

テールコールの最適化

テールコールスタックの非ボーダーケースには状態が格納されていないため、それらはそれほど重要ではありません。一部の言語/インタープリターは、古いスタックを新しいスタックに置き換えます。したがって、呼び出しの数を制限するスタックフレームがない場合、これらの場合、末尾呼び出しはforループのように動作します

それを最適化するかどうかはコンパイラ次第です。


6

以下は、再帰関数がどのように機能するかを示す簡単な例です。

long f (long n)
{

    if (n == 0) // have we reached the bottom of the ocean ?
        return 0;

    // code executed in the descendence

    return f(n-1) + 1; // recurrence

    // code executed in the ascendence

}

テール再帰は単純な再帰関数であり、関数の最後で再帰が行われるため、コードは昇順で実行されません。これにより、高水準プログラミング言語のほとんどのコンパイラが、テール再帰最適化と呼ばれるものを実行するのに役立ち、Tail再帰モジュロとして知られるより複雑な最適化


1

再帰関数はそれ自体呼び出す関数です

これにより、プログラマは最小限のコードを使用して効率的なプログラムを作成できます

欠点は、適切に記述しないと、無限ループやその他の予期しない結果を引き起こす可能があることです

Simple Recursive関数とTail Recursive関数の両方を説明します

単純な再帰関数を書くために

  1. 最初に考慮すべき点は、ループから抜け出すことを決定するタイミングです。これはifループです。
  2. 2つ目は、私たちが自分の機能である場合に実行するプロセスです。

与えられた例から:

public static int fact(int n){
  if(n <=1)
     return 1;
  else 
     return n * fact(n-1);
}

上記の例から

if(n <=1)
     return 1;

ループを終了するときの決定要因です

else 
     return n * fact(n-1);

実際に行われる処理です

わかりやすいように、1つずつ作業を中断してみましょう。

実行すると内部で何が起こるか見てみましょう fact(4)

  1. n = 4を代入
public static int fact(4){
  if(4 <=1)
     return 1;
  else 
     return 4 * fact(4-1);
}

Ifループが失敗するのでelseループに戻り、戻る4 * fact(3)

  1. スタックメモリには、 4 * fact(3)

    n = 3を代入

public static int fact(3){
  if(3 <=1)
     return 1;
  else 
     return 3 * fact(3-1);
}

Ifループは失敗するのでループしelseます

それで戻る 3 * fact(2)

`` `4 * fact(3)` `を呼び出したことを思い出してください

の出力 fact(3) = 3 * fact(2)

これまでのところ、スタックは 4 * fact(3) = 4 * 3 * fact(2)

  1. スタックメモリには、 4 * 3 * fact(2)

    n = 2を代入

public static int fact(2){
  if(2 <=1)
     return 1;
  else 
     return 2 * fact(2-1);
}

Ifループは失敗するのでループしelseます

それで戻る 2 * fact(1)

電話したことを覚えている 4 * 3 * fact(2)

の出力 fact(2) = 2 * fact(1)

これまでのところ、スタックは 4 * 3 * fact(2) = 4 * 3 * 2 * fact(1)

  1. スタックメモリには、 4 * 3 * 2 * fact(1)

    n = 1を代入

public static int fact(1){
  if(1 <=1)
     return 1;
  else 
     return 1 * fact(1-1);
}

If ループは真です

それで戻る 1

電話したことを覚えている 4 * 3 * 2 * fact(1)

の出力 fact(1) = 1

これまでのところ、スタックは 4 * 3 * 2 * fact(1) = 4 * 3 * 2 * 1

最後に、fact(4)= 4 * 3 * 2 * 1 = 24の結果

ここに画像の説明を入力してください

末尾再帰はなり

public static int fact(x, running_total=1) {
    if (x==1) {
        return running_total;
    } else {
        return fact(x-1, running_total*x);
    }
}
  1. n = 4を代入
public static int fact(4, running_total=1) {
    if (x==1) {
        return running_total;
    } else {
        return fact(4-1, running_total*4);
    }
}

Ifループが失敗するのでelseループに戻り、戻るfact(3, 4)

  1. スタックメモリには、 fact(3, 4)

    n = 3を代入

public static int fact(3, running_total=4) {
    if (x==1) {
        return running_total;
    } else {
        return fact(3-1, 4*3);
    }
}

Ifループは失敗するのでループしelseます

それで戻る fact(2, 12)

  1. スタックメモリには、 fact(2, 12)

    n = 2を代入

public static int fact(2, running_total=12) {
    if (x==1) {
        return running_total;
    } else {
        return fact(2-1, 12*2);
    }
}

Ifループは失敗するのでループしelseます

それで戻る fact(1, 24)

  1. スタックメモリには、 fact(1, 24)

    n = 1を代入

public static int fact(1, running_total=24) {
    if (x==1) {
        return running_total;
    } else {
        return fact(1-1, 24*1);
    }
}

If ループは真です

それで戻る running_total

の出力 running_total = 24

最後に、fact(4,1)= 24の結果

ここに画像の説明を入力してください


0

再帰は内部実装に関連するものであるため、私の答えはもっと推測です。

末尾再帰では、同じ関数の最後に再帰関数が呼び出されます。おそらくコンパイラは以下の方法で最適化できます:

  1. 進行中の関数を巻き上げます(つまり、使用されたスタックが呼び出されます)
  2. 関数の引数として使用される変数を一時ストレージに保存します
  3. この後、一時的に格納された引数を使用して関数を再度呼び出します

ご覧のとおり、同じ関数の次の反復の前に元の関数を巻き上げているため、実際にはスタックを「使用」していません。

しかし、関数内で呼び出されるデストラクタがある場合、この最適化は適用されない可能性があると思います。


0

コンパイラーは、末尾再帰を理解するのに十分なほどインテリジェントです。再帰呼び出しから戻っているときに、保留中の操作がなく、再帰呼び出しが最後のステートメントである場合、末尾再帰のカテゴリーに分類されます。コンパイラは基本的に末尾再帰の最適化を実行し、スタックの実装を削除します。以下のコードを検討してください。

void tail(int i) {
    if(i<=0) return;
    else {
     system.out.print(i+"");
     tail(i-1);
    }
   }

最適化を実行した後、上記のコードは以下のコードに変換されます。

void tail(int i) {
    blockToJump:{
    if(i<=0) return;
    else {
     system.out.print(i+"");
     i=i-1;
     continue blockToJump;  //jump to the bolckToJump
    }
    }
   }

これは、コンパイラがテール再帰最適化を行う方法です。

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