再帰
関数が自分自身を呼び出して、大きな問題を「同じ形の小さな問題」に分割して解くテクニック。木構造の探索や分割統治と相性が良い。
- 1.再帰とは、関数が自分自身を呼び出し、問題を“同じ形の小さい問題”に分割して解く方法。
- 2.必ず必要なのは2つ。止まる条件(ベースケース)と、自分を呼ぶ部分(再帰ケース)。どちらが欠けても無限ループになる。
- 3.1回の呼び出しごとにコールスタックを消費するので、深すぎるとスタックオーバーフローになる。深い反復はループの方が安全なことも多い。
何がうれしい?
ループ(反復)でも同じことはたいてい書けます。それでも再帰が好まれるのは、「全体は部分の繰り返しでできている」構造を、そのままコードに写し取れるからです。
- 木構造の探索(ファイルのフォルダ、HTML/DOM、JSON)— 「フォルダの中身を処理する」関数が、サブフォルダに対して自分を呼ぶ
- 分割統治(クイックソート、マージソート、二分探索)— 半分に分けて、それぞれを同じ手順で解く
- 数学的な定義がそのまま再帰(階乗、フィボナッチ、ユークリッドの互除法)
「問題の中に、同じ形の小さい問題が入れ子になっている」と気づいたら、再帰の出番です。
再帰の2つの部品
再帰関数は、必ず次の2つから成り立ちます。どちらか一方でも欠けると、正しく動きません。
| 部品 | 役割 | 階乗での例 |
|---|---|---|
| ベースケース(基底) | それ以上分割せず、答えを直接返して再帰を止める条件 | n が 0 のとき、1 を返す |
| 再帰ケース | 自分自身を、より小さい入力で呼び出す部分 | それ以外は n × factorial(n - 1) |
function factorial(n) {
if (n === 0) return 1; // ① ベースケース:ここで止まる
return n * factorial(n - 1); // ② 再帰ケース:1つ小さい問題を呼ぶ
}
factorial(5); // 120 → 5 × 4 × 3 × 2 × 1
ポイントは、再帰ケースが必ずベースケースに近づいていくこと。上の例では呼ぶたびに n が 1 ずつ減るので、いつか必ず 0(ベースケース)に到達します。
ベースケースが無い、あるいは引数がベースケースに近づかない再帰は、止まらずにメモリを食いつぶします(無限再帰)。たとえば factorial(n - 1) を factorial(n) と書いてしまうと、同じ値を永遠に呼び続けてクラッシュします。「毎回ちゃんと小さくなっているか?」を必ず確認しましょう。
コールスタックとスタックオーバーフロー
再帰を理解するカギはコールスタックです。関数を呼ぶと、戻り先や途中の変数を記録した「スタックフレーム」が積み上がります。factorial(5) を呼ぶと、factorial(0) が答えを返すまで5個のフレームが積み重なったまま待機します。
factorial(5) → 5 * factorial(4) を待つ
factorial(4) → 4 * factorial(3) を待つ
factorial(3) → 3 * factorial(2) を待つ
factorial(2) → 2 * factorial(1) を待つ
factorial(1) → 1 * factorial(0) を待つ
factorial(0) → 1 を返す(ベースケース)
← 1 を返す ← 2 を返す ← 6 ... と巻き戻りながら計算
このスタックには容量の上限があります。再帰が深すぎる(=フレームが積み上がりすぎる)と、上限を超えてスタックオーバーフローになります。
要素数 N のリストを1段ずつ再帰で処理すると、深さも N になります。N が数万〜数十万になりうるなら、再帰の深さも同じだけ膨らみ、スタックオーバーフローの危険があります(多くの環境で数千〜数万段が限界)。**「再帰の深さが、入力サイズに比例して青天井に伸びないか?」**を見積もる癖をつけましょう。
反復(ループ)との比較
同じ階乗をループで書くと、こうなります。スタックを積まないので、深さの心配がありません。
function factorial(n) {
let result = 1;
for (let i = 2; i <= n; i++) result *= i;
return result;
}
| 観点 | 再帰 | 反復(ループ) |
|---|---|---|
| 書き方 | 問題を分割する定義をそのまま書ける | 状態を変数で管理し、明示的に回す |
| 可読性 | 木・分割統治では非常にすっきり | 深いネスト構造だと逆に複雑になりがち |
| メモリ | 呼び出しごとにスタックを消費(深さに比例) | 原則 O(1) の追加メモリで済むことが多い |
| リスク | 深いとスタックオーバーフロー | 無限ループ(ただしクラッシュはしにくい) |
| 向く問題 | 木構造・分割統治・数学的定義 | 単純な繰り返し・上限の読めない大量データ |
データが木や入れ子なら再帰、フラットな繰り返しならループが素直です。深さが数十段で収まると分かっているなら、再帰の読みやすさを優先してOK。深さが入力次第で青天井なら、ループや明示的なスタック(配列)への書き換えを検討します。計算量の見積もりについては /programming/big-o/ も参考に。
つまずきポイント:素朴なフィボナッチ
再帰の「うっかり」で有名なのが、フィボナッチ数列の素朴な実装です。定義をそのまま書けて美しいのですが、同じ計算を爆発的に繰り返します。
def fib(n):
if n < 2:
return n # ベースケース:fib(0)=0, fib(1)=1
return fib(n - 1) + fib(n - 2)
fib(5) は fib(4) と fib(3) を呼び、その fib(4) がまた fib(3) を呼ぶ…と、同じ fib(3) を何度も計算します。計算量はおよそ O(2ⁿ) に膨らみ、fib(50) ですら現実的な時間で終わりません。
遅さの原因は再帰そのものではなく、同じ部分問題を何度も解いていること。一度計算した結果を覚えておくメモ化(キャッシュ)を足すだけで O(n) に改善します。「再帰は遅いから禁止」ではなく、「重複が出ていないか」を見るのが正しい着眼点です。
from functools import lru_cache
@lru_cache(maxsize=None) # 計算結果を自動でキャッシュ(メモ化)
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
末尾再帰という最適化
再帰ケースで、自分自身の呼び出しが「最後の処理」になっている形を末尾再帰(tail recursion)と呼びます。最初の factorial は n * factorial(...) と、戻ってきた値に掛け算がまだ残るので末尾再帰ではありません。途中結果を引数で持ち回ると、末尾再帰に書き換えられます。
// 末尾再帰:return が「自分の呼び出しそのもの」で終わる
function factorial(n, acc = 1) {
if (n === 0) return acc;
return factorial(n - 1, acc * n); // 呼び出しの後にやることが無い
}
呼び出しの後にやることが無いため、コンパイラ/処理系は今のフレームを使い回して新しいフレームを積まずに済ませられます。これが**末尾呼び出し最適化(TCO)**で、うまく効けば深さが深くてもスタックを消費しません(ループ相当に変換される)。
末尾再帰に書いても、多くの言語/処理系は実際には最適化してくれません。仕様にある JavaScript でも主要エンジンは未実装、Python は設計方針として TCO を採用していません(Scheme など一部言語は保証)。「末尾再帰だから無限に深くしても安全」と決めつけるのは危険。深さが心配なら、最終的にはループや明示的スタックへ書き換えるのが確実です。
まとめ:再帰を使う前のチェック
- ベースケースはあるか? そして再帰ケースは毎回それに近づくか?
- 再帰の深さは入力サイズに比例して青天井にならないか?(なるならループ/明示スタックを検討)
- 同じ部分問題を重複して解いていないか?(出ているならメモ化)
再帰は「木構造・分割統治・数学的定義」を、人間の考え方そのままに書ける強力な道具です。一方でコールスタックという有限資源の上で動く点を忘れずに。ループと再帰は対立ではなく、問題の形に合うほうを選ぶのが正解です。
プログラミング Article
再帰を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
再帰
比較で見る軸
難易度: intermediate / カテゴリ: プログラミング / タグ数: 3
導入後に効く点
必ず必要なのは2つ。止まる条件(ベースケース)と、自分を呼ぶ部分(再帰ケース)。どちらが欠けても無限ループになる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- intermediate
- カテゴリ
- プログラミング
- タグ数
- 3
判断チェックリスト
- 自社の用途が「再帰 / アルゴリズム」に近いか確認する。
- 強みである「再帰とは、関数が自分自身を呼び出し、問題を“同じ形の小さい問題”に分割して解く方法。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。