末尾再帰とは何ですか?


1695

Lispの学習を始めている間に、末尾再帰という用語に出くわしました。正確にはどういう意味ですか?


155
好奇心が強い人のために:非常に長い間この言語を使用している間も使用している間も。古い英語で使用されていた間; しばらくの間中英語の発達です。結合詞としては、それらは意味において交換可能ですが、標準的なアメリカ英語では生き残っていません。
Filip Bartuzi 2014年

14
多分それは遅いですが、これは末尾再帰についてのかなり良い記事です:programmerinterview.com/index.php/recursion/tail-recursion
Sam003 '08 / 08/15

5
末尾再帰関数を特定することの大きな利点の1つは、反復形式に変換できるため、アルゴリズムをメソッドスタックオーバーヘッドから取得できることです。@Kyle Croninと以下の他のいくつかからの応答にアクセスしたいかもしれません
KGhatak

@yesudeepからのこのリンクは、私が見つけた中で最も詳細な説明です-lua.org/pil/6.3.html
Jeff Fischer

1
誰かが私に言うことができますか、マージソートとクイックソートは末尾再帰(TRO)を使用しますか?
majurageerthan

回答:


1721

最初のN個の自然数を追加する単純な関数を考えます。(例sum(5) = 1 + 2 + 3 + 4 + 5 = 15)。

以下は、再帰を使用する簡単なJavaScript実装です。

function recsum(x) {
    if (x === 1) {
        return x;
    } else {
        return x + recsum(x - 1);
    }
}

を呼び出した場合recsum(5)、JavaScriptインタープリターはこれを評価します。

recsum(5)
5 + recsum(4)
5 + (4 + recsum(3))
5 + (4 + (3 + recsum(2)))
5 + (4 + (3 + (2 + recsum(1))))
5 + (4 + (3 + (2 + 1)))
15

JavaScriptインタープリターが合計の計算作業を実際に開始する前に、すべての再帰呼び出しが完了する必要があることに注意してください。

次に、同じ関数の末尾再帰バージョンを示します。

function tailrecsum(x, running_total = 0) {
    if (x === 0) {
        return running_total;
    } else {
        return tailrecsum(x - 1, running_total + x);
    }
}

を呼び出した場合に発生する一連のイベントを次に示しますtailrecsum(5)tailrecsum(5, 0)デフォルトの2番目の引数のため、これは事実上です)。

tailrecsum(5, 0)
tailrecsum(4, 5)
tailrecsum(3, 9)
tailrecsum(2, 12)
tailrecsum(1, 14)
tailrecsum(0, 15)
15

末尾再帰の場合、再帰呼び出しの評価ごとに、running_totalが更新されます。

注:元の回答ではPythonの例を使用していました。Pythonインタープリターは末尾呼び出しの最適化をサポートしていないため、これらはJavaScriptに変更されました。ただし、末尾呼び出しの最適化はECMAScript 2015仕様の一部ですが、ほとんどのJavaScriptインタープリターはそれをサポートしていません


32
末尾再帰を使用すると、メソッドの最後の呼び出しだけで最終的な回答が計算されると言えますか?末尾再帰でない場合は、すべてのメソッドですべての結果を計算して回答を計算する必要があります。
chrisapotek

2
ここ補遺だプレゼントというのLuaでのいくつかの例:lua.org/pil/6.3.htmlは 同様にその通過するために有用であり得ます!:)
yesudeep

2
誰かがchrisapotekの質問に答えてもらえますか?私はtail recursion、末尾呼び出しを最適化しない言語でどのように達成できるか混乱しています。
ケビンメレディス2013年

3
@KevinMeredith "末尾再帰"は、関数の最後のステートメントが同じ関数への再帰呼び出しであることを意味します。その再帰を最適化しない言語でこれを行う意味がないことは正しいです。それにもかかわらず、この回答は概念を(ほぼ)正しく示しています。「else:」が省略されていれば、より明確に末尾呼び出しになっていただろう。動作は変更されませんが、末尾呼び出しは独立したステートメントとして配置されます。それを編集として提出します。
ToolmakerSteve

2
pythonでは、tailrecsum関数を呼び出すたびに新しいスタックフレームが作成されるため、利点はありません。
Quazi Irfan 2017

707

では、伝統的な再帰、典型的なモデルは、あなたが最初にあなたの再帰呼び出しを行い、その後、あなたは再帰呼び出しの戻り値を取り、結果を計算していることです。この方法では、すべての再帰呼び出しから戻るまで、計算の結果は得られません。

末尾再帰は、最初に計算を実行し、その後、あなたは次の再帰的ステップにあなたの現在のステップの結果を渡し、再帰呼び出しを実行します。これにより、最後のステートメントはの形式になり(return (recursive-function params))ます。基本的に、特定の再帰ステップの戻り値は、次の再帰呼び出しの戻り値と同じです。

