Menu
Coddy logo textTech

再帰

最終更新

再帰とは、同じ問題のより小さな版に対して関数が自分自身を呼び出し、直接答えられるほど小さなケースに到達するまでそれを繰り返すことです。その直接答えられるケースがベースケースであり、すべての再帰関数には必ず1つ必要です。fib(n)fib(n - 1)fib(n - 2) に分かれ続け、fib(1) または fib(0) に達すると、それらは自分自身の値をそのまま返します。上の可視化はまさにこれを実行しています。再生を押すと、呼び出しが木のように枝分かれし、葉でベースケースに到達し、そこから値を上へ返しながら各段で組み合わせていく様子が見られます。

アニメーションが示すもう1つのものが呼び出しスタック、つまり開始したもののまだ返っていないすべての呼び出しです。スタックは呼び出しが深くなるにつれて伸び、再帰の深さで最大になり、結果が返るにつれてほどけていきます。だからこそ深い再帰はスタックオーバーフローを起こしうる一方、反復ループはスタックを一切伸ばしません。同じ呼び出しの形が 深さ優先探索マージソート、そして 二分木 に対するほとんどの操作を動かしています。

時間計算量と空間計算量

上で示した素朴な再帰版フィボナッチと、標準的な2つの改善策について:

手法時間空間備考
素朴な再帰O(2^n)O(n)呼び出し木は各段で2倍になる。空間は木全体ではなく、最も深いスタックの分だけ。
メモ化ありO(n)O(n)fib(k) は一度だけ計算してキャッシュする。重複する部分木は参照に置き換わる。
反復ループO(n)O(1)2つの変数を転がすだけで、スタックは完全に不要になる。
一般の再帰全般呼び出し回数 × 1回あたりの仕事量O(max depth)スタックは、開始したがまだ返っていない呼び出しごとに1つのフレームを保持する。

ステップごとの手順

ステップ何が起きるか
1最初の呼び出し fib(n) が呼び出しスタックに積まれる。
2その呼び出しには fib(n - 1) が必要なので、それもスタックに積まれる。親は待機する。
3n <= 1 かどうかを問う呼び出しが現れるまで、呼び出しは入れ子になり続ける。ベースケースは即座に答え、それより深くは呼び出さない。
4ベースケースの値が親に返り、親は2つ目の呼び出し fib(n - 2) を開始できるようになる。
5両方の子が返ると、親はそれらを足して自分も返る。そのフレームはスタックから外れる。
6この返却が木を上へたどって繰り返され、最初の呼び出しのフレームが最終的な答えとともに取り除かれて、スタックが空になる。

具体例

アニメーションの再生順そのままに、fib(4) を正確な呼び出し順で評価すると:

呼び出しその時点のスタック返り値
fib(4)fib(4)子を待つ
fib(3)fib(4) > fib(3)子を待つ
fib(2)fib(4) > fib(3) > fib(2)子を待つ
fib(1)fib(4) > fib(3) > fib(2) > fib(1)1(ベースケース)
fib(0)fib(4) > fib(3) > fib(2) > fib(0)0(ベースケース)
fib(2) を合成fib(4) > fib(3) > fib(2)1 + 0 = 1
fib(1)fib(4) > fib(3) > fib(1)1(ベースケース)
fib(3) を合成fib(4) > fib(3)1 + 1 = 2
再び fib(2)fib(4) > fib(2)1、ゼロから再計算
fib(4) を合成fib(4)2 + 1 = 3

再帰を使うべきとき

使うべきとき避けるべきとき
問題が自己相似のとき:木、入れ子構造、分割統治同じことがスタックフレームなしの単純なループで書けるとき
深さが有界で控えめなとき。たとえば マージソートO(log n)巨大な入力で深さが入力サイズに達しうるとき。スタックオーバーフローの危険がある
バックトラッキングで、どこから再開するかをスタックに覚えさせる必要があるとき同じ部分問題が繰り返し現れるのに、それをキャッシュしていないとき
再帰版のほうが明らかに読みやすく、検証もしやすいとき呼び出しのオーバーヘッドが実測で効いてくるホットループの中にいるとき

Recursionのコード

Python, JavaScript, Java, C++, Cによるクリーンで実行可能なRecursionの実装です。言語を選んでコードをコピーするか、Coddyプレイグラウンドに読み込んだ状態で開けます。

PythonでのRecursionのコード

Python
1calls = 02
3def fib(n, depth=0):4    global calls5    calls += 16    # Print the call with its depth so the recursion is visible7    print("  " * depth + f"fib({n})")8    if n <= 1:9        return n10    return fib(n - 1, depth + 1) + fib(n - 2, depth + 1)11
12
13print("fib(5) =", fib(5))14print("calls made:", calls)
このコードをPythonプレイグラウンドで実行

再帰に関するよくある質問

再帰のベースケースとは何ですか?
それ以上再帰呼び出しをせずに答えられるほど小さい入力のことです。fib(n) なら n <= 1 がそれにあたり、n をそのまま返します。到達可能なベースケースがなければ呼び出しは止まらず、スタックは伸び続け、プログラムはスタックオーバーフローでクラッシュします。
呼び出しスタックとは何で、なぜ重要なのですか?
ランタイムは、開始したもののまだ返っていない呼び出しごとに1つのフレームを保持し、その引数とローカル変数を格納します。再帰の深さはそのままスタックの高さになるため、n 段の深さまで再帰する処理は、各呼び出しがほとんど何もしなくても O(n) のメモリを使います。アニメーションの下に並ぶチップの列が、まさにこのスタックが伸びてほどける様子を示しています。
再帰版フィボナッチはなぜ指数時間になるのですか?
同じ部分問題を何度も計算し直すからです。上の具体例では fib(2)fib(4) の中で2回評価されており、この重複はおよそ1段ごとに倍増して O(2^n) 回の呼び出しになります。最初に計算した結果をキャッシュする手法、すなわちメモ化を使えば、木は O(n) にまで畳み込まれます。
再帰は反復より優れているのですか?
どちらかが常に優れているということはありません。あらゆる再帰は明示的なスタックを持つループに書き換えられますし、あらゆるループは再帰に書き換えられます。木の走査や 深さ優先探索 のような自己相似な問題では再帰が読みやすさで勝り、線形の処理ではメモリと呼び出しコストの面で反復が勝ります。
再帰関数でスタックオーバーフローが起きる原因は何ですか?
ベースケースが無いか到達できずに呼び出しが止まらないか、あるいは再帰自体は正しくても深さがランタイムのスタック上限に対して単純に大きすぎるか、のどちらかです。後者は、数百万件の入力に対して要素ごとに1回再帰するような場合です。対処法は、ベースケースへの到達を保証する、深さを抑える、反復に書き換える、のいずれかです。
自然に再帰的なアルゴリズムにはどんなものがありますか?
マージソート やクイックソートのような分割統治のソート、二分木 やグラフの走査、二分探索、Nクイーンのようなバックトラッキングのパズル、そして JSON やファイルシステムのように入れ子構造の上で定義されるものすべてです。
Coddy programming languages illustration

Coddy でアルゴリズムをマスターしよう

始める