末尾再帰最適化について
末尾再帰最適化について:
自分の中での整理用に書きました。
詳しくは参考記事を参照してください。
末尾再帰最適化について、再帰関数やスタックオーバーフローにも触れつつ書いていきます。
関数の中でその関数自身を呼び出す関数のこと
例)階乗n!を求める関数
6!=6*5*4*3*2*1=720
みたいに、その数字以降全ての積を表すのが階乗
これを関数にすると、
ただし、この関数には問題があり、nが非常に大きな数になった場合にスタックオーバーフローが起きてしまう
上記の階乗の計算を求める際、例えばn=10のとき、
factorial(10)=10*factorial(9)なので、factorial(9)の計算結果が必要となるためfactorial(9)を計算しにいく。
このとき、10*factorial(9)の10をどこかに記憶しておかないといけない。
同様にfactorial(9)では9*factorial(8)となるので9を記憶し、これがfactorial(1)=1と分かるまで繰り返される。
この記憶の積み重ねがメモリに溜まっていく。(このときに使われるデータ構造がスタック)
nが大きくなればなるほどメモリが必要になるが、メモリも無限ではないため、いつかはデータが多すぎて溢れてしまう。
これがスタックオーバーフロー。
これを回避するためには、ループでプログラムを記述するか、末尾再帰最適化を利用する。(実際は末尾再帰で書いたコードがループで書かれたものにコンパイルされるっぽい)
返り値が再帰関数の呼び出しのみになっている場合に、それを末尾再帰(Tail Recursion, Tail Call)と呼ぶ。
上記の階乗を求めるコードを末尾再帰として書きなおすと下のようになる。
factorialTailCall(4)のとき、
factorialTailCall(3,4*1)
factorialTailCall(2,3*4)
factorialTailCall(1,2*12)
という順番で関数が呼び出されていき、最終的に24が帰ってくる。
積を保存しておくaccumulatorを引数に追加することで、再帰関数の呼び出しのみを返り値にできている。
末尾再帰の条件を満たしているとき、コンパイラなんかが最適化してくれることを末尾再帰最適化と言う。
ただし、末尾再帰最適化に対応しているものとしていないものがある。
ES6の仕様には末尾再帰最適化があるみたいだが、それに対応しているブラウザがSafariくらいしかない模様(ES6対応表)
Node.jsでは一時期
babelについても、一時期対応していた時期もあったみたいだが、現在は非対応になっているようだ。
実際にNode.jsで前述のfactorialTail関数とfactorialTailCall関数を動かしたら、末尾再帰になっているはずの後者の方が早くスタックオーバーフローを起こしてしまった
悲しい
末尾再帰による最適化
http://www.fos.kuis.kyoto-u.ac.jp/~igarashi/class/pl/09-rec-iter.html
概要
自分の中での整理用に書きました。詳しくは参考記事を参照してください。
末尾再帰最適化について、再帰関数やスタックオーバーフローにも触れつつ書いていきます。
キーワード
- 再帰関数
- スタックオーバーフロー
- 末尾再帰
再帰関数とは
関数の中でその関数自身を呼び出す関数のこと例)階乗n!を求める関数
6!=6*5*4*3*2*1=720
みたいに、その数字以降全ての積を表すのが階乗
これを関数にすると、
factorial.js
function factorial(n){ if (n===1) return 1; else return n*factorial(n-1) }
スタックオーバーフロー
上記の階乗の計算を求める際、例えばn=10のとき、factorial(10)=10*factorial(9)なので、factorial(9)の計算結果が必要となるためfactorial(9)を計算しにいく。
このとき、10*factorial(9)の10をどこかに記憶しておかないといけない。
同様にfactorial(9)では9*factorial(8)となるので9を記憶し、これがfactorial(1)=1と分かるまで繰り返される。
この記憶の積み重ねがメモリに溜まっていく。(このときに使われるデータ構造がスタック)
nが大きくなればなるほどメモリが必要になるが、メモリも無限ではないため、いつかはデータが多すぎて溢れてしまう。
これがスタックオーバーフロー。
これを回避するためには、ループでプログラムを記述するか、末尾再帰最適化を利用する。(実際は末尾再帰で書いたコードがループで書かれたものにコンパイルされるっぽい)
末尾再帰
返り値が再帰関数の呼び出しのみになっている場合に、それを末尾再帰(Tail Recursion, Tail Call)と呼ぶ。上記の階乗を求めるコードを末尾再帰として書きなおすと下のようになる。
factorialTailCall.js
function factorialTailCall(n, accumulator=1){ if (n===1) return accumulator; else return factorialTailCall(n-1, n*accumulator) }
factorialTailCall(3,4*1)
factorialTailCall(2,3*4)
factorialTailCall(1,2*12)
という順番で関数が呼び出されていき、最終的に24が帰ってくる。
積を保存しておくaccumulatorを引数に追加することで、再帰関数の呼び出しのみを返り値にできている。
末尾再帰の条件を満たしているとき、コンパイラなんかが最適化してくれることを末尾再帰最適化と言う。
ただし、末尾再帰最適化に対応しているものとしていないものがある。
言語ごとの対応・非対応
言語 | 対応しているか | 補足 |
---|---|---|
Java | × | |
Scala | ○ | @tailrec |
Kotlin | ○ | tailrec修飾子 |
JavaScript | △ | ※後述 |
JavaScriptの末尾再帰最適化
ES6の仕様には末尾再帰最適化があるみたいだが、それに対応しているブラウザがSafariくらいしかない模様(ES6対応表)Node.jsでは一時期
--harmony
オプションをつけることで末尾再帰最適化ができていたみたいだが、現在のバージョンでは非対応になってしまっている。(Node.js ES6対応表)babelについても、一時期対応していた時期もあったみたいだが、現在は非対応になっているようだ。
実際にNode.jsで前述のfactorialTail関数とfactorialTailCall関数を動かしたら、末尾再帰になっているはずの後者の方が早くスタックオーバーフローを起こしてしまった
悲しい
参考
末尾再帰による最適化http://www.fos.kuis.kyoto-u.ac.jp/~igarashi/class/pl/09-rec-iter.html
コメント
コメントを投稿