この結果、次の再帰ステップを実行する準備ができたら、現在のスタックフレームは必要なくなります。これにより、最適化が可能になります。実際、適切に作成されたコンパイラを使用すると、末尾再帰呼び出しでスタックオーバーフロースニッカーが発生することはありません。現在のスタックフレームを次の再帰的なステップに再利用するだけです。Lispがこれを行うと確信しています。


17
「Lispがこれを行うと確信しています」-Schemeは行いますが、Common Lispは常に行うわけではありません。
アーロン

2
@Daniel「基本的に、特定の再帰ステップの戻り値は次の再帰呼び出しの戻り値と同じです。」-Lorin Hochsteinによって投稿されたコードスニペットについて、この引数がtrueであるとは思いません。詳しく説明していただけますか?
Geek

8
@Geekこれは本当に遅い応答ですが、実際にはLorin Hochsteinの例に当てはまります。各ステップの計算は、再帰呼び出しの後ではなく、前に行われます。その結果、各ストップは直前のステップから直接値を返します。最後の再帰呼び出しは計算を終了し、最終的な結果を変更せずに呼び出しスタックまで返します。
reirab 2014

3
Scalaにはありますが、それを強制するには@tailrecを指定する必要があります。
SilentDirge 14

2
「この方法では、すべての再帰呼び出しから戻るまで、計算の結果は得られません。」-私はこれを誤解しているかもしれませんが、これは、従来の再帰がすべての再帰を呼び出さずに実際に結果を得る唯一の方法である怠惰な言語には特に当てはまりません(たとえば、&&でブールの無限リストを折りたたみます)。
hasufell 2015年

206

重要な点は、末尾再帰は基本的にループと同等であることです。それはコンパイラの最適化だけの問題ではなく、表現力に関する基本的な事実です。これは両方の方法で行われます。フォームのどのループでも実行できます

while(E) { S }; return Q

ここでE、およびQは式でSあり、一連のステートメントであり、末尾再帰関数に変換します

f() = if E then { S; return f() } else { return Q }

もちろん、ES、およびQいくつかの変数の上にいくつかの興味深い値を計算するために定義する必要があります。たとえば、ループ関数

sum(n) {
  int i = 1, k = 0;
  while( i <= n ) {
    k += i;
    ++i;
  }
  return k;
}

末尾再帰関数と同等です

sum_aux(n,i,k) {
  if( i <= n ) {
    return sum_aux(n,i+1,k+i);
  } else {
    return k;
  }
}

sum(n) {
  return sum_aux(n,1,0);
}

(より少ないパラメーターを持つ関数による末尾再帰関数のこの「ラッピング」は、一般的な関数イディオムです。)


@LorinHochsteinの回答で、私は彼の説明に基づいて、再帰部分が「Return」に続く場合の末尾再帰であると理解しましたが、あなたの場合、末尾再帰はそうではありません。あなたの例は末尾再帰と適切に見なされていますか?
CodyBugstein 2013年

1
@Imray末尾再帰部分は、sum_aux内の「return sum_aux」ステートメントです。
Chris Conway、

1
@lmray:Chrisのコードは基本的に同等です。if / thenの順序と制限テストのスタイル... if x == 0対if(i <= n)...は、問題を解決するものではありません。ポイントは、各反復がその結果を次の反復に渡すことです。
テイラー

else { return k; }次のように変更できますreturn k;
c0der

144

この本からの抜粋Luaのでのプログラミングを示し、適切な末尾再帰を作成する方法(Luaの中で、あまりにもlispに適用されるべきである)、それは良いでしょう、なぜ。

末尾呼び出し [末尾再帰]はコールに扮後藤の一種です。末尾呼び出しは、関数が最後のアクションとして別の関数を呼び出すときに発生するため、他に何もする必要はありません。たとえば、次のコードでは、への呼び出しgは末尾呼び出しです。

function f (x)
  return g(x)
end

f呼び出し後はg、他に何もする必要はありません。このような状況では、プログラムは、呼び出された関数が終了したときに呼び出し関数に戻る必要はありません。したがって、末尾呼び出しの後、プログラムは呼び出し元の関数に関する情報をスタックに保持する必要はありません。...

適切な末尾呼び出しはスタックスペースを使用しないため、プログラムが作成できる「ネストされた」末尾呼び出しの数に制限はありません。たとえば、次の関数を任意の数値を引数として呼び出すことができます。スタックがオーバーフローすることはありません:

function foo (n)
  if n > 0 then return foo(n - 1) end
end

