再帰呼出し

ある関数 f の定義の中に f 自身を呼び出している箇所があるとき、その呼び出しを再帰呼出し(recursive call)と言います。また、そのような定義は再帰的(recursive)である、と言います。

191x181(381bytes)
f が f 自身を呼出す

f の定義の中に直接 f 自身の呼び出しが含まれていなくても、fg を呼び出し、gf を呼出す、というようになっていれば、再帰的な定義だと考えます。f の実行中にもう一度 f の実行に入る可能性があるからです。3つ以上の関数が互いを呼び出し合っている場合についても同様です。

621x186(1560bytes)
相互的な再帰

再帰呼出しと言っても、呼出しのための特別な構文があるわけではなく、普通に関数を呼出すだけです。呼出す側と呼出される側がたまたま同じ関数になっているだけのことです。しかし、再帰呼出しには特別な魅力があります。何かの処理をくり返したいようなとき、for 文や while 文のようなくり返し構文を使うことが多いわけですが、そうした構文を使わずに再帰的な定義により計算することも可能です。実際、ある種の処理はくり返しを使うよりも再帰的に定義したほうが自然かつ簡潔に記述できることがあります。

この章では、再帰的な関数定義の比較的簡単な例を学びます。

再帰呼出しの例

再帰呼出しの簡単な例としてよく引合いに出されるのが階乗 n! の計算です。 念のために書いておくと、n! = n × (n - 1) × (n - 2) × … × 1 であって、 0! = 1 と定義されています。例えば、3! = 3 × 2 × 1 = 6 となります。n が負の場合、n! は普通定義されません。

n を受け取って n! を返す関数 fact を再帰的に定義してみると、次のようになります。

#include <stdio.h> int fact(int n) { int m; if (n == 0) return 1; // 0! = 1 /* 以下、n が 0 でないとき */ m = fact(n - 1); // (n-1)! を求めてそれを m とおく。ここのfact(n-1)が再帰呼出し。 return n * m; // n! = n * m } int main() { int i, ans; scanf("%d", &i); ans = fact(i); printf("%dの階乗は%d.\n", i, ans); return 0; }

fact の定義の中に、fact(n-1)という形でfact自身を呼出している箇所があるので、factの定義は再帰的であることに注意して下さい。

上のプログラムを fact.c というファイルに記述して、コンパイル・実行してみると次のようになります。

cc2001(82)% cc -o fact fact.c cc2001(83)% ./fact 3 ←ユーザからの入力 3の階乗は6. cc2001(84)%

上のような再帰的呼出しによって正しく階乗が求められる理由は、第一に次のような2つの等式が成立するからです:

1つ目の等式は、0の階乗の定義そのものです。2つ目の等式については、例えば 4! = 4 × 3 × 2 × 1 = 4 × (3 × 2 × 1) = 4 × 3! であることから理解できると思います。上の等式において n! の代りに fact(n) と書いてやると次のようになります:

上のプログラムを見ると、fact の定義はこの2つの等式をそっくり C 言語の表記に直したような形をしていることがわかるでしょう。

第二のポイントは、fact(n) の計算が負でない整数 n に対して必ず終了するようにプログラムが作られていることです。fact(1)、つまり n が 1 の場合を考えてみます。

fact(2) についても同様です。fact(2) の実行の中で再帰的に fact(1) を呼出しますが、上で説明したように fact(1) は 1 と求まりますから、m = fact(1); return n * m によって fact(2) は 2 * 1、つまり 2 を返して終了します。

上で説明したことを図にすると下のようになります。ユーザが2を入力して、fact(2)が呼出されたあと再帰呼出しが進行するようすを示しています。

936x215(3444bytes)
fact(2)の実行が進むようす

この図のように、呼出しが入れ子状になって連鎖して行くわけですが、この入れ子がどこかで止まってくれなければ困ります。n が負でないときは、fact(n) の呼出しの入れ子は無限に続きません。fact(n) が fact(n-1) を呼び、fact(n-1) が fact(n-2) を呼び、…という形で、引数が n, n-1, n-2, ... とだんだん小さくなって行くことがポイントです。そのうち必ず引数は0となり、fact(0) は fact を呼ばないので、入れ子は止まります。

一般的に、次の2条件を守ってプログラムを書けば再帰的なプログラムはきちんと終了します:

fact の場合、引数が負でない範囲ではこれらの条件が成り立っているので計算は止まります。

しかし、負の引数も含めると上の条件は成り立ちません。 n が負の場合に fact(n) を計算しようとすると、呼出しの入れ子がいくらでも深くなってしまいます。fact(-1) が fact(-2) を呼び、それが fact(-3) を呼び、…という形で、引数がどんどん負の方向に進んで行ってしまうからです。これは for 文や while 文の場合で言えば無限ループに相当する現象です。実際にプログラムを走らせてみると、無限に走り続けるわけではなく、エラーが発生します:

cc2001(106)% ./fact -1 セグメントエラー

エラーになる理由は、プログラムに与えられたあるメモリ領域を使いつくしてしまうからです。その領域は「スタック」と呼ばれており、一般に関数呼出しの入れ子が深くなるたびにスタック領域が消費されるようになっています。詳しくはコンピュータ基礎1・2で学びますので、ここではスタックについてこれ以上述べません。

