web-dev-qa-db-ja.com

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

末尾再帰の仕組みと、通常の再帰との違いをほとんど理解しています。 I only理由がわからないdoes n'tスタックに戻りアドレスを記憶する必要がある.

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

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

120
Alan Coromano

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

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;
}
166
Alexey Frunze

なぜ「戻りアドレスを覚えるのにスタックを必要としないのか」と尋ねます。

これを好転させたい。 It doesスタックを使用して戻りアドレスを記憶します。秘Theは、末尾再帰が発生する関数がスタック上に独自の戻りアドレスを持ち、呼び出された関数にジャンプすると、これを独自の戻りアドレスとして扱うことです。

具体的には、テールコールの最適化なし:

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が呼び出された場所に戻ります。

[〜#〜] edit [〜#〜]:上記の例では、ある関数が別の関数を呼び出す場合を使用しています。関数がそれ自体を呼び出すときのメカニズムは同一です。

56
Lindydancer

再帰関数に存在しなければならない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)を計算するためにRAMの1kbが必要な場合、factorial(100)を計算するために〜100kが必要です。

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

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

int階乗(int n){intヘルパー(int num、int累積){if num == 0 return return累積else return returnerer(num-1、累計* num)} return helper(n、1)
}

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

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

違いがわかりますか? 「通常の」再帰呼び出しでは、戻り関数は再帰的に最終値を構成します。末尾再帰では、基本ケース(最後に評価されたもの)のみを参照しますaccumulator古い値を追跡する引数を呼び出します。

再帰テンプレート

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

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)

違いを見ます?

テールコールの最適化

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

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

11
Lucas Ribeiro

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

// 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;
}
11
mepcotterell

再帰関数がどのように機能するかを示す簡単な例を次に示します。

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 recursion modulo として知られるより複雑な最適化もあります

6
Khaled.K

再帰関数は、単独で呼び出す

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

欠点は、無限ループの原因および適切に記述されていないの場合、他の予期しない結果になる可能性があることです。

単純再帰関数と末尾再帰関数の両方を説明します

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

  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ループはtrue

したがって、_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の結果

enter image description here

Tail Recursion

_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ループはtrue

したがって、_running_total_を返します

_running_total = 24_の出力

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

enter image description here

1
Nursnaaz

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

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

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

0

再帰は内部実装に関連するものであるため、私の答えは推測に過ぎません。

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

  1. 進行中の機能を終了させます(つまり、使用されたスタックが呼び出されます)
  2. 関数への引数として使用される変数を一時ストレージに保存します
  3. この後、一時的に保存された引数で関数を再度呼び出します

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

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

0
iammilind