...先に述べたように、テールコールは一種のgotoです。そのため、Luaでの適切な末尾呼び出しの非常に便利なアプリケーションは、ステートマシンのプログラミング用です。このようなアプリケーションは、各状態を関数で表すことができます。状態を変更するには、特定の関数に移動(または呼び出し)します。例として、単純な迷路ゲームを考えてみましょう。迷路にはいくつかの部屋があり、それぞれ最大4つのドア(北、南、東、西)があります。各ステップで、ユーザーは移動方向を入力します。その方向にドアがある場合、ユーザーは対応する部屋に行きます。それ以外の場合、プログラムは警告を出力します。目標は、最初の部屋から最後の部屋に移動することです。

このゲームは、現在の部屋が状態である典型的な状態機械です。部屋ごとに1つの機能でこのような迷路を実装できます。テールコールを使用して、ある部屋から別の部屋に移動します。4つの部屋がある小さな迷路は次のようになります。

function room1 ()
  local move = io.read()
  if move == "south" then return room3()
  elseif move == "east" then return room2()
  else print("invalid move")
       return room1()   -- stay in the same room
  end
end

function room2 ()
  local move = io.read()
  if move == "south" then return room4()
  elseif move == "west" then return room1()
  else print("invalid move")
       return room2()
  end
end

function room3 ()
  local move = io.read()
  if move == "north" then return room1()
  elseif move == "east" then return room4()
  else print("invalid move")
       return room3()
  end
end

function room4 ()
  print("congratulations!")
end

つまり、次のような再帰呼び出しを行うと、

function x(n)
  if n==0 then return 0
  n= n-2
  return x(n) + 1
end

これは、再帰呼び出しが行われた後もその関数で実行する必要がある(1を加える)ため、末尾再帰ではありません。非常に高い数値を入力すると、スタックオーバーフローが発生する可能性があります。


9
スタックサイズに対する末尾呼び出しの影響を説明しているため、これは素晴らしい答えです。
アンドリュースワン

@AndrewSwan確かに、元の質問者とこの質問に偶然出くわす可能性のある読者は、受け入れられた回答を提供するほうがよいと思いますが(彼が実際にスタックが何であるかを知らない可能性があるためです)。ファン。
ホフマン

1
スタックサイズへの影響が含まれているため、私のお気に入りの答えも。
njk2015

80

通常の再帰を使用して、各再帰呼び出しは別のエントリを呼び出しスタックにプッシュします。再帰が完了すると、アプリは各エントリを元に戻す必要があります。

末尾再帰を使用すると、言語によっては、コンパイラがスタックを1つのエントリに縮小できるため、スタックスペースを節約できます...大きな再帰クエリは、実際にスタックオーバーフローを引き起こす可能性があります。

基本的に、テール再帰は反復に最適化できます。


1
「大規模な再帰クエリは実際にスタックオーバーフローを引き起こす可能性があります。」2番目(末尾再帰)の段落ではなく、最初の段落にあるべきですか?末尾再帰の大きな利点は、スタックに呼び出しが「蓄積」されないように(例:スキーム)最適化できるため、ほとんどの場合スタックオーバーフローを回避できることです。
Olivier Dulac

70

専門用語ファイルは、末尾再帰の定義についてこれを言っています:

末尾再帰 /n./

まだ飽きていない場合は、末尾再帰を参照してください。


68

言葉で説明するのではなく、例を示します。これは階乗関数のSchemeバージョンです:

(define (factorial x)
  (if (= x 0) 1
      (* x (factorial (- x 1)))))

これは末尾再帰的な階乗のバージョンです:

(define factorial
  (letrec ((fact (lambda (x accum)
                   (if (= x 0) accum
                       (fact (- x 1) (* accum x))))))
    (lambda (x)
      (fact x 1))))

最初のバージョンでは、factへの再帰呼び出しが乗算式に渡されるため、再帰呼び出しを行うときに状態をスタックに保存する必要があります。末尾再帰バージョンでは、再帰呼び出しの値を待機している他のS式はありません。さらに処理する必要がないため、状態をスタックに保存する必要はありません。原則として、Schemeの末尾再帰関数は一定のスタックスペースを使用します。


4
+1は、反復形式に変換でき、それによってO(1)メモリ複雑度形式に変換できる末尾再帰の最も重要な側面について言及します。
KGhatak

1
@KGhatak正確ではありません。答えは、一般的なメモリではなく、「一定のスタックスペース」について正しく述べています。誤解しないように、誤解がないことを確認してください。たとえば、tail-recursive list-tail-mutating list-reverseプロシージャは一定のスタックスペースで実行されますが、ヒープ上にデータ構造を作成して拡張します。ツリートラバーサルは、追加の引数でシミュレートされたスタックを使用できます。等
Ness

45

