再帰関数は、関数が自分自身を呼び出すことで問題を小さく分割しながら解く手法です。
本記事では、初心者でもつまずかないように「ベースケース」「再帰ステップ」「呼び出しスタック」の要点を丁寧に押さえ、階乗とフィボナッチを題材に基本から実装、改良、デバッグまで一歩ずつ解説します。
再帰関数の基本
再帰の考え方と使いどころ
直感的なイメージ
再帰は「同じ形の小さな問題に言い換えて解く」考え方です。
大きな問題をそのまま解こうとせず、ひと回り小さい問題に任せつつ、自分は一歩だけ進めるイメージを持つと理解しやすくなります。
使いどころの目安
再帰が自然に書ける代表例は、階層構造や分割統治の問題です。
本記事では次の2つに絞って扱います。
- 階乗(n!)の計算(定義自体が再帰的)
- フィボナッチ数列(同じ部分問題が多数出てくる)
処理の流れが「定義のままコードになる」とき、再帰は読みやすく安全に書けます。
ベースケースと再帰ステップ
ベースケース
ベースケースは「これ以上分割せず、ただちに答えがわかる最小の状態」です。
例えば階乗では、0!を1と定義しておき、n=0のときだけは再帰をせず即座に返します。
ベースケースを先に書くと、無限再帰の事故を防ぎやすくなります。
再帰ステップ
再帰ステップは「一歩進めて小さくし、残りは自分自身に任せる」部分です。
階乗なら n! = n × (n-1)! のように、nから(n-1)に小さくしていきます。
ここで「必ず小さくなる」ことが終了に直結します。
呼び出しスタックと終了条件
呼び出しスタックのイメージ
C言語では関数呼び出しのたびに「スタックフレーム」が積み重なります。
各フレームはその呼び出し時の引数やローカル変数を持ち、戻り値が返ると1段分スタックが外れます。
簡単な図で表すと次のようになります(例: fact(3))。
main
└─ fact(3)
└─ fact(2)
└─ fact(1)
└─ fact(0) // ここがベースケースで直ちに戻る
終了条件の役割
終了条件は「ベースケースに必ず到達すること」を保証します。
引数を小さくする方向が間違っていたり、分岐が漏れていたりすると、スタックが無限に積まれスタックオーバーフローになります。
入力値の妥当性チェックを加えるとより安全です。
再帰関数の作り方
関数の定義と呼び出しの流れ
典型パターン
再帰関数は次の骨組みに沿って書きます。
ベースケースを先に、続いて再帰ステップを置くのが読みやすさと安全性のコツです。
// 典型的な再帰関数の骨組み
// 1) ベースケース(必ず終了する条件)
// 2) 再帰ステップ(問題を小さくして自分自身を呼ぶ)
// 3) 小さくした結果を組み合わせて答えを返す
#include <stdio.h>
// プロトタイプ宣言(後で定義する関数を先に知らせる)
long long f(int n);
int main(void) {
int n = 3;
long long r = f(n);
printf("f(%d) = %lld\n", n, r);
return 0;
}
// 再帰関数の定義(例: 単なる雛形)
long long f(int n) {
// ベースケース: これ以上分割しない最小のケース
if (n <= 0) {
return 1; // 例として1を返す
}
// 再帰ステップ: 問題を小さくして自分を呼ぶ
long long smaller = f(n - 1);
// 小さくした結果を使って答えを組み立てる
return n * smaller;
}
f(3) = 6
この例では「f」は階乗の形をしています。
mainより下で関数を定義する場合、上にプロトタイプ宣言が必要です。
別ファイルに関数を分ける際も、ヘッダ(関数プロトタイプ)を用意してから使うのが基本です。
引数と戻り値の設計(値渡し/ポインタ)
値渡しでシンプルに返す
C言語の引数は「値渡し」です。
再帰関数は基本的に「引数で状態を小さくし、戻り値で結果を返す」形を取ると読みやすくなります。
ポインタ渡しで付加情報を返す
戻り値1つでは足りない場合、ポインタ引数で追加の結果を外へ渡せます。
例えばフィボナッチの「呼び出し回数」を数える場合は次のようにします。
#include <stdio.h>
// 呼び出し回数をカウントするフィボナッチ
long long fib_count(int n, long long *counter) {
(*counter)++; // 呼び出しが1回増えた
if (n <= 1) return n; // ベースケース
return fib_count(n - 1, counter) + fib_count(n - 2, counter);
}
このように、主結果は戻り値で、補助的な結果をポインタ引数で返すと分離が明確になります。
無限再帰を防ぐ条件分岐
ガード節で入力を早めに拒否する
負の数を受け取ると定義できない関数(例: 階乗)は、最初に不正な入力を検出して戻ると安全です。
早い段階で戻る分岐(ガード節)を入れると、再帰ステップに到達しないため事故が減ります。
ベースケースを先に書く
関数冒頭でベースケースを判定し、該当したら即returnする構成にします。
そうすれば、以降の処理は「ベースケースでない」ことが前提になり、ロジックが単純化します。
小さくなる方向の確認
n -> n-1 のように、必ずベースケースへ近づく方向に引数を更新しているかを目視で確認します。
+1 にしてしまう等のミスは典型的な無限再帰の原因です。
階乗を再帰で実装
ベースケースと再帰式を決める
階乗は次のように定義されます。
- ベースケース: 0! = 1
- 再帰式: n! = n × (n-1)! (n ≥ 1)
この定義はそのまま再帰関数に写せます。
サンプルコードと実行トレース
再帰呼び出しの流れが見えるよう、深さごとにインデントしてログを出します。
#include <stdio.h>
// プロトタイプ宣言
unsigned long long factorial(unsigned int n);
unsigned long long factorial_trace(unsigned int n, unsigned int depth);
// 実装: 通常の再帰版
unsigned long long factorial(unsigned int n) {
if (n == 0) {
return 1; // ベースケース
}
return n * factorial(n - 1); // 再帰ステップ
}
// 実装: トレース付き(学習用)
unsigned long long factorial_trace(unsigned int n, unsigned int depth) {
// インデントを表示(深さごとに2スペース)
for (unsigned int i = 0; i < depth; ++i) printf(" ");
printf("enter factorial(%u)\n", n);
if (n == 0) {
for (unsigned int i = 0; i < depth; ++i) printf(" ");
printf("return 1\n");
return 1;
}
unsigned long long sub = factorial_trace(n - 1, depth + 1);
unsigned long long ans = n * sub;
for (unsigned int i = 0; i < depth; ++i) printf(" ");
printf("return %u * factorial(%u) => %llu\n", n, n - 1, ans);
return ans;
}
int main(void) {
unsigned int n = 5;
printf("n = %u のトレース:\n", n);
unsigned long long result = factorial_trace(n, 0);
printf("factorial(%u) = %llu\n", n, result);
return 0;
}
n = 5 のトレース:
enter factorial(5)
enter factorial(4)
enter factorial(3)
enter factorial(2)
enter factorial(1)
enter factorial(0)
return 1
return 1 * factorial(0) => 1
return 2 * factorial(1) => 2
return 3 * factorial(2) => 6
return 4 * factorial(3) => 24
return 5 * factorial(4) => 120
factorial(5) = 120
このログから、呼び出しが深く潜ってベースケースに達し、その後に積み上げて戻ってくる様子が分かります。
ループ版との比較と選び方
ループ版のコード例
再帰せずfor文で書くと次のようになります。
// ループ版(反復)の階乗
unsigned long long factorial_iter(unsigned int n) {
unsigned long long acc = 1;
for (unsigned int i = 1; i <= n; ++i) {
acc *= i;
}
return acc;
}
比較の観点
観点 | 再帰 | ループ |
---|---|---|
可読性 | 定義に忠実で短く書ける | 逐次的で馴染みやすい |
メモリ | 呼び出しごとにスタックを消費 | スタック消費が小さい |
実行速度 | オーバーヘッドがやや大きいことがある | 低オーバーヘッドで速いことが多い |
デバッグ | トレースしやすいが深くなると大変 | 逐次追跡しやすい |
適した問題 | 階層構造や分割統治 | 単純な繰り返し処理 |
階乗のようにどちらでも書ける問題では、入力が大きい場合はループ版が堅実です。
学習段階では再帰で定義とコードの対応関係を体験し、実務ではシンプルで安全な方(多くはループ版)を選びます。
フィボナッチを再帰で実装
サンプルコードと計算量
フィボナッチはベースケースが2つ(n=0,1)あり、n≥2では fib(n) = fib(n-1) + fib(n-2) です。
まずは素直な再帰と、呼び出し回数の可視化を行います。
#include <stdio.h>
// 素直なフィボナッチ
long long fib(int n) {
if (n <= 1) return n; // ベースケース
return fib(n - 1) + fib(n - 2); // 再帰ステップ
}
// 呼び出し回数を数える版(学習用)
long long fib_count(int n, long long *counter) {
(*counter)++; // 呼び出し回数をカウント
if (n <= 1) return n;
return fib_count(n - 1, counter) + fib_count(n - 2, counter);
}
int main(void) {
for (int i = 0; i <= 10; ++i) {
long long calls = 0;
long long v = fib_count(i, &calls);
printf("n=%d, fib=%lld, calls=%lld\n", i, v, calls);
}
return 0;
}
n=0, fib=0, calls=1
n=1, fib=1, calls=1
n=2, fib=1, calls=3
n=3, fib=2, calls=5
n=4, fib=3, calls=9
n=5, fib=5, calls=15
n=6, fib=8, calls=25
n=7, fib=13, calls=41
n=8, fib=21, calls=67
n=9, fib=34, calls=109
n=10, fib=55, calls=177
呼び出し回数が急増していることから、素直な再帰は指数オーダーで増えることが直感できます。
重複する部分問題が多数あり、同じ値を何度も計算してしまうためです。
改善策(メモ化/反復/尾再帰)
メモ化(結果の使い回し)
一度求めた値を配列に保存して再利用すると、計算量は線形程度に改善します。
ここでは配列を引数で受け取り、グローバルに依存しない形にしています。
#include <stdio.h>
#include <stdlib.h>
// メモ化つきフィボナッチ
long long fib_memo(int n, long long *memo, int memo_size) {
if (n < 0) return -1; // 簡易エラー扱い
if (n < memo_size && memo[n] != -1) {
return memo[n]; // 既計算の結果を再利用
}
long long v;
if (n <= 1) {
v = n; // ベースケース
} else {
v = fib_memo(n - 1, memo, memo_size) + fib_memo(n - 2, memo, memo_size);
}
if (n < memo_size) memo[n] = v; // 配列範囲内なら保存
return v;
}
int main(void) {
int n = 50;
int memo_size = n + 1;
long long *memo = (long long *)malloc(sizeof(long long) * memo_size);
if (!memo) return 1;
for (int i = 0; i < memo_size; ++i) memo[i] = -1;
long long v = fib_memo(n, memo, memo_size);
printf("fib(%d) = %lld\n", n, v);
free(memo);
return 0;
}
fib(50) = 12586269025
この方法なら大きなnでも高速に求められます。
反復(ループ)での計算
ループで前から順番に2項の和を積み上げると、最もシンプルで効率的です。
// 反復版フィボナッチ
long long fib_iter(int n) {
if (n <= 1) return n;
long long a = 0, b = 1; // a=fib(0), b=fib(1)
for (int i = 2; i <= n; ++i) {
long long c = a + b;
a = b;
b = c;
}
return b;
}
尾再帰(最適化が効けばループ相当)
中間結果を引数で持ち回り、最後にそのまま返せる形にした再帰を尾再帰といいます。
Cコンパイラによっては最適化が働かないこともあるため、深い再帰での安全性は環境依存です。
// 尾再帰版フィボナッチ(最適化があればループ相当)
long long fib_tail_helper(int n, long long a, long long b) {
if (n == 0) return a;
if (n == 1) return b;
return fib_tail_helper(n - 1, b, a + b); // 末尾で自分を呼ぶ
}
long long fib_tail(int n) {
return fib_tail_helper(n, 0, 1);
}
どれを選ぶか
- 学習・定義どおりに書きたい: 素直な再帰(ただしnは小さく)
- 実用・高速化重視: 反復、またはメモ化
- 再帰スタイルを維持しつつ効率改善: メモ化、(環境が対応していれば)尾再帰
デバッグのコツとスタックオーバーフロー
小さな入力から始める
まずは0、1、2などの最小ケースで正しく動くかを確認します。
ベースケースの漏れや条件の矛盾は、ここで早期に発見できます。
深さの可視化
再帰の深さを引数で受け取り、インデント付きログを出すと流れを把握しやすくなります。
先ほどの階乗トレースのように「enter」「return」を対にするのが有効です。
// 簡易インデント関数(学習用)
void indent(unsigned int depth) {
for (unsigned int i = 0; i < depth; ++i) {
printf(" "); // 2スペース
}
}
スタックオーバーフローの兆候と回避
終了条件の不備や、入力が極端に大きいと、実行時に「セグメンテーションフォルト」や「スタックオーバーフロー」エラーが発生します。
対策としては、ベースケースと入力チェックの厳格化、深さが大きくなるケースでは反復実装で置き換えることが実務的です。
まとめ
再帰関数は、ベースケースと再帰ステップを正しく設計し、呼び出しスタックの挙動を理解することで、安全で読みやすいコードを書けます。
階乗では定義どおりに短く書け、フィボナッチでは重複計算のある素直な再帰が遅いことを、メモ化と反復により補えると学びました。
始めは小さな入力とトレースで流れを確かめ、無限再帰を防ぐ条件分岐を徹底しましょう。
最終的には、問題に最も適した表現(再帰か反復か)を選び、読みやすさと実行効率のバランスを取ることが大切です。