コンテンツへスキップ
Topos Uranos

要約

関数は自分自身を呼び出すことができる。再帰は、問題を同じ問題のより小さな事例に帰着させて解く。このレッスンでは、数学の再帰的定義(階乗、累乗、各桁の和)をそれぞれの基底と再帰段階とともに C++ に移し、帰納法がその正しさを、減少する尺度がその停止を保証することを示す。また、積み重なっては取り除かれるスタックフレームをコールスタックで追い、スタックがいつ尽きるかを説明する。さらに、スタックよりずっと早くオーバーフローする結果の型の限界を調べ、フィボナッチ数の再帰版と反復版を呼び出しの回数で比べ、最後に再帰とループのどちらを選ぶかの基準を述べる。

学習目標

  1. 再帰的定義から、基底と再帰段階をもつ再帰関数を書く。
  2. 再帰関数の正しさと停止を、問題の大きさについての帰納法で論証する。
  3. 再帰関数の実行をコールスタックで追い、スタックオーバーフローを説明する。
  4. フィボナッチ数の再帰版と反復版のコストを、呼び出しと演算の回数を数えて比べる。

再帰的定義

階乗

自然数 nn の階乗は、1 から nn までの自然数の積 n!=1⋅2⋯nn! = 1 \cdot 2 \cdots n であり、0!=10! = 1 と約束する。この式の省略記号はループを隠しており、その書き方はレッスン ループ で学んだ。しかし、同じ定義はループに一切触れない別の形も許す。nn の階乗は、nn に n−1n - 1 の階乗を掛けたものであり、0 の階乗は 1 である。

0!=1,n!=n⋅(n−1)!(n≥1).0! = 1, \qquad n! = n \cdot (n - 1)! \quad (n \geq 1).

これは再帰的定義であり、nn の階乗をより小さい数の階乗によって定義する。循環論法ではない。二番目の規則を一回使うたびに階段を一段下り、その階段は一番目の規則で終わるからである:3!=3⋅2!=3⋅2⋅1!=3⋅2⋅1⋅0!=63! = 3 \cdot 2! = 3 \cdot 2 \cdot 1! = 3 \cdot 2 \cdot 1 \cdot 0! = 6。二つの規則には名前がある。一番目は基底で、ほかの何にも頼らずに結果を与える。二番目は再帰段階で、問題を同じ問題のより小さな事例に帰着させる。

C++ では関数が自分自身を呼び出すことができ、そのおかげで定義はほとんど一語一語そのまま移せる。

例再帰による階乗
C++ のプログラム
#include <print>​// n の階乗。// 事前条件:0 <= n <= 12(この上限の理由は後で述べる)。// 事後条件:n! = 1 · 2 · ... · n を返す。ただし 0! = 1。int factorial(int n){    if (n == 0) {        return 1;                   // 基底    }    return n * factorial(n - 1);    // 再帰段階}​int main(){    for (int n{0}; n <= 6; ++n) {        std::println("{}! = {}", n, factorial(n));    }}
出力
0! = 11! = 12! = 23! = 64! = 245! = 1206! = 720

この関数は定義とまったく同じ形をしている。if が基底を扱い、return が再帰段階を適用する。呼び出し factorial(n - 1) は、レッスン 関数 のほかのどの呼び出しとも変わらない。実引数が計算され、自分専用の n をもつ新しいフレームが作られ、終わると、返された値に呼び出し側の n が掛けられる。

契約は飾りではない。n=−1n = -1 のとき、再帰段階は −2-2 で、続いて −3-3 で呼び出し、0 には決して届かない。また 13!13! は int に収まらない。多くの入門書はこの階乗を基底 if (n == 0 || n == 1) で書くが、これは冗長でも無害である。しかし負の数についても結果の大きさについても何も言わないので、そのコードは n<0n < 0 では停止せず、13!13! からは意味のない結果を出す。この二つの欠陥は、それぞれの節で調べる。

基底と再帰段階

どの再帰関数にも、直接解かれる一つ以上の基底と、ある正確な意味でより小さい実引数で同じ関数を呼び出し、その戻り値を組み合わせる一つ以上の再帰段階がある。

累乗 xnx^n は、nn を自然数として、x0=1x^0 = 1 と xn=x⋅xn−1x^n = x \cdot x^{n - 1} で定義される。ここでの大きさは指数である。自然数 nn の各桁の和(2026 なら 2+0+2+6=102 + 0 + 2 + 6 = 10)は次のように定義される。n<10n < 10 ならば、和は nn である。桁が一つしかないからである。そうでなければ、⌊n/10⌋\lfloor n / 10 \rfloor の各桁の和(これは nn から最後の桁を除いた数である)に、その最後の桁 n mod 10n \bmod 10 を加えたものである。ここでの大きさは桁数である。

例累乗と各桁の和
C++ のプログラム
#include <print>​// x の n 乗。// 事前条件:n >= 0。// 事後条件:x^n を返す。ただし x^0 = 1。double power(double x, int n){    if (n == 0) {        return 1.0;    }    return x * power(x, n - 1);}​// n の十進の各桁の和。// 事前条件:n >= 0。// 事後条件:n の各桁の和を返す。int digitSum(int n){    if (n < 10) {        return n;    }    return digitSum(n / 10) + n % 10;}​int main(){    std::println("2^10 = {}", power(2.0, 10));    std::println("1.5^3 = {}", power(1.5, 3));    std::println("2026 の各桁の和:{}", digitSum(2026));}
出力
2^10 = 10241.5^3 = 3.3752026 の各桁の和:10

digitSum では、整数除算 n / 10 が最後の桁を取り除き、n % 10 がその桁を取り出す。レッスン 数値アルゴリズム の基数変換と同じである。n=2026n = 2026 のとき、関数は 202 の各桁の和を求め、それが 20 の和を求め、それが 2 の和を求める。これは基底で 2 を返し、結果は戻る途中で組み立てられる:2+0=22 + 0 = 2、2+2=42 + 2 = 4、4+6=104 + 6 = 10。

したがって、再帰関数を書くとは二つの問いに答えることである。どの場合が助けなしに解けるか、そして、ある場合の解をより小さい場合の解からどう得るか。両方の答えが正しければ関数も正しく、それを以下の節で証明する。