末尾再帰とは、再帰的アルゴリズムの最後の論理命令の最後にある再帰的呼び出しを指します。

通常、再帰では、再帰呼び出しを停止して呼び出しスタックのポップを開始するベースケースがあります。古典的な例を使用するために、階乗関数はLispよりもCっぽいですが、尾部再帰を示します。再帰呼び出しは、基本ケースの条件を確認した後に発生ます。

factorial(x, fac=1) {
  if (x == 1)
     return fac;
   else
     return factorial(x-1, x*fac);
}

階乗への最初の呼び出しfactorial(n)fac=1(デフォルト値)であり、nは階乗が計算される数値です。


私はあなたの説明を理解するのが最も簡単だと思いましたが、それが通例である場合、テール再帰は1つのステートメントベースケースを持つ関数にのみ役立ちます。このpostimg.cc/5Yg3Cdjnなどのメソッドを検討してください。注:外側elseは「ベースケース」と呼ぶステップですが、複数の行にまたがっています。私はあなたを誤解していますか、それとも私の仮定は正しいですか?テール再帰は1つのライナーにのみ有効ですか?
私は回答を

2
@IWantAnswers-いいえ、関数の本体は任意に大きくできます。末尾呼び出しに必要なのは、それが入っているブランチが最後に行うこととして関数を呼び出し、関数を呼び出した結果を返すことだけです。このfactorial例は、古典的な単純な例にすぎません。
TJクラウダー

28

つまり、スタック上で命令ポインタをプッシュする必要はなく、再帰関数の先頭にジャンプして実行を続けることができます。これにより、スタックがオーバーフローすることなく、関数が無限に再帰することができます。

私はこの件についてブログ投稿を書きました。これには、スタックフレームがどのように見えるかのグラフィカルな例が含まれています。


21

2つの関数を比較する簡単なコードスニペットを次に示します。1つ目は、指定された数の階乗を見つけるための従来の再帰です。2番目は、末尾再帰を使用します。

非常にシンプルで直感的に理解できます。

再帰関数が末尾再帰であるかどうかを判断する簡単な方法は、基本ケースで具体的な値を返すかどうかです。つまり、1やtrueなどは返されません。メソッドパラメーターの1つのバリアントを返す可能性が高いです。

もう1つの方法は、再帰呼び出しに追加、算術、変更などがないかどうかを確認することです。つまり、純粋な再帰呼び出しにすぎません。

public static int factorial(int mynumber) {
    if (mynumber == 1) {
        return 1;
    } else {            
        return mynumber * factorial(--mynumber);
    }
}

public static int tail_factorial(int mynumber, int sofar) {
    if (mynumber == 1) {
        return sofar;
    } else {
        return tail_factorial(--mynumber, sofar * mynumber);
    }
}

3
0!つまり、「mynumber == 1」は「mynumber == 0 "」になります。
ポレト2014年

19

私が理解する最善の方法tail call recursionは、最後の呼び出し(または末尾呼び出し)が関数自体である再帰の特殊なケースです。

Pythonで提供される例の比較:

def recsum(x):
 if x == 1:
  return x
 else:
  return x + recsum(x - 1)

^再帰

def tailrecsum(x, running_total=0):
  if x == 0:
    return running_total
  else:
    return tailrecsum(x - 1, running_total + x)

^テール再帰

一般的な再帰バージョンでわかるように、コードブロックの最後の呼び出しはx + recsum(x - 1)です。したがって、recsumメソッドを呼び出した後、別の操作がありx + ..ます。

ただし、末尾再帰バージョンでは、コードブロックの最後の呼び出し(または末尾呼び出し)tailrecsum(x - 1, running_total + x)は、最後の呼び出しがメソッド自体に対して行われ、その後の操作は行われないことを意味します。

ここで見られるような末尾再帰はメモリを増大させないため、この点は重要です。基礎となるVMが末尾位置でそれ自体を呼び出す関数(関数で評価される最後の式)を見つけると、現在のスタックフレームが削除されます。 Tail Call Optimization(TCO)として知られています。

編集する

NB。上記の例は、ランタイムがTCOをサポートしていないPythonで記述されていることに注意してください。これはポイントを説明するための例にすぎません。TCOは、Scheme、Haskellなどの言語でサポートされています


12

Javaでは、フィボナッチ関数の末尾再帰実装の可能性があります。

public int tailRecursive(final int n) {
    if (n <= 2)
        return 1;
    return tailRecursiveAux(n, 1, 1);
}

private int tailRecursiveAux(int n, int iter, int acc) {
    if (iter == n)
        return acc;
    return tailRecursiveAux(n, ++iter, acc + iter);
}

これを標準の再帰的な実装と比較してください。

