ループと再帰の関係
プログラマなら誰もが知っている再帰ですが、よくC言語のポインタと並んで初心者がよくつまづく概念としても有名(?)です。今回はこの再帰(特に末尾再帰について)と基本的な制御構造の一つであるループとの関係について考えてみます。
ループ構文というのは大抵の言語に存在する制御構造ですが、世の中にはループ構文が存在しない言語もたくさんあります(gotoでジャンプしてるわけではありませんよ)。このような言語ではループの代わりに再帰呼び出しを使います。一見するとループと再帰呼び出しは別物のように見えますが、よーく見ていくと実はループと再帰呼び出し(特に末尾再帰)はとてもよく似ているのです。
例えば、次のようなループである範囲の整数を順に標準出力に出力するメソッドは
void loopSample(int min, int max) { for (int i = min; i < max; i++) { System.out.println(i); } }
ループを使わないで、これと同じ動作をするメソッドは次のように書けます。
void recSample(int min, int max) { if (min < max) { System.out.println(min); recSample(min + 1, max); } }
これは末尾再帰という形で再帰呼び出しをすることでループと同等のコードを再帰で記述しています。
では末尾再帰はただの再帰とどう違うのでしょうか。
重複のない順列の個数を求めるループと(末尾再帰でない)普通の再帰呼び出しと末尾呼び出しのコードを比較してみましょう。
①ループ
int loopPermutation(int n, int m) { int result = 1; for (int i = m; i > n; i--) { result *= i; } return result; }
②再帰
int recPermutation(int n, int m) { if (n >= m) { return 1; } else { return m * recPermutation(n, m - 1); } }
③末尾再帰
int tailRecPermutation(int n, int m, int acc) { if (n <= m) { return acc; } else { return tailRecPermutation(n - 1, m, n * acc); } }
重複のない順列の個数というのは、n個の異なる要素からm個の要素を選び出した順列の個数で nPm と表されます(中学で習いましたね)。この値はnの階乗を(n-m)の階乗で割ることで求めることができるのですが、これは n * (n - 1) * ... * (m + 2) * (m + 1)、つまりnから(m+1)までの積求めることができます。
このアルゴリズムをループ、再帰で記述すればそれぞれ①、②のようになることでしょう。②の再帰版は①と同様に動作しますが、再帰が深くなるとスタックオーバーフローを起こします。これは引数やローカル変数をスタックに積んでメソッドを呼び出すためです。
これに対して③の末尾再帰版は引数が一つ増えており、この引数で計算結果を引き回しています。
これらのコードがどのように動作するかを見るために、上の3つのメソッドを実装しているクラスを継承して次のようなコードを実装して動かしてみます。
@Override int loopPermutation(int n, int m) { int result = 1; for (int i = n; i > m; i--) { result *= i; System.out.println("Loop loopPermutation(" + this.count + "): i = " + i + ", result = " + result); this.count++; } this.count = 0; return result; } @Override int recPermutation(int n, int m) { System.out.println(indent(this.count) + "Call recPermutation(" + this.count + "): n = " + n + ", m = " + m); this.count++; int result = super.recPermutation(n, m); this.count--; System.out.println(indent(this.count) + "Return recPermutation(" + this.count + "): result = " + result); return result; } @Override int tailRecPermutation(int n, int m, int acc) { System.out.println(indent(this.count) + "Call tailRecPermutation(" + this.count + "): n = " + n + ", m = " + m + ", acc = " + acc); this.count++; int result = super.tailRecPermutation(n, m, acc); this.count--; System.out.println(indent(this.count) + "Return tailRecPermutation(" + this.count + "): result = " + result); return result; }
このコードを実行すると次のような出力が得られます。
①loopPermutation:
Loop loopPermutation(0): i = 4, result = 4
Loop loopPermutation(1): i = 3, result = 12
②recPermutation:
Call recPermutation(0): n = 4, m = 2
Call recPermutation(1): n = 3, m = 2
Call recPermutation(2): n = 2, m = 2
Return recPermutation(2): result = 1
Return recPermutation(1): result = 3
Return recPermutation(0): result = 12
③tailRecPermutation:
Call tailRecPermutation(0): n = 4, m = 2, acc = 1
Call tailRecPermutation(1): n = 3, m = 2, acc = 4
Call tailRecPermutation(2): n = 2, m = 2, acc = 12
Return tailRecPermutation(2): result = 12
Return tailRecPermutation(1): result = 12
Return tailRecPermutation(0): result = 12
②と③のReturnに注目してください。②では呼び出しから返るたびに返り値が異なっています。これに対して③では返り値がすべて同じです。これにより③のコードはReturnするコードを削除することができるようになり、またスタックに引数やローカル変数を保存しておく必要がなくなり、最適化することで結局ループと同じになるのです。
でも③の末尾再帰のコードを大きな数値で実行してみるとスタックオーバーフローするじゃないか、と言う人も居るかもしれません。その通りです。残念ながらJavaはこのような末尾再帰の最適化はしてくれないのでした。(何)