コンピュータサイエンスにおいて、再帰とは、計算問題を解決する方法の一つで、その解決方法は、同じ問題のより小さなインスタンスの解決に依存します。[ 1 ] [ 2 ]再帰は、自身のコード内から自身を呼び出す関数を用いることで、このような再帰的な問題を解決します。このアプローチは多くの種類の問題に適用でき、再帰はコンピュータサイエンスの中心的な概念の一つです。[ 3 ]
再帰の力は、明らかに有限の文によって無限のオブジェクト集合を定義できる可能性にある。同様に、たとえ明示的な繰り返しが含まれていなくても、有限の再帰プログラムによって無限の計算を記述することができる。
—ニクラウス・ヴィルト、『アルゴリズム+データ構造=プログラム』、1976年[ 4 ]
ほとんどのコンピュータプログラミング言語は、関数が自身のコード内から自身を呼び出すことを許可することで再帰をサポートしています。一部の関数型プログラミング言語(例えば、Clojure)[ 5 ]は、組み込みのループ構造を定義せず、代わりに再帰のみに依存しています。計算可能性理論では、これらの再帰のみの言語はチューリング完全であることが証明されています。これは、これらの言語が、やなどの制御構造に基づく命令型言語と同じくらい強力である(同じ問題を解決するために使用できる)ことを意味します。whilefor
関数内から関数を繰り返し呼び出すと、呼び出しスタックのサイズが、関連するすべての呼び出しの入力サイズの合計に等しくなる可能性があります。したがって、反復処理で容易に解決できる問題の場合、再帰は一般的に効率が悪く、特定の問題では、末尾呼び出し最適化などのアルゴリズム的またはコンパイラ最適化手法を用いることで、単純な再帰実装よりも計算性能が向上する場合があります。
コンピュータ科学における再帰の発展は、数理論理学から生まれ、後にプログラミング言語設計の不可欠な部分となった。 [ 6 ]チャーチ、ゲーデル、クリーネ、チューリングによる再帰関数と計算可能性に関する初期の研究は、プログラミング言語で再帰を可能にする基礎を築いた。[ 6 ]再帰は数学者によって長い間使用されてきたが、プログラミングの実用的なツールになったのは1950年代後半から1960年代初頭になってからである。[ 7 ]ジョン・マッカーシーやALGOL 60設計委員会などの主要人物が、プログラミングへの再帰の導入に貢献した。[ 7 ]
ジョン・マッカーシーは、1960年にプログラミング言語LISPを作成することで最初の一歩を踏み出しました。[ 8 ]マッカーシーは、論文「記号式の再帰関数とその機械による計算、パートI」の中で、記号を段階的に処理することで動作するプログラミング言語において、再帰が中核となり得ることを示しました。[ 8 ] LISPでは、単純なルールを使用して関数内で再帰を使用でき、言語内でそれらを評価する方法もありました。[ 8 ]これは、再帰がプログラムを書くための実用的な方法であり、計算のプロセスを記述するものでもあることを示しました。[ 6 ]そのため、LISPは再帰を主要な機能として使用する最初のプログラミング言語の1つとなり、後に続く他の言語にも影響を与えました。[ 7 ]
その頃、ALGOL 60に再帰も追加されました。[ 9 ] 1960 年に発行されたアルゴリズム言語 ALGOL 60 に関する報告書は、標準言語を設計するための国際的な試みの結果でした。 [ 9 ]手続きが自身を呼び出すことができるようになったことは、この言語の新しい重要な機能の 1 つです。[ 7 ]それ以前は、プログラマーはループしか使用できなかったため、これは大きな変化でした。[ 7 ]再帰により、プログラマーはアルゴリズムをより自然で柔軟な方法で記述できるようになりました。[ 7 ]
再帰関数の定義は、通常、1 つ以上の基本ケースと 1 つ以上の再帰ケースの 2 つの部分に分けられます。[ 10 ]この構造は、数学的帰納法の論理を反映しています。数学的帰納法とは、基本ケースと帰納ステップを証明することで、与えられた定理がすべての有効な入力に対して成り立つことを保証する証明手法です。
基本ケースは、関数がそれ以上の再帰なしに結果を直接提供できる入力値を指定します。[ 11 ]これらは通常、最も単純または最小の入力(簡単に解けるもの)であり、計算を終了させることができます。基本ケースは無限後退を防ぐため不可欠です。言い換えれば、再帰を終了する停止条件を定義します。
例えば、整数 n の階乗を計算する場合を考えてみましょう。階乗とは、0 から n までのすべての整数の積です。この問題では、0! = 1 という定義が基本ケースとなります。この定義がないと、再帰が無限に続き、実際の実装では終了しない、あるいはスタックオーバーフローエラーが発生する可能性が高くなります。
正しいベースケースを設計することは、理論的にも実践的にも非常に重要です。問題によっては自然なベースケースが存在するもの(例えば、空のリストは一部の再帰的なリスト処理関数におけるベースケース)もあれば、停止条件を示すための追加パラメータを必要とするもの(例えば、再帰的なツリー走査における深さカウンタの使用)もあります。
再帰的なコンピュータプログラミングでは、基本ケースを省略したり、定義を誤ったりすると、意図しない無限再帰が発生する可能性があります。ある研究では、多くの学生が適切な基本ケースを特定するのに苦労していることが研究者によって示されました。[ 11 ]
再帰ケースは、問題を同じ形式のより小さなサブ問題に分解する方法を説明しています。[ 11 ]各再帰ステップは、入力をベースケースに近づけるように変換し、終了に向けての進行を保証します。削減ステップがベースケースに向かって進行しない場合、アルゴリズムは無限ループに陥る可能性があります。
階乗の例では、再帰的なケースは次のように定義されます。 ここでは、関数の呼び出しごとに入力が減少します。1. これにより、再帰が最終的に基本ケースに到達することが保証されます。。
再帰的なケースは、帰納法による証明における帰納的ステップに類似しています。つまり、関数がより小さなインスタンスで正しく機能することを前提とし、その前提を現在の入力に拡張します。したがって、再帰的な定義とアルゴリズムは、数学における帰納的議論と非常に類似しており、その正当性はしばしば同様の推論手法に依存します。
再帰には、基本ケースと再帰ケースの構造に従う実用的な応用例がいくつか存在する。これらは、コンピュータサイエンスにおける複雑な問題を解決するために広く用いられている。
多くのコンピュータプログラムは、任意の量のデータを処理または生成する必要があります。再帰は、プログラマーが正確なサイズを知らないデータを表現するための手法です。プログラマーは、自己参照定義によってこのデータを指定できます。自己参照定義には、帰納的定義と共帰納的定義の2種類があります。
帰納的に定義された再帰的データ定義とは、データのインスタンスを構築する方法を指定する定義のことです。例えば、連結リストは帰納的に定義できます(ここではHaskellの構文を使用)。
data ListOfStrings = EmptyList | Cons String ListOfStrings上記のコードは、文字列のリストが空であるか、または文字列と文字列のリストを含む構造体であるかを指定しています。定義内の自己参照により、任意の(有限個の)文字列からなるリストを作成できます。
帰納的定義のもう一つの例は、自然数(または正の整数)です。
自然数とは、1またはn+1のいずれかであり、nは自然数である。
同様に、再帰的な定義は、プログラミング言語における式や文の構造をモデル化するためによく用いられます。言語設計者は、文法をバッカス・ナウア記法などの構文で表現することがよくあります。以下は、乗算と加算を含む単純な算術式の言語における文法の一例です。
<式> ::= <数値> | ( <式> * <式> ) | ( <式> + <式> ) これは、式が数値、2つの式の積、または2つの式の和のいずれかであることを示しています。2行目と3行目の式を再帰的に参照することで、この文法は、(5 * ((3 * 6) + 8))1つの式の中に複数の積または和演算を含む、任意の複雑な算術式(例:)を許容します。
共帰納的データ定義とは、あるデータに対して実行可能な操作を指定する定義のことです。一般的に、自己参照的な共帰納的定義は、無限サイズのデータ構造に使用されます。
非公式に与えられた無限の文字列の流れの帰納的定義は、次のようになるかもしれない。
文字列ストリームとは、次のようなオブジェクトsのことである。 head(s)は文字列であり、 tail(s)は文字列のストリームです。
これは文字列リストの帰納的定義と非常によく似ています。違いは、この定義ではデータ構造の内容にアクセスする方法(つまり、アクセサ関数を介して)とhead、tailその内容が何であるかを指定するのに対し、帰納的定義では構造を作成する方法と、それが何から作成されるかを指定する点です。
共再帰は共帰納法と関連しており、(場合によっては)無限のオブジェクトの特定のインスタンスを計算するために使用できます。プログラミング手法としては、遅延評価プログラミング言語のコンテキストで最もよく使用され、プログラムの出力の望ましいサイズや精度が不明な場合は再帰よりも好ましい場合があります。このような場合、プログラムは無限に大きい(または無限に正確な)結果の定義と、その結果の有限部分を取得するメカニズムの両方を必要とします。最初の n個の素数を計算する問題は、共再帰プログラムで解決できる問題です(例:こちら)。
自己参照が1つだけ含まれる再帰は、単一の再帰、一方、複数の自己参照を含む再帰は、多重再帰。単一再帰の標準的な例としては、線形探索におけるリストの走査や階乗関数の計算などがあり、多重再帰の標準的な例としては、深さ優先探索におけるツリーの走査
単一再帰は多くの場合、複数再帰よりもはるかに効率的であり、一般的には反復計算で置き換えることができ、線形時間で実行され、一定のメモリ領域しか必要としません。一方、複数再帰は指数関数的な時間とメモリ領域を必要とする場合があり、より根本的に再帰的であるため、明示的なスタックなしでは反復処理で置き換えることはできません。
多重再帰は、場合によっては単一の再帰(そして必要に応じて反復)に変換できます。例えば、フィボナッチ数列を計算するには、各値に前の2つの値が必要となるため、単純には多重反復が必要になりますが、連続する2つの値をパラメータとして渡すことで、単一の再帰で計算できます。
再帰の最も基本的な例、そしてここで紹介する例のほとんどは、直接再帰とは、関数が自身を呼び出すことです。 間接再帰とは、関数が自身ではなく、関数が呼び出した別の関数(直接または間接的に)によって呼び出される場合に発生します。たとえば、 f がf を呼び出すは直接再帰ですが、 f がgを呼び出し、g が fを呼び出すは、f の間接再帰です。3つ以上の関数の連鎖も可能です。たとえば、関数 1 が関数 2 を呼び出し、関数 2 が関数 3 を呼び出し、関数 3 が再び関数 1 を呼び出す場合などです。
間接再帰は相互再帰とも呼ばれ、より対称的な用語ではありますが、これは単に強調の仕方の違いであって、概念が異なるわけではありません。つまり、fがgを呼び出し、次にgがfを呼び出し、fが再びgを呼び出す場合、 fだけの観点から見るとfは間接的に再帰しており、gだけの観点から見ても間接的に再帰していますが、fとgの両方の観点から見ると、fとgは互いに相互に再帰しています。同様に、互いに呼び出し合う3つ以上の関数の集合は、相互再帰関数の集合と呼ぶことができます。
再帰は通常、関数を明示的に名前で呼び出すことによって行われます。しかし、再帰は現在のコンテキストに基づいて関数を暗黙的に呼び出すことによっても行うことができ、これは特に匿名関数に役立ち、匿名再帰として知られています。
再帰を「構造的」または「生成的」に分類する著者もいる。この区別は、再帰的な手続きが処理対象のデータをどこから取得し、どのように処理するかに関係している。
構造化データを処理する関数は、通常、引数をその直接の構造的構成要素に分解し、それらの構成要素を処理します。直接の構成要素のいずれかが入力と同じクラスのデータに属する場合、その関数は再帰的です。そのため、これらの関数を(構造的に)再帰的な関数と呼びます。
— Felleisen、Findler、Flatt、Krishnaurthi、『How to Design Programs』、2001年[ 14 ]
したがって、構造的に再帰的な関数の決定的な特徴は、各再帰呼び出しの引数が元の入力のフィールドの内容であることです。構造的再帰には、XML処理、二分木の作成と検索など、ほぼすべてのツリー走査が含まれます。自然数の代数的構造(つまり、自然数はゼロか、自然数の次の数のいずれかである)を考慮すると、階乗などの関数も構造的再帰とみなすことができます。
生成再帰は代替手段である。
多くの有名な再帰アルゴリズムは、与えられたデータから全く新しいデータを生成し、それに対して再帰処理を行います。HtDP ( How to Design Programs)では、このようなアルゴリズムを生成再帰と呼んでいます。生成再帰の例としては、最大公約数、クイックソート、二分探索、マージソート、ニュートン法、フラクタル、適応積分などがあります。
—マティアス・フェライゼン、『Advanced Functional Programming』、2002年[ 15 ]
この区別は、関数の終了を証明する上で重要である。
実際の実装では、純粋な再帰関数(基本ケースを一度チェックし、それ以外は再帰ステップを実行する)ではなく、明確さや効率性を高めるために、いくつかの変更が加えられる場合があります。これには以下が含まれます。
優雅さの観点から、ラッパー関数は一般的に承認されている一方、基本ケースを短絡させる方法は、特に学術界では好ましくないとされている。ハイブリッドアルゴリズムは、効率性を高め、小規模なケースでの再帰のオーバーヘッドを削減するためによく用いられ、アームズレングス再帰はその一例である。
ラッパー関数とは、直接呼び出される関数ですが、自身は再帰処理を行わず、代わりに別の補助関数を呼び出し、その補助関数が実際に再帰処理を実行します。
ラッパー関数は、パラメータの検証(再帰関数がこれらのパラメータをスキップできるようにするため)、初期化(メモリの割り当て、変数の初期化)の実行(特に「再帰レベル」などの補助変数やメモ化のための部分計算)、例外やエラーの処理に使用できます。ネストされた関数をサポートする言語では、補助関数をラッパー関数内にネストして共有スコープを使用できます。ネストされた関数がない場合、補助関数は別の関数として扱われ、可能であればプライベート関数(直接呼び出されないため)となり、参照渡しによってラッパー関数と情報が共有されます。
基本ケースの短絡処理(アームズレングス再帰とも呼ばれる)は、再帰呼び出しを行う前に基本ケースをチェックすること、つまり、呼び出し後に基本ケースをチェックするのではなく、次の呼び出しが基本ケースになるかどうかを確認することです。短絡処理は、特に効率上の理由から行われ、すぐに戻る関数呼び出しのオーバーヘッドを回避します。基本ケースは既にチェックされているため(再帰ステップの直前)、別途チェックする必要はありませんが、全体の再帰が基本ケース自体から始まる場合は、ラッパー関数を使用する必要があります。たとえば、階乗関数では、基本ケースは 0! = 1 ですが、1! に対してすぐに 1 を返すのは短絡処理であり、0 を見逃す可能性があります。これはラッパー関数で軽減できます。ボックスは、階乗のケース 0 と 1 を短絡処理するCコードを示しています。
短絡評価は、ツリー内のヌルポインタなど、多くの基本ケースが発生する場合に主に重要となります。基本ケースは関数呼び出しの数に比例するため、O ( n )アルゴリズムでは大幅な節約になります。これは、深さ優先探索の場合に以下に示します。ツリーでの短絡評価は、空ノードを基本ケースとするのではなく、葉ノード(子ノードを持たない空でないノード)を基本ケースとみなすことに相当します。階乗の計算のように基本ケースが 1 つしかない場合は、短絡評価による節約はO (1)にしかなりません。
概念的には、短絡は、同じ基本ケースと再帰ステップを持ち、再帰の前に基本ケースのみをチェックするものと考えることも、異なる基本ケース(標準の基本ケースから1ステップ離れたもの)とより複雑な再帰ステップ、つまり「有効性をチェックしてから再帰する」ものと考えることもできます。これは、ツリーの基本ケースとしてNullノードではなくリーフノードを考慮する場合と同様です。短絡は、標準的な再帰における基本ケースと再帰ステップの明確な分離と比較して、より複雑な流れを持つため、特に学術界では、しばしば不適切なスタイルとみなされます。[ 16 ]
短絡処理の基本的な例として、二分木の深さ優先探索(DFS)が挙げられます。標準的な再帰処理については、二分木のセクションを参照してください。
深さ優先探索(DFS)の標準的な再帰アルゴリズムは次のとおりです。
短絡の場合、これは代わりに次のようになります。
標準的な手順で言えば、これは基本ケースのチェックを再帰ステップの前に移動させるものです。あるいは、これらはそれぞれ基本ケースと再帰ステップの異なる形式と考えることもできます。なお、ツリー自体が空の場合(ルートノードがNullの場合)を処理するには、ラッパー関数が必要になることに注意してください。
高さhの完全二分木の場合、2 h +1 − 1 個のノードと 2 h +1個のヌルポインタが子として存在します(2 h個の葉それぞれに 2 つずつ)。そのため、最悪の場合、短絡評価によって関数呼び出しの回数が半分になります。
C言語では、標準的な再帰アルゴリズムは次のように実装できます。
bool tree_contains ( struct BinaryTree * node , int i ) { if ( ! node ) { return false ; // 基本ケース} else if ( node -> data == i ) { return true ; } else { return tree_contains ( node -> left , i ) || tree_contains ( node -> right , i ); } }短絡アルゴリズムは次のように実装できます。
#include <assert.h>// 空のツリーを処理するためのラッパー関数bool tree_contains ( struct BinaryTree * node , int i ) { if ( ! node ) { return false ; // 空のツリー} else { return tree_contains_aux ( node , i ); // 補助関数を呼び出す} }// node != NULL を前提とするbool tree_contains_aux ( struct BinaryTree * node , int i ) { assert ( node ); if ( node -> data == i ) { return true ; // 見つかった} else { // 再帰return ( node -> left && tree_contains_aux ( node -> left , i )) || ( node -> right && tree_contains_aux ( node -> right , i )); } }ブール演算子& & (AND) の短絡評価を使用している点に注意してください。これにより、ノードが有効 (Null 以外) の場合にのみ再帰呼び出しが行われます。AND の最初の項はノードへのポインタですが、2 番目の項はブール値であるため、式全体はブール値に評価されることに注意してください。これは、再帰短絡における一般的な慣用表現です。これは、左の子ノードが失敗した場合にのみ右の子ノードをチェックするために、ブール演算子 || (OR) の短絡評価に加えて行われます。実際には、これらの関数の制御フロー全体を return 文内の単一のブール式に置き換えることができますが、効率性の向上はなく、可読性が損なわれます。
再帰アルゴリズムは、関数呼び出しと戻り値の繰り返しによるオーバーヘッドのため、データ量が少ない場合には非効率になることが多い。そのため、効率的な再帰アルゴリズムの実装では、まず再帰アルゴリズムから始め、入力データが小さくなった時点で別のアルゴリズムに切り替えることが多い。重要な例としてマージソートが挙げられる。マージソートは、タイルマージソートのように、データが十分に小さくなった時点で非再帰的な挿入ソートに切り替えることで実装されることが多い。ハイブリッド再帰アルゴリズムは、ハイブリッドマージソートと挿入ソートを組み合わせたTimsortのように、さらに改良できる場合が多い。
再帰と反復は表現力において同等です。再帰は明示的な呼び出しスタックを用いた反復に置き換えることができ、反復は末尾再帰に置き換えることができます。どちらのアプローチが好ましいかは、検討対象の問題と使用する言語によって異なります。命令型プログラミングでは、特に単純な再帰の場合、関数呼び出しと呼び出しスタック管理のオーバーヘッドを回避できるため、反復が好まれますが、多重再帰の場合は一般的に再帰が使用されます。対照的に、関数型言語では、末尾再帰の最適化によりオーバーヘッドがほとんどないため、再帰が好まれます。反復を使用してアルゴリズムを実装することは容易ではない場合があります。
x基底からx n = f(n, x n-1 ) で定義される x nを計算するテンプレートを比較します。
命令型言語の場合、オーバーヘッドは関数を定義することであり、関数型言語の場合、オーバーヘッドはアキュムレータ変数xを定義することである。
例えば、階乗関数は、再帰によって引数を渡して値を返すのではなく、ループインデックス変数とアキュムレータ変数に値を代入することで、C言語で反復的に実装できます。
unsigned int factorial ( unsigned int n ) { unsigned int product = 1 ; // 空の積は 1 ですwhile ( n > 0 ) { product *= n ; -- n ; } return product ; }今日使用されているほとんどのプログラミング言語では、再帰関数とプロシージャを直接指定できます。このような関数が呼び出されると、プログラムの実行環境は関数のさまざまなインスタンスを追跡します(多くの場合、コールスタックを使用しますが、他の方法を使用することもできます)。すべての再帰関数は、再帰呼び出しを反復制御構造に置き換え、コールスタックをプログラムによって明示的に管理されるスタックでシミュレートすることにより、反復関数に変換できます。 [ 17 ] [ 18 ]
逆に、コンピュータで評価できるすべての反復関数と手順 (チューリング完全性を参照) は再帰関数で表現できます。whileループやfor ループなどの反復制御構造は、関数型言語では日常的に再帰形式で書き直されます。[ 19 ] [ 20 ]ただし、実際にはこの書き直しは末尾呼び出し除去に依存しますが、これはすべての言語の機能ではありません。C 、Java、Python は、末尾呼び出しを含むすべての関数呼び出しがループ構造を使用した場合に発生しないスタック割り当てを引き起こす可能性がある、注目すべき主流言語です。これらの言語では、末尾呼び出し除去が言語の仕様でカバーされていない機能である場合や、同じ言語の異なる実装で末尾呼び出し除去機能が異なる場合があるにもかかわらず、再帰形式で書き直された動作する反復プログラムが呼び出しスタックをオーバーフローする可能性があります。
反復ループ構造を好む言語( CやJavaなど)では、スタックの管理に必要なオーバーヘッドや関数呼び出しの相対的な遅さのため、再帰プログラムには通常、かなりの時間と空間のコストがかかります。一方、関数型言語では、関数呼び出し(特に末尾呼び出し)は通常非常に高速な操作であり、その差はあまり目立ちません。
具体的な例として、上記の「階乗」の例の再帰的実装と反復的実装のパフォーマンスの違いは、使用するコンパイラに大きく依存します。ループ構造が好まれる言語では、反復的バージョンは再帰的バージョンよりも数桁も高速になる可能性があります。関数型言語では、2つの実装の全体的な時間差は無視できるほど小さいかもしれません。実際、(ここで示した反復的バージョンが行っているように)小さい数よりも大きい数を先に掛けるコストが、反復を選択することで節約できる時間を上回ってしまう可能性があります。
一部のプログラミング言語では、コールスタックの最大サイズがヒープで使用可能なスペースよりもはるかに小さく、再帰アルゴリズムは反復アルゴリズムよりも多くのスタックスペースを必要とする傾向があります。そのため、これらの言語ではスタックオーバーフローを避けるために再帰の深さに制限を設けることがあります。Pythonはそのような言語の 1 つです。[ 21 ]末尾再帰の特殊なケースについては、以下の注意書きに注意してください。
再帰アルゴリズムはスタックオーバーフローを起こす可能性があるため、病的な入力や悪意のある入力に対して脆弱である可能性があります。[ 22 ]一部のマルウェアは、プログラムのコールスタックを特に標的とし、スタックの本来の再帰的な性質を利用します。[ 23 ]マルウェアがない場合でも、無制限の再帰によって引き起こされるスタックオーバーフローはプログラムにとって致命的となる可能性があり、例外処理ロジックが対応するプロセスの終了を防げない場合があります。[ 24 ]
多重再帰問題は、追跡する必要のある以前の状態があるため、本質的に再帰的です。 1 つの例として、深さ優先探索のようなツリーの走査があります。再帰的方法と反復的方法の両方が使用されますが、[ 25 ]リストの走査やリスト内の線形探索とは対照的です。リストの走査や線形探索は単一の再帰であり、したがって自然に反復的な方法です。 他の例としては、クイックソートなどの分割統治アルゴリズムや、アッカーマン関数などの関数があります。 これらのアルゴリズムはすべて、明示的なスタックの助けを借りて反復的に実装できますが、スタックの管理に伴うプログラマの労力と、結果として得られるプログラムの複雑さは、反復ソリューションの利点を上回る可能性があります。
再帰アルゴリズムは、非再帰アルゴリズムに置き換えることができます。[ 26 ]再帰アルゴリズムを置き換える方法の 1 つは、スタック メモリの代わりにヒープ メモリを使用してシミュレートすることです。[ 27 ]代替案は、完全に非再帰的な方法に基づいて代替アルゴリズムを開発することですが、これは困難な場合があります。[ 28 ]例えば、Rich Salzのwildmatアルゴリズム[ 29 ]のようなワイルドカードのマッチングのための再帰アルゴリズムは、かつては一般的でした。同じ目的のための非再帰アルゴリズム、例えばKrauss のワイルドカード マッチング アルゴリズムは、再帰の欠点を回避するために開発され[ 30 ] 、テストの収集やパフォーマンスのプロファイリングなどの技術に基づいて徐々に改善されてきました。[ 31 ]
末尾再帰関数とは、すべての再帰呼び出しが末尾呼び出しであり、遅延演算が発生しない関数です。例えば、gcd 関数(下記参照)は末尾再帰関数です。一方、階乗関数(下記参照)は末尾再帰関数ではありません。階乗関数の再帰呼び出しが末尾にないため、最後の再帰呼び出しが完了した後に実行する必要のある遅延乗算演算が発生します。末尾再帰呼び出しを関数呼び出しではなくジャンプとして扱うコンパイラやインタプリタでは、gcd のような末尾再帰関数は定数空間で実行されます。したがって、このプログラムは本質的に反復的であり、命令型言語の「for」ループや「while」ループなどの制御構造を使用するのと同等です。
末尾再帰の重要な点は、末尾再帰呼び出し(または任意の末尾呼び出し)を行う際に、呼び出し元の戻り位置をコールスタックに保存する必要がないことです。再帰呼び出しが戻ると、以前に保存された戻り位置に直接分岐します。したがって、末尾呼び出しのこの特性を認識する言語では、末尾再帰によってメモリと時間の両方を節約できます。
次の2つの関数について考えてみましょう。
#include <stdio.h>void recursiveFunction ( int num ) { printf ( "%d \n " , num ); if ( num < 4 ) { recursiveFunction ( num + 1 ); } }![]()
#include <stdio.h>void recursiveFunction ( int num ) { if ( num < 4 ) { recursiveFunction ( num + 1 ); } printf ( "%d \n " , num ); }![]()
関数2の出力は、関数1の出力の行を入れ替えたものです。
関数が自身を一度だけ呼び出す場合、再帰呼び出しの前に配置された命令は、再帰呼び出しの後に配置された命令よりも先に、再帰ごとに一度実行されます。再帰呼び出しの後に配置された命令は、最大再帰回数に達した後に繰り返し実行されます。
また、 print文の順序が逆になっていることにも注意してください。これは、関数と文がコールスタックに格納される方法によるものです。
再帰的手続きの典型的な例は、自然数の階乗を計算するために使用される関数です。
この関数は漸化式としても表すことができます。
この漸化式の評価は、上記の擬似コードを評価する際に実行される計算を示しています。
この階乗関数は、命令型プログラミング言語によく見られるループ構造を利用することで、再帰を用いずに記述することもできます。
上記の命令型コードは、アキュムレータ変数tを使用した以下の数学的定義と同等です。
上記の定義は、 Schemeのような関数型プログラミング言語にそのまま適用できます。これは、再帰的に実装された反復処理の一例です。
2つの整数の最大公約数を計算するユークリッドの互除法は、再帰的に記述することができる。
関数定義:
上記の再帰プログラムは末尾再帰です。これは反復アルゴリズムと同等であり、上記の計算は末尾呼び出しを排除する言語で実行される評価手順を示しています。以下は、末尾呼び出しを排除しない言語に適した、明示的な反復を使用した同じアルゴリズムのバージョンです。このプログラムは、状態を変数xとyに完全に保持し、ループ構造を使用することで、再帰呼び出しと呼び出しスタックの増大を回避しています。
反復アルゴリズムは一時変数を必要とし、ユークリッドの互除法の知識があっても、手順は非常に似ているものの、単純な観察だけでそのプロセスを理解するのはより困難である。

ハノイの塔は、再帰を例示する数学パズルです。[ 32 ] [ 33 ]直径の異なる円盤を積み重ねることができる杭が3つあります。大きい円盤を小さい円盤の上に積み重ねることはできません。1つの杭にn個の円盤がある状態から始め、1つずつ別の杭に移動する必要があります。積み重ねた円盤を移動させる最小ステップ数はいくつですか?
関数定義:
ハノイの漸化式:
実装例:
すべての再帰関数に明示的な解があるわけではないが、ハノイの塔の数列は明示的な式に還元できる。[ 34 ]
二分探索アルゴリズムは、再帰的に配列を半分に分割しながら、ソート済みの配列から単一の要素を検索する方法です。そのコツは、配列の中央付近にある中間点を選択し、その点のデータを検索対象のデータと比較し、次の3つの条件のいずれかに応答することです。中間点にデータが見つかった場合、中間点のデータが検索対象のデータより大きい場合、または中間点のデータが検索対象のデータより小さい場合です。
このアルゴリズムでは再帰が用いられています。なぜなら、各パスで古い配列を半分に分割することで新しい配列が作成されるからです。その後、バイナリサーチの手順が再帰的に呼び出され、今度は新しい(より小さい)配列に対して実行されます。通常、配列のサイズは開始インデックスと終了インデックスを操作することで調整されます。このアルゴリズムは、各パスで問題領域を実質的に半分に分割するため、対数的な成長を示します。
C言語による二分探索の実装例:
/** * @brief 適切な初期条件を指定して binary_search を呼び出します。* @param data 昇順にソートされた整数の配列* @param target 検索対象の整数* @param count 配列内の要素の総数* @returns binary_search の結果*/ int search ( int data [], int target , int count ) { // Start = 0 (開始インデックス) // End = count - 1 (最上位インデックス) return binary_search ( data , target , 0 , count - 1 ); }/** * @brief 二分探索アルゴリズム。* @param data 昇順にソートされた整数の配列* @param target 検索する整数* @param start 配列の最小インデックス* @param end 配列の最大インデックス* @returns 配列データ内で検索する整数の位置。見つからない場合は -1 */ int binary_search ( int data [], int target , int start , int end ) { // 中間点を取得します。int mid = start + ( end - start ) / 2 ; // 整数除算if ( start > end ) { return -1 ; // 停止条件 (基本ケース) } else if ( data [ mid ] == target ) { return mid ; // 見つかったので、インデックスを返す} else if ( data [ mid ] > target ) { // データがターゲットより大きいので、下半分を検索return binary_search ( data , target , start , mid - 1 ); } else { // データがターゲットより小さいので、上半分を検索return binary_search ( data , target , mid + 1 , end ); } }コンピュータサイエンスにおける再帰の重要な応用例の一つは、リストやツリーといった動的なデータ構造を定義することである。再帰的なデータ構造は、実行時の要件に応じて理論的には無限のサイズまで動的に拡張できる。一方、静的な配列のサイズはコンパイル時に設定する必要がある。
「再帰アルゴリズムは、根本的な問題や処理対象のデータが再帰的な用語で定義されている場合に特に適しています。」[ 35 ]
このセクションの例は、「構造的再帰」と呼ばれるものを示しています。この用語は、再帰的な手続きが、再帰的に定義されたデータに対して作用するという事実を指します。
プログラマがデータ定義からテンプレートを導出する限り、関数は構造的再帰を使用します。つまり、関数の本体内の再帰は、与えられた複合値の直接的な一部を消費します。[ 15 ]
以下は、連結リストのノード構造のC言語による定義です。特に、ノードが自身を用いてどのように定義されているかに注目してください。「next」要素はLinkedList別の連結リストへのポインタであり、実質的にリスト型を作成します。
struct LinkedList { int data ; // 整数データstruct LinkedList * next ; // リンクリストの別のノードへのポインタ};struct nodeデータ構造は再帰的に定義されているため、この構造を操作するプロシージャは自然に再帰的なプロシージャとして実装できます。以下に定義するlist_printプロシージャは、リストが空になるまで (つまり、リストポインタの値が NULL になるまで) リストを順に処理します。各ノードについて、データ要素 (整数) を出力します。C 言語の実装では、list_printプロシージャによってリストは変更されません。
void list_print ( struct LinkedList * list ) { // 基本ケースif ( list ) { printf ( "%d " , list -> data ); // 整数データの後にスペースを出力list_print ( list -> next ); // 次のノードで再帰呼び出し} }以下は、二分木ノードの簡単な定義です。連結リストのノードと同様に、再帰的に自身に基づいて定義されます。自己参照ポインタは2つあり、left(左サブツリーを指す)とright(右サブツリーを指す)です。
struct BinaryTree { int data ; // 整数データstruct BinaryTree * left ; // 左サブツリーへのポインタstruct BinaryTree * right ; // 右サブツリーへのポインタ};ツリーに対する操作は再帰を用いて実装できます。ただし、自己参照ポインタが2つ(左と右)存在するため、ツリー操作には2回の再帰呼び出しが必要になる場合があることに注意してください。
// tree_node に i が含まれているかどうかをテストします。含まれている場合は 1 を、含まれていない場合は 0 を返します。int tree_contains ( struct BinaryTree * node , int i ) { if ( ! node ) { return 0 ; //基本ケース} else if ( node - > data == i ) { return 1 ; } else { return tree_contains ( node -> left , i ) || tree_contains ( node -> right , i ); } }上記で定義したtree_containsへの呼び出しごとに、最大で2回の再帰呼び出しが行われます。
// 中順走査: void tree_print ( struct BinaryTree * node ) { // 基本ケースif ( node ) { tree_print ( node -> left ); // 左へ進むprintf ( "%d " , node -> data ); // 整数とスペースを出力するtree_print ( node -> right ); // 右へ進む} }上記の例は、二分木の順序通りの走査を示しています。二分探索木は、各ノードのデータ要素が順序通りに格納されている二分木の特殊なケースです。
ファイルシステム内のファイル数は変動する可能性があるため、その内容を走査して列挙するには再帰が唯一実用的な方法です。ファイルシステムの走査はツリー走査と非常によく似ているため、ツリー走査の概念はファイルシステムの走査にも適用できます。より具体的には、以下のコードはファイルシステムの先行順走査の例となります。
パッケージorg.wikipedia.examples ;import java.io.File ;public class Example { /** * ファイルシステムのルートを取得します * 再帰的なファイルシステム走査を実行します */ private static void traverse () { File [] fs = File . listRoots (); for ( int i = 0 ; i < fs . length ; i ++ ) { System . out . println ( fs [ i ] ); if ( fs [ i ] . isDirectory () && fs [ i ] . canRead ()) { rtraverse ( fs [ i ] ); } } }/** * 指定されたディレクトリを再帰的に走査します * * @param fd 走査の開始点を示します */ private static void rtraverse ( File fd ) { File [] fss = fd . listFiles ();for ( int i = 0 ; i < fss . length ; i ++ ) { System . out . println ( fss [ i ] ); if ( fss [ i ] . isDirectory () && fss [ i ] . canRead ()) { rtraverse ( fss [ i ] ); } } }public static void main ( String [] args ) { traverse (); } }このコードは再帰と反復の両方を含んでいます。ファイルとディレクトリが反復処理され、各ディレクトリが再帰的に開かれます。
「rtraverse」メソッドは直接再帰の一例であり、「traverse」メソッドはラッパー関数である。
「基本ケース」のシナリオは、特定のファイルシステムには常に一定数のファイルやディレクトリが存在するというものです。
再帰アルゴリズムの時間効率は、ビッグオー記法による漸化式で表現できます。そして、通常は単一のビッグオー項に簡略化できます。
関数の時間計算量が次の形式である場合
すると、時間計算量のビッグオー記法は次のようになる。
ここで、a は各再帰レベルでの再帰呼び出しの数を表し、b は次の再帰レベルの入力がどれだけ小さくなるか(つまり、問題を分割する部分の数)を表し、f ( n )は各再帰レベルで関数が再帰とは独立して行う作業(分割、再結合など)を表します。
論理プログラムの手続き的解釈では、 の形式の節(または規則)は手続きとして扱われ、 の形式の目標を の形式のサブ目標に還元します。たとえば、Prolog の節は次のようになります。A:-BAB
path ( X , Y ) :- arc ( X , Y ). path ( X , Y ) :- arc ( X , Z ), path ( Z , Y ).XからYへの パスを検索するために使用できる手順を定義します 。この手順は、 XからYへの直接の弧を見つけるか、 XからZへの弧を見つけてから、 ZからYへのパスを再帰的に検索することによって実行できます。Prolog は、トップダウン (またはバックワード)で推論し、可能なパスの空間を深さ優先で一度に 1 つのブランチずつ検索することによって、この手順を実行します。2 番目の節を試して、ZからYへのパスを見つけることが有限回失敗した場合、バックトラックして、 Xから別のノードへの弧を見つけようとし、次にその別のノードからYへのパスを検索します。
しかし、論理プログラムの論理的解釈においては、節は全称量化条件文として宣言的に理解されます。例えば、経路探索手順の再帰節は、すべてのX、Y、Zについて、 XからZへの弧と Z から Y への経路が存在するならば、X からYへの経路が存在するという知識を表していると理解されます。記号形式では、次のようになります。
論理的な読み方によって、読者は節が問題を解決するためにどのように使用されるかを知る必要がなくなります。節は、Prolog のようにトップダウンで使用して問題をサブ問題に縮小することもできます。あるいは、Datalogのようにボトムアップ (またはフォワード) で使用して条件から結論を導き出すこともできます。この関心の分離は抽象化の一形態であり、宣言的知識と問題解決方法を分離します (アルゴリズム#アルゴリズム = ロジック + 制御を参照)。[ 36 ]
プログラマーによくある間違いは、再帰関数を終了する方法を提供しないことです。多くの場合、基本ケースのチェックを省略したり、誤ってチェックしたりすることで、再帰的に無限に自身を呼び出すことで、(少なくとも理論的には)無限に実行されてしまいます。これは無限再帰と呼ばれ、プログラムは決して終了しません。実際には、これは通常、使用可能なスタック領域を使い果たします。ほとんどのプログラミング環境では、無限再帰を持つプログラムは実際には永遠に実行されません。最終的には何かが壊れて、プログラムはエラーを報告します。[ 37 ]ただし、末尾呼び出し最適化を使用すると、再帰呼び出しが無限ループに最適化され、永遠に実行される場合があります。
以下は、無限再帰を使用するJavaコードです。
public class InfiniteRecursion {static void recursive () {// 出口のない再帰関数再帰的();}public static void main ( String [] args ) {recursive (); // 実行時に再帰関数を実行します}}このコードを実行すると、スタックオーバーフローエラーが発生します。