public int recursive(final int n) {
    if (n <= 2)
        return 1;
    return recursive(n - 1) + recursive(n - 2);
}

1
これは私にとって間違った結果を返しています。入力8の場合、36を取得します。21でなければなりません。何か不足していますか?私はjavaを使用しており、コピーして貼り付けています。
Alberto Zaccagni

1
これは、[1、n]のiに対してSUM(i)を返します。フィボナッチとは関係ありません。Fibboのために、あなたはsubstractsテストの必要iteraccときをiter < (n-1)
Askolein 2013年

10

私はLispプログラマではありませんが、これは役立つと思います。

基本的には、再帰呼び出しが最後に行うようなプログラミングのスタイルです。


10

以下は、末尾再帰を使用して階乗を行うCommon Lispの例です。スタックレスの性質により、めちゃくちゃ大きな階乗計算を実行できます...

(defun ! (n &optional (product 1))
    (if (zerop n) product
        (! (1- n) (* product n))))

そして、楽しみのためにあなたは試すことができます (format nil "~R" (! 25))


9

つまり、末尾再帰では、関数の最後のステートメントとして再帰呼び出しが行われるため、再帰呼び出しを待つ必要がありません。

つまり、これは末尾再帰です。つまり、N(x-1、p * x)は、forループ(階乗)に最適化できることをコンパイラが理解できる関数の最後のステートメントです。2番目のパラメーターpは、中間の積の値です。

function N(x, p) {
   return x == 1 ? p : N(x - 1, p * x);
}

これは、上記の階乗関数を記述する非再帰的な方法です(ただし、一部のC ++コンパイラはとにかく最適化できる場合があります)。

function N(x) {
   return x == 1 ? 1 : x * N(x - 1);
}

しかし、これはそうではありません:

function F(x) {
  if (x == 1) return 0;
  if (x == 2) return 1;
  return F(x - 1) + F(x - 2);
}

「Tail Recursionの理解– Visual Studio C ++ –アセンブリビュー」という長い記事を書いた

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


1
関数Nの末尾再帰はどうですか?
Fabian Pijcke 16

N(x-1)は、forループ(階乗)に最適化できることをコンパイラが理解できる関数の最後のステートメントです
doctorlai

私の懸念は、あなたの関数Nがこのトピックの受け入れられた答えからの関数recsumであり(それが合計であり、積ではないことを除いて)、そのrecsumが末尾再帰ではないと言われていることです。
Fabian Pijcke 2016

8

これは、tailrecsum前述の関数のPerl 5バージョンです。

sub tail_rec_sum($;$){
  my( $x,$running_total ) = (@_,0);

  return $running_total unless $x;

  @_ = ($x-1,$running_total+$x);
  goto &tail_rec_sum; # throw away current stack frame
}

8

これは、末尾再帰に関するコンピュータプログラムの構造と解釈からの抜粋です。

反復と再帰を対比すると、再帰的プロセスの概念と再帰的プロシージャの概念を混同しないように注意する必要があります。プロシージャを再帰的に説明するときは、プロシージャ定義がプロシージャ自体を(直接的または間接的に)参照するという構文上の事実を指します。しかし、プロセスを、たとえば線形再帰的なパターンに従うと説明するときは、プロシージャの記述方法の構文ではなく、プロセスがどのように進化するかについて話しています。fact-iterなどの再帰的な手順を反復プロセスの生成と呼ぶのは、気が遠くなるかもしれません。ただし、プロセスは実際には反復的です。その状態は3つの状態変数によって完全にキャプチャされ、インタープリターはプロセスを実行するために3つの変数のみを追跡する必要があります。

プロセスとプロシージャの違いがわかりにくい理由の1つは、一般的な言語(Ada、Pascal、Cを含む)のほとんどの実装が、再帰的なプロシージャの解釈でメモリ量が増えるように設計されていることです。説明されているプロセスが原則として反復的な場合でも、プロシージャコールの数 結果として、これらの言語は、do、repeat、until、for、whileなどの特別な目的の「ループ構造」に頼ることによってのみ、反復プロセスを記述できます。Schemeの実装はこの欠陥を共有していません。反復プロセスが再帰的プロシージャで記述されている場合でも、定数プロセスで反復プロセスを実行します。このプロパティを持つ実装は、末尾再帰と呼ばれます。 末尾再帰の実装では、通常のプロシージャコールメカニズムを使用して反復を表現できるため、特別な反復構成は構文糖としてのみ役立ちます。


1
ここですべての回答を読みましたが、これはこの概念の非常に深い核に触れる最も明確な説明です。それは、すべてがとてもシンプルで明確に見えるように、まっすぐな方法で説明しています。私の無礼を許してください。それはどういうわけか私は他の答えがちょうど頭に釘を打たないように感じさせます。SICPが重要なのはそのためだと思います。
18年

