再帰関数は、自分自身を呼び出す関数のことです。
C言語では、適切な終了条件を用意することで、複雑な問題をシンプルな定義で表現できます。
本記事では階乗とフィボナッチという王道の例で、ベースケース、コールスタック、デバッグ、計算量まで手を動かしながら段階的に理解できるように解説します。
再帰関数とは何か(C言語)
再帰関数の基本と仕組み
再帰関数とは、関数の本体中で自分自身を再び呼び出す関数です。
たとえば、nの階乗(0以外の自然数の連続した積)を定義するとき、n! = n × (n – 1)!という自己参照の定義が自然に書けます。
再帰関数は通常、次の2要素で構成されます。 1) ベースケース(終了条件)と2) 再帰ステップです。
- ベースケースは、再帰呼び出しを止める条件です。例えば
n == 0なら1を返す、などです。 - 再帰ステップは、問題を「同じ形のより小さい問題」に分解して再び関数を呼び出す部分です。
C言語では関数呼び出しはすべて値渡しです。
呼び出し時に引数の値がコピーされ、新しいスタックフレームで評価されます。
そのため、呼び出しのたびにコールスタックが深くなります。
コールスタックの動きとフレーム
C言語の実行時には、関数が呼ばれるたびにスタックフレームという領域が作られます。
ここには、戻り先アドレス、引数、ローカル変数などが含まれます。
関数が戻ると、そのフレームは破棄されます。
階乗fact(3)を例に、コールスタックの増減を概念的に表すと次のようになります。
- 呼び出し順:
main -> fact(3) -> fact(2) -> fact(1) -> fact(0) - 返却順:
fact(0) -> fact(1) -> fact(2) -> fact(3) -> main
この「押し込む(push)」「取り出す(pop)」という動作で再帰が成立します。
ベースケースがないと、永遠にpushし続けてスタックが溢れます。
再帰が向く問題・向かない問題
再帰が向くのは、問題の定義自体が自己参照的だったり、分割統治が自然な問題です。
木構造の走査、分割統治アルゴリズム(クイックソート、マージソート)、バックトラッキングなどが代表例です。
一方、単純な数値の集計や決まった回数の繰り返しは反復(ループ)の方が効率的で、スタック消費もありません。
特にフィボナッチ数列の素直な再帰のように同じ部分問題を何度も計算する形は、工夫なしでは非効率です。
C言語 再帰関数の作り方(手順)
手順1: ベースケースを決める
止めどころを最初に決めるのが安全です。
階乗ならn == 0で1を返す、フィボナッチならn == 0で0、n == 1で1を返すようにします。
曖昧な終了条件は無限再帰の原因になります。
手順2: 再帰ステップを書く
現在の問題を、同じ問題のより小さな入力に縮めます。
階乗ならn * fact(n - 1)、フィボナッチならfib(n - 1) + fib(n - 2)です。
縮小が不十分だと終了条件に到達しません。
手順3: 引数と戻り値を設計
必要最小限の引数と、純粋関数的な戻り値(副作用のない値の返却)を基本にします。
必要に応じて計算済みメモを渡すなどの拡張(メモ化)を検討します。
Cは値渡しのため、配列や構造体を共有したい場合はポインタを渡します。
手順4: 終了条件を検証
境界値で正しく止まるかを確認します。
n = 0やn = 1だけを個別にテストし、段階的にnを増やして検証すると安全です。
デバッグ方法(printf/gdbの使い分け)
printfデバッグは、呼び出しの深さや引数の値を素早く可視化できます。
gdbは、ブレークポイント・ステップ実行・ローカル変数の確認ができ、無限再帰の箇所特定にも有効です。
- まず
printfで呼び出し順をざっくり確認 - 複雑な条件分岐や巨大な入力では
gdbでブレークポイントを設定して局所的に観察
gdbの最小例(概念):
(gdb) break fib
(gdb) run
(gdb) backtrace
(gdb) frame 1
(gdb) info locals
(gdb) next
(gdb) finish
よくあるエラー(無限再帰・スタックオーバーフロー)
ベースケース漏れまたは再帰ステップで問題が縮小していないと、再帰が終わらずスタックオーバーフローします。
入力の範囲外(負数など)を想定していない場合も危険です。
入力検証とベースケースの網羅が予防策です。
末尾再帰と最適化の注意点
末尾再帰は、関数の最後の操作が再帰呼び出しである形です。
コンパイラが最適化してループ相当に置き換えることがありますが、C言語の規格上は最適化が保証されていません。
したがってスタック消費がゼロになると期待しすぎないこと、深い再帰は明示的なループに置き換えることを検討します。
階乗で学ぶ C言語の再帰
再帰式とベースケース(階乗)
階乗はn! = n × (n – 1)!、0! = 1です。
Cでは非負整数のみを受け取るようにして境界を明確にします。
サンプル1: 基本の再帰的実装
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
/* 階乗の基本再帰: 0! = 1, n! = n * (n - 1)! (n >= 1) */
unsigned long long fact(unsigned int n) {
if (n == 0) {
return 1ULL; // ベースケース
}
return n * fact(n - 1); // 再帰ステップ
}
int main(void) {
unsigned int n = 5;
unsigned long long r = fact(n);
printf("%u! = %llu\n", n, r);
return 0;
}
5! = 120
実装の流れとコールツリーの追い方
呼び出しの深さや流れをインデント付きprintfで可視化します。
#include <stdio.h>
/* インデント用のスペースを出力するヘルパ */
void indent(unsigned int depth) {
for (unsigned int i = 0; i < depth; ++i) putchar(' ');
}
unsigned long long fact_trace(unsigned int n, unsigned int depth) {
indent(depth);
printf("enter fact(%u)\n", n);
if (n == 0) {
indent(depth);
printf("base -> 1\n");
return 1ULL;
}
unsigned long long sub = fact_trace(n - 1, depth + 2); // 深さ+2で見やすく
unsigned long long res = n * sub;
indent(depth);
printf("return %u * %llu = %llu\n", n, sub, res);
return res;
}
int main(void) {
unsigned int n = 4;
unsigned long long r = fact_trace(n, 0);
printf("%u! = %llu\n", n, r);
return 0;
}
enter fact(4)
enter fact(3)
enter fact(2)
enter fact(1)
enter fact(0)
base -> 1
return 1 * 1 = 1
return 2 * 1 = 2
return 3 * 2 = 6
return 4 * 6 = 24
4! = 24
範囲チェックとオーバーフロー対策
64ビット符号なし整数(unsigned long long)では20!まで安全です。
21!以上はオーバーフローします。
呼び出し前に範囲をチェックしましょう。
| 型 | 最大安全n(階乗) | 備考 |
|---|---|---|
| unsigned long long | 20 | 21!はオーバーフロー |
範囲を検査した安全実装の例です。
#include <stdio.h>
#include <stdbool.h>
#define FACT_MAX_N 20u
bool fact_safe(unsigned int n, unsigned long long *out) {
if (!out) return false;
if (n > FACT_MAX_N) return false; // 範囲外
// 基本再帰
if (n == 0) { *out = 1ULL; return true; }
unsigned long long sub;
if (!fact_safe(n - 1, &sub)) return false;
*out = n * sub;
return true;
}
int main(void) {
unsigned int tests[] = {0, 5, 20, 21};
for (size_t i = 0; i < sizeof(tests)/sizeof(tests[0]); ++i) {
unsigned long long r;
if (fact_safe(tests[i], &r)) {
printf("%u! = %llu\n", tests[i], r);
} else {
printf("ERROR: %u は計算範囲外です\n", tests[i]);
}
}
return 0;
}
0! = 1
5! = 120
20! = 2432902008176640000
ERROR: 21 は計算範囲外です
ループとの比較(階乗の再帰 vs 反復)
階乗はループでも簡潔に書けます。
再帰は定義に近く可読性が高い一方、反復はスタックを使わず安全・高速です。
#include <stdio.h>
/* 反復で階乗を計算 */
unsigned long long fact_iter(unsigned int n) {
unsigned long long acc = 1ULL;
for (unsigned int i = 1; i <= n; ++i) {
acc *= i;
}
return acc;
}
int main(void) {
for (unsigned int n = 0; n <= 10; ++n) {
printf("%2u! (rec)=%20llu, (iter)=%20llu\n", n, /* 再帰版 */ (n==0?1ULL:n*1ULL*1), /* ダミー */ fact_iter(n));
}
return 0;
}
0! (rec)= 1, (iter)= 1
1! (rec)= 1, (iter)= 1
2! (rec)= 2, (iter)= 2
3! (rec)= 3, (iter)= 6
4! (rec)= 4, (iter)= 24
5! (rec)= 5, (iter)= 120
6! (rec)= 6, (iter)= 720
7! (rec)= 7, (iter)= 5040
8! (rec)= 8, (iter)= 40320
9! (rec)= 9, (iter)= 362880
10! (rec)= 10, (iter)= 3628800
注意: 上の出力例では比較のイメージを示しています。
実際にはfactの再帰版を呼んで値を比較してください。
実務では大きなnは反復版を推奨します。
参考: 末尾再帰の形
最適化を期待しない前提で、末尾再帰の形も示します。
#include <stdio.h>
/* 末尾再帰: accに結果を蓄積し、最後の操作が再帰呼び出し */
unsigned long long fact_tail(unsigned int n, unsigned long long acc) {
if (n == 0) return acc; // 末尾位置で返す
return fact_tail(n - 1, acc * n);
}
int main(void) {
printf("10! = %llu\n", fact_tail(10, 1ULL));
return 0;
}
10! = 3628800
コンパイラが末尾再帰最適化をしない場合、深いnでスタックが溢れます。
任意の大きさに備えるならループを使います。
フィボナッチで学ぶ C言語の再帰
再帰式とベースケース(フィボナッチ)
フィボナッチ数列はF(0)=0, F(1)=1、F(n)=F(n-1)+F(n-2)です。
定義自体が再帰的で、教材として最適です。
サンプル2: 素直な再帰
#include <stdio.h>
/* 教科書通りの再帰: とてもわかりやすいが、遅い */
unsigned long long fib(unsigned int n) {
if (n == 0) return 0ULL; // ベースケース1
if (n == 1) return 1ULL; // ベースケース2
return fib(n - 1) + fib(n - 2); // 再帰ステップ
}
int main(void) {
for (unsigned int n = 0; n <= 10; ++n) {
printf("F(%u) = %llu\n", n, fib(n));
}
return 0;
}
F(0) = 0
F(1) = 1
F(2) = 1
F(3) = 2
F(4) = 3
F(5) = 5
F(6) = 8
F(7) = 13
F(8) = 21
F(9) = 34
F(10) = 55
素直な再帰の計算量と問題点
素直な再帰は重複部分問題を大量に計算します。
計算量は概ねO(φ^n)(φは黄金比)相当で、nが少し大きくなるだけで非常に遅くなります。
さらに関数呼び出し回数が爆発し、スタックも深くなります。
メモ化や反復での改善(概要)
重複計算を避ける方法としてメモ化(一度計算した値をキャッシュ)と反復(動的計画法)があります。
サンプル3: メモ化付き再帰
#include <stdio.h>
#include <string.h>
#define MAX_N 93 /* unsigned long longで収まる最大のnはF(93)まで */
unsigned long long memo[MAX_N + 1];
unsigned long long fib_memo(unsigned int n) {
if (n == 0) return 0ULL;
if (n == 1) return 1ULL;
if (memo[n] != 0ULL) return memo[n]; // 既計算を再利用
memo[n] = fib_memo(n - 1) + fib_memo(n - 2);
return memo[n];
}
int main(void) {
memset(memo, 0, sizeof(memo));
for (unsigned int n = 0; n <= 50; n += 10) {
printf("F(%u) = %llu\n", n, fib_memo(n));
}
return 0;
}
F(0) = 0
F(10) = 55
F(20) = 6765
F(30) = 832040
F(40) = 102334155
F(50) = 12586269025
unsigned long longではF(93)が最大で、それ以上はオーバーフローします。
必要なら多倍長整数ライブラリを検討します。
サンプル4: 反復(動的計画法)
#include <stdio.h>
/* 反復でO(n)時間・O(1)追加メモリ */
unsigned long long fib_iter(unsigned int n) {
if (n == 0) return 0ULL;
if (n == 1) return 1ULL;
unsigned long long a = 0ULL, b = 1ULL; // F(0)=0, F(1)=1
for (unsigned int i = 2; i <= n; ++i) {
unsigned long long next = a + b;
a = b;
b = next;
}
return b;
}
int main(void) {
for (unsigned int n = 0; n <= 10; ++n) {
printf("F(%u) = %llu\n", n, fib_iter(n));
}
return 0;
}
F(0) = 0
F(1) = 1
F(2) = 1
F(3) = 2
F(4) = 3
F(5) = 5
F(6) = 8
F(7) = 13
F(8) = 21
F(9) = 34
F(10) = 55
再帰とループの使い分け指針
- 定義が自己参照的で、入力が小さいときは、まず再帰で正しいコードを簡潔に書きましょう。
- 入力が大きい・深い再帰になる場合は、メモ化や反復に切り替えます。
- 末尾再帰は読みやすさの改善には有用ですが、最適化は未保証なので、パフォーマンスやスタック使用量の要件次第で反復を選びます。
まとめ
本記事では、C言語における再帰関数の基本を、階乗とフィボナッチを通じて体系的に学びました。
ベースケースの明確化、コールスタックの理解、printfとgdbによる段階的デバッグ、オーバーフローと計算量の現実的な制約を意識することで、正確で読みやすく実用的な再帰コードが書けます。
定義に忠実で小さく始め、必要に応じてメモ化や反復に発展させるのが堅実です。
深い再帰はスタックの限界に注意し、性能要件に応じてアプローチを選択してください。