数学が得意な人は次のような見方をしてみると再帰がわかりやすくなるかも知れません。 fact(n) を anと書くと、次のようになります:

これは、数列 a0, a1, a2, ... を定義する漸化式になっています。実は高校数学で習った漸化式というのは再帰的定義の一種と考えられます。あるいは逆に言えば、再帰的定義というのは、漸化式を一般化したようなものだとも言えます。「再帰呼出しをしないケースを作っておく」ということは、数列では初項を決めておくことに相当します。

再帰的なグラフィクス

再帰的なプログラムによって面白いグラフィクスを作り出すことができます。芸術的なグラフィクスがそのようにして作られているのをよく見ますが、ここではごく簡単な例をやってみましょう。見た目に面白いだけでなく、「再帰を使うと書きやすいがループでは書きづらいプログラム」の例にもなっています。

簡単なフラクタル図形

フラクタル図形と呼ばれるものの簡単な一例を描いてみます。次のプログラムは三角形の複雑な集まりを描きます。

#include <stdio.h> #include <eggx.h> #define R3 1.732 int win; void triangle(int n, float x, float y, float len) // void は値を返さないという意味。 { // void についての詳細はあとの章で。 float left_x, left_y, right_x, right_y; left_x = x - len/2.0; // 左下の頂点の x 座標位置 right_x = x + len/2.0; // 右下の頂点の x 座標位置 left_y = right_y = y - len/2.0*R3; // 両頂点の y 座標位置 // 三つの頂点を一周して三角形を描く line(win, x, y, PENUP); line(win, left_x, left_y, PENDOWN); line(win, right_x, right_y, PENDOWN); line(win, x, y, PENDOWN); if (n < 1) { return; } else { triangle(n-1, left_x, left_y, len/2.0); // 再帰 triangle(n-1, right_x, right_y, len/2.0); // 再帰 } } int main() { int n; float x=250.0, y=500.0, len=200.0; win = gopen(500, 500); printf("レベル = "); scanf("%d", &n); // レベルを入力 triangle(n, x, y, len); ggetch(win); gclose(win); return 0; }

このプログラムを tri.c というファイルに記述し、egg -o tri tri.c によってコンパイルします。実行すると、レベルの入力を求められるので、0以上の整数を入力します。(負の入力は0と同じになります。)レベルが上がるに従って複雑なグラフィクスになります。実行結果を下に示します(タテ・ヨコを1/2に縮小してあります):

250x250(1724bytes) 250x250(2510bytes) 250x250(2873bytes)
レベル 0 レベル 1 レベル 2
250x250(3289bytes) 250x250(3651bytes) 250x250(3867bytes)
レベル 3 レベル 4 レベル 5

triangle(n, x, y, len) という関数は、(x, y) という点を頂点としてレベル n の図形を描きます。len は、頂点にある一番大きな正三角形の一辺の長さです。 レベル n の図形は次のようにして構成されています:

この構成のようすを n = 3 の場合に図示すると下のようになります:

501x500(5708bytes)
レベル 3 図形の分析

この構成が理解できれば、関数 triangle の定義を理解するのは難しくないでしょう。 triangle(n, x, y, len) はまず頂点の正三角形を描きます。そのあと、レベルが (n - 1) で len が半分の図形を左隅と右隅に描きます。それにはもちろん再帰呼出しを使っています。ただし、レベル 0 の場合には、頂点の正三角形だけがあればよいので、再帰呼出しをしません。この関数が

という条件に従っていることを確認しましょう。これにより、再帰呼出しはきちんと終了します。

この関数は自分自身を2回再帰的に呼出します。1回しか再帰呼出しをしない関数は、再帰を使わずにループを使う形に書き直すことが容易な場合も多いのですが、一般に2回以上再帰呼出しをする関数は、再帰を使わない形に記述するのが面倒になる場合が多いです。

課題

演習1.

上の例をまねて、下のような図形を描くプログラムを書いて下さい。(下の図はタテ・ヨコを1/2に縮小してあります。)

250x150(516bytes) 250x150(791bytes) 250x150(994bytes)
レベル 0 レベル 1 レベル 2
250x150(1251bytes) 250x150(1502bytes) 250x150(1613bytes)
レベル 3 レベル 4 レベル 5

下のプログラムを完成させる形で書けばよいでしょう:

#include <eggx.h> int win; void tree(int n, float x, float y, float len) { float left_x, left_y, right_x, right_y; left_x = x-len; // 左の枝の上端の x 座標位置 right_x = x+len; // 右の枝の上端の x 座標位置 left_y = right_y = y+len; // 左右の枝の上端の y 座標位置 // 2本の枝を描く line(win, left_x, left_y, PENUP); line(win, x, y, PENDOWN); line(win, right_x, right_y, PENDOWN); if (n < 1) { // ここを自分で書く } else { // ここを自分で書く } } int main() { int n; float x=250.0, y=20.0, len=100.0; win = gopen(500,300); printf("レベル = "); scanf("%d", &n); tree(n, x, y, len); ggetch(win); gclose(win); return 0; }

演習2.

演習1 で作ったプログラムを改造して、面白い図形を描くプログラムを作りなさい。 例えば、再帰呼出しの深さごとに線分の色を変える、枝分かれの角度を変化させてみる、乱数によって図形を変化させてみる、線分の代りに円を描くなど、色々な工夫が考えられるでしょう。