8

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

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

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

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の結果

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


7

末尾再帰は、あなたが現在生きている人生です。「前の」フレームに戻る理由や手段がないので、常に同じスタックフレームを繰り返しリサイクルします。過去は終わったので破棄できます。プロセスが必然的に終了するまで、1つのフレームを取得し、永遠に未来に移動します。

一部のプロセスが追加のフレームを使用する可能性があると考えると、アナロジーは壊れますが、スタックが無限に大きくならない場合は、末尾再帰と見なされます。


1
それは、分割された人格障害の解釈の下で壊れません。:) 心の社会 ; 社会としての心。:)
Will Ness

うわー!今それはそれについて考える別の方法です
スタヌダルイ

7

末尾再帰とは、関数が関数の最後(「末尾」)で自分自身を呼び出す再帰関数のことで、再帰呼び出しの戻り後に計算は行われません。多くのコンパイラーは、再帰呼び出しを末尾再帰呼び出しまたは反復呼び出しに変更するように最適化しています。

数値の階乗を計算する問題を考えてみましょう。

簡単なアプローチは次のとおりです。

  factorial(n):

    if n==0 then 1

    else n*factorial(n-1)

factorial(4)を呼び出すとします。再帰ツリーは次のようになります。

       factorial(4)
       /        \
      4      factorial(3)
     /             \
    3          factorial(2)
   /                  \
  2                factorial(1)
 /                       \
1                       factorial(0)
                            \
                             1    

上記の場合の最大再帰深度はO(n)です。

ただし、次の例を検討してください。

factAux(m,n):
if n==0  then m;
else     factAux(m*n,n-1);

factTail(n):
   return factAux(1,n);

factTail(4)の再帰ツリーは次のようになります。

factTail(4)
   |
factAux(1,4)
   |
factAux(4,3)
   |
factAux(12,2)
   |
factAux(24,1)
   |
factAux(24,0)
   |
  24

ここでも、最大の再帰の深さはO(n)ですが、どの呼び出しもスタックに余分な変数を追加しません。したがって、コンパイラはスタックを廃止できます。


7

Tail Recursionは、通常の再帰と比較してかなり高速です。祖先の呼び出しの出力は、トラックを維持するためにスタックに書き込まれないため、高速です。ただし、通常の再帰では、すべての祖先がスタックに書き込まれた出力を呼び出して追跡します。


6

末尾再帰関数は戻っているが、再帰関数呼び出しを行う前に、最後の操作は、それがない再帰関数です。つまり、再帰的な関数呼び出しの戻り値がすぐに返されます。たとえば、コードは次のようになります。

def recursiveFunction(some_params):
    # some code here
    return recursiveFunction(some_args)
    # no code after the return statement

末尾呼び出しの最適化または末尾呼び出しの削除を実装するコンパイラーおよびインタープリターは、再帰的コードを最適化してスタックのオーバーフローを防止できます。コンパイラーまたはインタープリターがテールコール最適化を実装していない場合(CPythonインタープリターなど)、この方法でコードを記述しても追加の利点はありません。

たとえば、これはPythonの標準の再帰的階乗関数です。

def factorial(number):
    if number == 1:
        # BASE CASE
        return 1
    else:
        # RECURSIVE CASE
        # Note that `number *` happens *after* the recursive call.
        # This means that this is *not* tail call recursion.
        return number * factorial(number - 1)

そして、これは階乗関数の末尾呼び出し再帰バージョンです:

def factorial(number, accumulator=1):
    if number == 0:
        # BASE CASE
        return accumulator
    else:
        # RECURSIVE CASE
        # There's no code after the recursive call.
        # This is tail call recursion:
        return factorial(number - 1, number * accumulator)
print(factorial(5))

(これはPythonコードですが、CPythonインタープリターは末尾呼び出しの最適化を行わないため、このようにコードを配置してもランタイム上の利点はありません。)

階乗の例に示すように、末尾呼び出しの最適化を利用するには、コードを少し読みにくくする必要がある場合があります。(たとえば、基本ケースは少し直感的ではなくなり、accumulatorパラメーターは一種のグローバル変数として効果的に使用されます。)

しかし、末尾呼び出しの最適化の利点は、スタックオーバーフローエラーを防ぐことです。(再帰的なアルゴリズムの代わりに反復的なアルゴリズムを使用することでこれと同じ利点を得ることができることに注意します。)

スタックオーバーフローは、呼び出しスタックにプッシュされたフレームオブジェクトが多すぎる場合に発生します。フレームオブジェクトは、関数が呼び出されると呼び出しスタックにプッシュされ、関数が戻ると呼び出しスタックからポップされます。フレームオブジェクトには、ローカル変数や、関数が戻ったときに戻るコード行などの情報が含まれています。

再帰関数がリターンせずに再帰呼び出しを多くしすぎると、呼び出しスタックがフレームオブジェクトの制限を超える可能性があります。(数はプラットフォームによって異なります。Pythonでは、デフォルトで1000フレームオブジェクトです。)これにより、スタックオーバーフローエラー発生します。(ねえ、それがこのウェブサイトの名前の由来です!)

ただし、再帰関数が最後に行うことは、再帰呼び出しを行い、その戻り値を返すことである場合、現在のフレームオブジェクトを呼び出しスタックに残す必要がある理由はありません。結局のところ、再帰的な関数呼び出しの後にコードがない場合は、現在のフレームオブジェクトのローカル変数に依存する必要はありません。したがって、現在のフレームオブジェクトをコールスタックに保持するのではなく、すぐに取り除くことができます。この結果、コールスタックのサイズが大きくならないため、スタックオーバーフローが発生しなくなります。

コンパイラーまたはインタープリターは、末尾呼び出し最適化をいつ適用できるかを認識できるようにするための機能として、末尾呼び出し最適化を備えている必要があります。それでも、再帰関数のコードを並べ替えて末尾呼び出しの最適化を利用している可能性があります。可読性のこの潜在的な低下が最適化に値するかどうかは、あなた次第です。


「末尾再帰(末尾呼び出しの最適化または末尾呼び出しの削除とも呼ばれます)」。番号; 末尾呼び出しの削除または末尾呼び出しの最適化は、末尾再帰関数に適用できるものですが、同じものではありません。Pythonでテール再帰関数を(図に示すように)作成できますが、Pythonはテール呼び出しの最適化を実行しないため、非テール再帰関数よりも効率的ではありません。
chepner

誰かがウェブサイトを最適化して再帰呼び出しを末尾再帰にレンダリングした場合、StackOverflowサイトはもう存在しないということですか?それは恐ろしいことです。
ナジブマミ

5

末尾呼び出しの再帰と非末尾呼び出しの再帰の主な違いのいくつかを理解するために、これらの手法の.NET実装を調査できます。

これは、C#、F#、およびC ++ \ CLIでのいくつかの例を含む記事です。C#、F#、およびC ++ \ CLI での尾の再帰における冒険

C#は末尾呼び出しの再帰を最適化しませんが、F#は最適化します。

原理の違いには、ループとラムダ計算が関係しています。C#はループを考慮して設計されていますが、F#はラムダ計算の原理から構築されています。ラムダ計算の原理に関する非常に優れた(そして無料の)本については、Abelson、Sussman、およびSussmanによる「コンピュータプログラムの構造と解釈」を参照してください。

F#での末尾呼び出しについては、非常に優れた紹介記事として、F#での末尾呼び出しの詳細な紹介を参照してください。最後に、非末尾再帰と末尾呼び出し再帰(F#の場合)の違いを説明する記事を次に示します。Fシャープでの末尾再帰と非末尾再帰

C#とF#の間の末尾呼び出し再帰の設計の違いについて読みたい場合は、C#とF#での末尾呼び出しオペコードの生成を参照してください。

C#コンパイラが末尾呼び出しの最適化を実行できない条件を知りたい場合は、この記事「JIT CLR末尾呼び出しの条件」を参照してください。


4

再帰には、2つの基本的な種類があります。ヘッド再帰テール再帰です。

ヘッド再帰、関数は再帰呼び出しを行い、その後、多分例えば、再帰呼び出しの結果を使用して、いくつかのより多くの計算を実行します。

末尾再帰関数で、すべての計算は、最初に起こると、再帰呼び出しが発生した最後のものです。

この超素晴らしい投稿から取られました。読んでみてください。


4

再帰は、それ自体を呼び出す関数を意味します。例えば:

(define (un-ended name)
  (un-ended 'me)
  (print "How can I get here?"))

末尾再帰は、関数を終了する再帰を意味します。

(define (un-ended name)
  (print "hello")
  (un-ended 'me))

終わりのない関数(Schemeの専門用語ではプロシージャ)が最後に行うことは、自分自身を呼び出すことです。別の(より有用な)例は次のとおりです。

(define (map lst op)
  (define (helper done left)
    (if (nil? left)
        done
        (helper (cons (op (car left))
                      done)
                (cdr left))))
  (reverse (helper '() lst)))

ヘルパープロシージャでは、左側がnilでない場合に最後に行うのは、それ自体を呼び出すことです(cons何かとcdr何かの後)。これは基本的にリストのマッピング方法です。

末尾再帰には、インタープリター(または言語とベンダーに依存するコンパイラー)が最適化して、whileループと同等のものに変換できるという大きな利点があります。実際のところ、Schemeの伝統では、ほとんどの "for"と "while"ループは末尾再帰の方法で行われます(私の知る限り、forとwhileはありません)。


3

この質問には多くの素晴らしい答えがあります...しかし、「尾の再帰」、または少なくとも「適切な尾の再帰」を定義する方法について、別の方法を試してみるしかありません。つまり、プログラム内の特定の式のプロパティと見なす必要がありますか?それとも、それをプログラミング言語の実装のプロパティとして見るべきですか?

後者の見方については、Will Clingerによる古典的な論文「Proper Tail Recursion and Space Efficiency」(PLDI 1998)があり、「適切なテール再帰」をプログラミング言語実装のプロパティとして定義しています。定義は、実装の詳細を無視できるように構築されています(コールスタックが実際にランタイムスタックを介して表されるか、フレームにヒープが割り当てられたリンクリストを介して表されるかなど)。

これを達成するために、それは漸近分析を使用します:通常見られるようなプログラム実行時間ではなく、プログラム空間使用量の。このように、ヒープに割り当てられたリンクリストとランタイムコールスタックのスペース使用量は、漸近的に同等になります。そのため、そのプログラミング言語の実装の詳細(実際にはかなり重要な詳細ですが、特定の実装が「プロパティテール再帰的」であるという要件を満たしているかどうかを判断しようとすると、かなり混乱する可能性があります。 )

この論文は、いくつかの理由で注意深く検討する価値があります。

  • プログラムの末尾式末尾呼び出しの帰納的定義を提供します。(そのような定義、およびそのような呼び出しが重要である理由は、ここに示されている他のほとんどの回答の主題のようです。)

    これらの定義は、テキストのフレーバーを提供するためだけです。

    定義1 Core Schemeで書かれたプログラムの末尾表現は、帰納的に次のように定義されます。

    1. ラムダ式の本体はテール式です
    2. (if E0 E1 E2)がテール式の場合、E1およびE2はテール式です。
    3. 他にテール式はありません。

    定義2 Aの末尾呼び出しは、プロシージャ呼び出しであるテール式です。

(末尾再帰呼び出し、または論文で述べられているように、「自己末尾呼び出し」は、プロシージャがそれ自体が呼び出される末尾呼び出しの特殊なケースです。)

  • これは、各マシンが同じ観察可能な行動があるコア制度、評価するための六つの異なる「マシン」のための正式な定義が提供除くための漸近それぞれがであることを宇宙の複雑性クラスを。

    たとえば、それぞれに1.スタックベースのメモリ管理、2。ガベージコレクションはテールコールなしで、3。ガベージコレクションとテールコールは、次のようなさらに高度なストレージ管理戦略を続けます。 4. "evlis tail recursion"。環境は、tail呼び出しの最後の部分式引数の評価全体で保存される必要はありません。5。クロージャーの環境を、そのクロージャーの自由変数だけに減らします。6. AppelとShaoによって定義された、いわゆる「セーフフォースペース」セマンティクス。

  • マシンが実際に6つの異なる空間複雑度クラスに属していることを証明するために、比較対象のマシンのペアごとに、1つのマシンで漸近的な空間爆発を公開し、他のマシンでは公開しないプログラムの具体例を示します。


(今私の回答を読んで、クリンジャー紙の重要なポイントを実際に捕捉できたかどうかはわかりませんが、残念ながら、今この回答を作成するためにこれ以上の時間を割くことができません。)


1

多くの人々はすでにここで再帰を説明しました。Riccardo Terrellの著書「.NETでの並行性、並行および並列プログラミングの最新パターン」から、再帰がもたらすいくつかの利点についていくつか考えてみます。

「関数型再帰は、状態の変化を回避するため、FPで反復する自然な方法です。各反復中に、新しい値がループコンストラクターに渡されて更新(変更)されます。さらに、再帰関数を構成して、プログラムをよりモジュール化したり、並列化を利用する機会を導入したりできます。」

また、末尾再帰に関する同じ本の興味深いメモもいくつかあります。

末尾呼び出し再帰は、通常の再帰関数を、リスクや副作用なしに大量の入力を処理できる最適化されたバージョンに変換する手法です。

注最適化としての末尾呼び出しの主な理由は、データの局所性、メモリ使用量、およびキャッシュ使用量を改善することです。末尾呼び出しを行うことにより、呼び出し先は呼び出し元と同じスタックスペースを使用します。これにより、メモリの負荷が軽減されます。同じメモリが後続の呼び出し元に再利用され、古いキャッシュラインを削除して新しいキャッシュライン用のスペースを空けるのではなく、キャッシュ内にとどまることができるため、キャッシュがわずかに改善されます。

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