メーリングリストやオンラインディスカッションで定期的に出てきそうなトピックの1つは、コンピュータサイエンスの学位を取得することのメリット(またはその欠如)です。ネガティブパーティーに対して何度も繰り返し登場するように思われる議論は、彼らが何年もコーディングしていて、再帰を使用したことがないということです。
だから問題は:
- 再帰とは何ですか?
- 再帰はいつ使用しますか?
- なぜ人々は再帰を使用しないのですか?
メーリングリストやオンラインディスカッションで定期的に出てきそうなトピックの1つは、コンピュータサイエンスの学位を取得することのメリット(またはその欠如)です。ネガティブパーティーに対して何度も繰り返し登場するように思われる議論は、彼らが何年もコーディングしていて、再帰を使用したことがないということです。
だから問題は:
回答:
このスレッドには、再帰について多くの優れた説明があります。この答えは、ほとんどの言語で再帰を使用してはいけない理由についてです。 、Ruby、Java、およびC#)の反復は、再帰よりもはるかに望ましい方法です。
理由を確認するには、上記の言語が関数の呼び出しに使用する手順を確認してください。
これらの手順をすべて実行するには時間がかかります。通常、ループを繰り返すのにかかる時間よりも少し長くなります。ただし、実際の問題はステップ1にあります。多くのプログラムが起動すると、スタックに1つのメモリチャンクが割り当てられ、そのメモリが不足すると(多くの場合、再帰が原因ではない場合もある)、スタックオーバーフローが原因でプログラムがクラッシュします。
そのため、これらの言語では再帰が遅くなり、クラッシュに対して脆弱になります。それを使用するためのいくつかの議論はまだあります。一般に、再帰的に記述されたコードは、読み方がわかれば、短くてエレガントになります。
言語の実装者が、スタックオーバーフローのいくつかのクラスを排除できる、呼び出された末尾呼び出しの最適化を使用できる手法があります。簡潔に言うと、関数の戻り式が単に関数呼び出しの結果である場合、スタックに新しいレベルを追加する必要はなく、呼び出されている関数に現在のレベルを再利用できます。残念ながら、いくつかの必須の言語実装には、末尾呼び出しの最適化が組み込まれています。
* 再帰が大好きです。 私のお気に入りの静的言語はループをまったく使用していません。再帰が何かを繰り返し行う唯一の方法です。再帰が調整されていない言語では、一般的に再帰は良い考えだとは思いません。
**ちなみに、マリオ、あなたのArrangeString関数の典型的な名前は "join"です。そして、あなたの選択した言語がまだそれを実装していないなら、私は驚くでしょう。
再帰の簡単な英語の例。
A child couldn't sleep, so her mother told her a story about a little frog,
who couldn't sleep, so the frog's mother told her a story about a little bear,
who couldn't sleep, so the bear's mother told her a story about a little weasel...
who fell asleep.
...and the little bear fell asleep;
...and the little frog fell asleep;
...and the child fell asleep.
コンピュータサイエンスの最も基本的な意味では、再帰はそれ自体を呼び出す関数です。リンクされたリスト構造があるとします。
struct Node {
Node* next;
};
そして、あなたはリンクリストが再帰でこれを行うことができる長さを知りたいです:
int length(const Node* list) {
if (!list->next) {
return 1;
} else {
return 1 + length(list->next);
}
}
(これはもちろんforループでも実行できますが、概念の説明として役立ちます)
length(list->next)
まだ戻る必要がありますlength(list)
。ここまでの長さを渡すように記述されていた場合にのみ、呼び出し元が存在することを忘れることができました。のようにint length(const Node* list, int count=0) { return (!list) ? count : length(list->next, count + 1); }
。
関数がそれ自体を呼び出してループを作成するときはいつでも、それが再帰です。何でもそうですが、再帰には良い使い方と悪い使い方があります。
最も単純な例は、関数の最後の行がそれ自体の呼び出しである末尾再帰です。
int FloorByTen(int num)
{
if (num % 10 == 0)
return num;
else
return FloorByTen(num-1);
}
ただし、これは不十分でほとんど意味のない例です。これは、より効率的な反復で簡単に置き換えることができるためです。結局のところ、再帰は関数呼び出しのオーバーヘッドの影響を受けます。これは、上記の例では、関数自体の内部の操作と比較してかなりの量になる可能性があります。
したがって、反復ではなく再帰を行う理由はすべて、呼び出しスタックを利用して賢いことを行うためです。たとえば、同じループ内で異なるパラメーターを使用して関数を複数回呼び出す場合、それは分岐を実行する方法です。典型的な例は、シェルピンスキーの三角形です。
再帰を使用すると、これらの1つを非常に簡単に描画できます。この場合、呼び出しスタックは3方向に分岐します。
private void BuildVertices(double x, double y, double len)
{
if (len > 0.002)
{
mesh.Positions.Add(new Point3D(x, y + len, -len));
mesh.Positions.Add(new Point3D(x - len, y - len, -len));
mesh.Positions.Add(new Point3D(x + len, y - len, -len));
len *= 0.5;
BuildVertices(x, y + len, len);
BuildVertices(x - len, y - len, len);
BuildVertices(x + len, y - len, len);
}
}
イテレーションで同じことを行おうとすると、達成するのにより多くのコードが必要になると思います。
その他の一般的な使用例には、階層のトラバース、たとえばWebサイトクローラー、ディレクトリ比較などがあります。
結論
実際には、反復分岐が必要な場合はいつでも、再帰が最も理にかなっています。
再帰は、分割統治の考え方に基づいて問題を解決する方法です。基本的な考え方は、元の問題を取り、それをそれ自体の小さな(より簡単に解決できる)インスタンスに分割し、それらの小さなインスタンスを(通常は同じアルゴリズムを再度使用して)解決してから、それらを最終的なソリューションに再構成することです。
正規の例は、nの階乗を生成するルーチンです。nの階乗は、1からnまでのすべての数値を乗算して計算されます。C#の反復ソリューションは次のようになります。
public int Fact(int n)
{
int fact = 1;
for( int i = 2; i <= n; i++)
{
fact = fact * i;
}
return fact;
}
反復的なソリューションには驚くべきことは何もありません。C#に精通している人なら誰でも理解できるはずです。
再帰的解は、n番目の階乗がn * Fact(n-1)であることを認識することで見つかります。別の言い方をすると、特定の階乗数がわかっている場合は、次の階乗数を計算できます。C#での再帰的なソリューションは次のとおりです。
public int FactRec(int n)
{
if( n < 2 )
{
return 1;
}
return n * FactRec( n - 1 );
}
この関数の最初の部分はベースケース(またはガード句)と呼ばれ、アルゴリズムが永久に実行されるのを防ぎます。関数が1以下の値で呼び出されると、値1を返すだけです。2番目の部分はより興味深く、「再帰的ステップ」として知られています。ここで、わずかに変更されたパラメーターを使用して同じメソッドを呼び出し(1ずつデクリメント)、結果をnのコピーで乗算します。
最初に遭遇したときこれは一種の混乱を招く可能性があるので、実行時にそれがどのように機能するかを調べることは有益です。FactRec(5)を呼び出すと想像してください。ルーチンに入ると、ベースケースに拾われないため、次のようになります。
// In FactRec(5)
return 5 * FactRec( 5 - 1 );
// which is
return 5 * FactRec(4);
パラメーター4を指定してメソッドに再び入ると、再びガード節によって停止されないため、次のようになります。
// In FactRec(4)
return 4 * FactRec(3);
この戻り値を上記の戻り値に代入すると、
// In FactRec(5)
return 5 * (4 * FactRec(3));
これにより、最終的な解決策がどのように到達するかについての手掛かりが得られるので、途中の各ステップをすばやく追跡して表示します。
return 5 * (4 * FactRec(3));
return 5 * (4 * (3 * FactRec(2)));
return 5 * (4 * (3 * (2 * FactRec(1))));
return 5 * (4 * (3 * (2 * (1))));
この最終的な置換は、ベースケースがトリガーされたときに行われます。この時点で、最初に階乗の定義に直接等しい、解決する単純な代数公式があります。
メソッドへのすべての呼び出しは、ベースケースがトリガーされるか、パラメータがベースケースに近い同じメソッドへの呼び出し(再帰呼び出しと呼ばれることが多い)になることに注意してください。そうでない場合、メソッドは永久に実行されます。
FactRec()
乗算する必要がありますn
。
再帰とは、問題のより小さなバージョンを解決し、その結果に加えて他の計算を使用して元の問題の答えを定式化することによって問題を解決する方法を指します。多くの場合、小さいバージョンを解決するプロセスでは、メソッドは問題のさらに小さいバージョンを解決し、解決するのは簡単な「基本ケース」に達するまで続きます。
たとえば、数の階乗を計算するには、次のようX
に表すことができX times the factorial of X-1
ます。したがって、メソッドはの階乗を見つけるために「再帰」し、最終的な答えを与えるためX-1
に得られたものX
を乗算します。もちろん、の階乗を見つけるにはX-1
、最初にの階乗を計算しますX-2
。ときに基本ケースのようになりX
、それが戻るために知っている場合には、0又は1である1
ので0! = 1! = 1
。
古いよく知られた問題を考えてみましょう:
数学では、2つ以上のゼロ以外の整数の最大公約数(gcd)…は、剰余なしで数値を除算する最大の正の整数です。
gcdの定義は驚くほど簡単です:
ここでmodはモジュロ演算子(つまり、整数除算後の剰余)です。
英語では、この定義は、任意の数値とゼロの最大公約数がその数であることを示し、2つの数値mとnの最大公約数はnの最大公約数であり、mをnで除算した後の余りです。
これが機能する理由を知りたい場合は、ユークリッドアルゴリズムに関するウィキペディアの記事をご覧ください。
例としてgcd(10、8)を計算してみましょう。各ステップはその直前のステップと同じです。
最初のステップでは、8はゼロに等しくないため、定義の2番目の部分が適用されます。10 mod 8 = 2なので、8は一度に10になり、残りは2になります。ステップ3で2番目の部分が再度適用されますが、今回は8 mod 2 = 0なので、2は剰余なしで8を割ります。ステップ5では、2番目の引数は0なので、答えは2です。
等号の左側と右側の両方にgcdが表示されていることに気づきましたか?数学者は、式は、あなたが定義しているので、この定義は再帰的であると言うでしょう再発するが、その定義内。
再帰的な定義はエレガントになる傾向があります。たとえば、リストの合計の再帰的な定義は次のとおりです。
sum l =
if empty(l)
return 0
else
return head(l) + sum(tail(l))
どこhead
リストの最初の要素があるとtail
、リストの残りの部分です。sum
最後にその定義内で繰り返されることに注意してください。
多分あなたは代わりにリストの最大値を好むでしょう:
max l =
if empty(l)
error
elsif length(l) = 1
return head(l)
else
tailmax = max(tail(l))
if head(l) > tailmax
return head(l)
else
return tailmax
非負整数の乗算を再帰的に定義して、一連の加算に変えることができます。
a * b =
if b = 0
return 0
else
return a + (a * (b - 1))
乗算を一連の加算に変換することについて意味がない場合は、いくつかの簡単な例を拡張して、それがどのように機能するかを確認してください。
マージソートには素敵な再帰的な定義があります:
sort(l) =
if empty(l) or length(l) = 1
return l
else
(left,right) = split l
return merge(sort(left), sort(right))
何を探すべきかわかっていれば、再帰的な定義はあちこちにあります。これらの定義のすべては非常にシンプルなベースケースを持っているかに注意してください、例えば、GCD(M、0)=メートル。再帰的なケースは問題を回避し、簡単な答えを導き出します。
この理解があれば、ウィキペディアの再帰に関する記事の他のアルゴリズムに感謝することができます。
正規の例は次のような階乗です。
int fact(int a)
{
if(a==1)
return 1;
return a*fact(a-1);
}
一般に、再帰は必ずしも高速ではなく(再帰関数は小さい傾向があるため、関数呼び出しのオーバーヘッドが高くなる傾向があります。上記を参照)、いくつかの問題(スタックオーバーフローは誰か?)に悩まされる可能性があります。自明ではないケースでは「正しく」理解するのが難しい傾向にあると言う人もいますが、私は実際にはそうはいきません。状況によっては、再帰が最も理にかなっており、特定の関数を記述する最もエレガントで明確な方法です。一部の言語は再帰的ソリューションを支持し、それらをはるかに最適化していることに注意してください(LISPが思い浮かびます)。
再帰関数とは、それ自体を呼び出す関数です。私がそれを使用することがわかった最も一般的な理由は、ツリー構造をトラバースすることです。たとえば、チェックボックス付きのTreeViewがある場合(新しいプログラムのインストール、「インストールする機能の選択」ページを考えてください)、次のような「すべてチェック」ボタン(疑似コード)が必要になる場合があります。
function cmdCheckAllClick {
checkRecursively(TreeView1.RootNode);
}
function checkRecursively(Node n) {
n.Checked = True;
foreach ( n.Children as child ) {
checkRecursively(child);
}
}
つまり、checkRecursivelyは最初に渡されたノードをチェックし、次にそのノードの子ごとに自分自身を呼び出すことがわかります。
再帰については少し注意する必要があります。無限再帰ループに入ると、スタックオーバーフロー例外が発生します:)
必要に応じて使うべきではない理由は考えられません。状況によっては役立ちますが、状況によっては役立ちません。
面白いテクニックなので、一部のプログラマーは、正当な理由なしに、必要以上に頻繁にそれを使用することになると思います。これにより、一部のサークルでは再帰に悪い名前が付けられました。
再帰は、それ自体を直接または間接的に参照する式です。
単純な例として再帰的な頭字語を考えてみましょう:
再帰は、私が「フラクタル問題」と呼んでいるものと最もうまく機能します。この場合、大きなものの小さなバージョンで構成された大きなものを処理します。それぞれのバージョンは、大きなもののさらに小さなバージョンです。ツリーやネストされた同一の構造などをトラバースまたは検索する必要がある場合は、再帰の候補になりそうな問題が発生しています。
人々は多くの理由で再帰を避けます:
ほとんどの人(私も含む)は、関数型プログラミングとは対照的に、手続き型プログラミングまたはオブジェクト指向プログラミングでプログラミングの歯を磨きました。このような人々にとって、反復的なアプローチ(通常はループを使用)はより自然に感じられます。
手続き型プログラミングまたはオブジェクト指向プログラミングでプログラミングの歯を削った人たちは、エラーが発生しやすいため、再帰を回避するように言われることがよくあります。
再帰は遅いとよく言われます。ルーチンの呼び出しとルーチンからの戻りには、多くのスタックのプッシュとポップが含まれ、ループよりも低速です。一部の言語はこれを他の言語よりもうまく処理すると思います。これらの言語は、支配的なパラダイムが手続き型またはオブジェクト指向である言語ではない可能性が高いです。
私が使用した少なくともいくつかのプログラミング言語については、スタックがそれほど深くないために、特定の深さを超えた場合に再帰を使用しないようにという推奨を聞いたことを覚えています。
再帰的ステートメントとは、次の処理のプロセスを入力とすでに行った処理の組み合わせとして定義するステートメントです。
たとえば、階乗を取る:
factorial(6) = 6*5*4*3*2*1
しかし、factorial(6)も簡単に確認できます。
6 * factorial(5) = 6*(5*4*3*2*1).
だから一般的に:
factorial(n) = n*factorial(n-1)
もちろん、再帰に関してトリッキーなことは、すでに行ったことに関して物事を定義したい場合、開始する場所が必要になるということです。
この例では、factorial(1)= 1を定義して、特別なケースを作成します。
今、私たちはそれを下から上に見ていきます:
factorial(6) = 6*factorial(5)
= 6*5*factorial(4)
= 6*5*4*factorial(3) = 6*5*4*3*factorial(2) = 6*5*4*3*2*factorial(1) = 6*5*4*3*2*1
factorial(1)= 1を定義したので、「ボトム」に到達します。
一般的に言えば、再帰的な手順には2つの部分があります。
1)再帰部分。同じ手順を介して「すでに実行」したことと組み合わせた新しい入力の観点からいくつかの手順を定義します。(つまりfactorial(n) = n*factorial(n-1)
)
2)ベースパーツ。開始する場所を指定することにより、プロセスが永久に繰り返されないようにします(つまりfactorial(1) = 1
)
最初に頭を動かすのは少し混乱するかもしれませんが、たくさんの例を見るだけですべてがまとまるはずです。概念をより深く理解したい場合は、数学的帰納法を研究してください。また、一部の言語は再帰呼び出しに最適化されていますが、他の言語は最適化されていないことに注意してください。注意しないと、めちゃくちゃ遅い再帰関数を作成するのは簡単ですが、ほとんどの場合、パフォーマンスを向上させる手法もあります。
お役に立てれば...
私はこの定義が好きです
。再帰では、ルーチンは問題自体の小さな部分を解決し、問題を小さな部分に分割し、それ自体を呼び出して小さな部分をそれぞれ解決します。
また、再帰に関するコンピュータサイエンスの本で使用されている例を批判する、Code Completeでの再帰に関するSteve McConnellsの議論も気に入っています。
階乗やフィボナッチ数に再帰を使用しないでください
コンピュータサイエンスの教科書に関する問題の1つは、再帰の愚かな例を示していることです。典型的な例は、階乗の計算またはフィボナッチ数列の計算です。再帰は強力なツールであり、どちらの場合にもそれを使用するのは本当に馬鹿げています。私のために働いたプログラマーが階乗を計算するために再帰を使用した場合、私は他の誰かを雇うでしょう。
これは非常に興味深い指摘であり、再帰がよく誤解されている理由かもしれません。
編集:これはダブの答えを掘り下げたものではありませんでした-私がこれを投稿したとき、私はその返信を見ていませんでした
1.)メソッドはそれ自体を呼び出すことができる場合、再帰的です。直接:
void f() {
... f() ...
}
または間接的に:
void f() {
... g() ...
}
void g() {
... f() ...
}
2.)再帰を使用する場合
Q: Does using recursion usually make your code faster?
A: No.
Q: Does using recursion usually use less memory?
A: No.
Q: Then why use recursion?
A: It sometimes makes your code much simpler!
3.)反復コードの記述が非常に複雑な場合にのみ、再帰を使用します。たとえば、プレオーダー、ポストオーダーなどのツリートラバーサル手法は、反復的および再帰的の両方にすることができます。しかし、通常は単純であるため、再帰を使用します。
これは簡単な例です:セット内の要素数。(物事を数えるより良い方法がありますが、これは素晴らしい単純な再帰的な例です。)
まず、2つのルールが必要です。
次のようなセットがあるとします:[xxx]。アイテムの数を数えてみましょう。
これは次のように表すことができます。
count of [x x x] = 1 + count of [x x]
= 1 + (1 + count of [x])
= 1 + (1 + (1 + count of []))
= 1 + (1 + (1 + 0)))
= 1 + (1 + (1))
= 1 + (2)
= 3
再帰的ソリューションを適用する場合、通常、少なくとも2つのルールがあります。
上記を疑似コードに変換すると、次のようになります。
numberOfItems(set)
if set is empty
return 0
else
remove 1 item from set
return 1 + numberOfItems(set)
他にも多くの人がカバーすると確信しているもっと便利な例(たとえば、ツリーをトラバースする)があります。
わかりやすい英語で:次の3つのことができると仮定します。
テーブルの前にたくさんのリンゴがあり、リンゴの数を知りたいとします。
start
Is the table empty?
yes: Count the tally marks and cheer like it's your birthday!
no: Take 1 apple and put it aside
Write down a tally mark
goto start
完了するまで同じことを繰り返すプロセスは、再帰と呼ばれます。
これがあなたが探している「分かりやすい英語」の答えであることを願っています!
再帰関数は、それ自体への呼び出しを含む関数です。再帰的な構造体は、それ自体のインスタンスを含む構造体です。この2つを再帰クラスとして組み合わせることができます。再帰的なアイテムの重要な部分は、それ自体のインスタンス/呼び出しが含まれていることです。
2つのミラーが向かい合っていることを考慮してください。彼らが生み出すきちんとした無限の効果を見てきました。各反射はミラーのインスタンスであり、ミラーの別のインスタンス内に含まれています。それ自体の反射を含むミラーは再帰です。
バイナリ検索ツリーは、再帰の良いプログラミング例です。構造は再帰的であり、各ノードにはノードの2つのインスタンスが含まれます。二分探索木を操作する関数も再帰的です。
これは古い質問ですが、ロジスティクスの観点から(つまり、アルゴリズムの正確性の観点やパフォーマンスの観点からではなく)回答を追加したいと思います。
仕事にはJavaを使用していますが、Javaはネストされた関数をサポートしていません。そのため、再帰を実行する場合、外部関数を定義する必要があります(これは、コードがJavaの官僚的規則にぶつかるためだけに存在します)、またはコードを完全にリファクタリングする必要がある場合があります(これは私が本当に嫌いです)。
したがって、再帰自体は本質的にスタック操作であるため、私はしばしば再帰を避け、代わりにスタック操作を使用します。
「私がハンマーを持っているなら、すべてが釘のように見えるようにしてください。」
再帰は、大きな問題の問題解決戦略であり、すべてのステップで、同じハンマーで毎回「2つの小さなものを1つの大きなものに変える」というものです。
あなたの机が1024の紙のまとまりのない混乱で覆われているとしましょう。再帰を使用して、混乱からきれいな紙のスタックをどのように作成しますか?
すべてを数えることを除いて、これはかなり直感的であることに注意してください(厳密に必要なわけではありません)。実際には、1シートのスタックまでは下がらないかもしれませんが、実際にはそうすることができます。重要な部分はハンマーです。腕を使って、常に1つのスタックを他のスタックの上に置いて、より大きなスタックを作成できます。どちらのスタックの大きさも(理由の範囲内で)問題ではありません。
ちょっと申し訳ありませんが、私の意見が誰かに同意する場合、私は単純な英語で再帰を説明しようとしています。
ジャック、ジョン、モーガンの3人のマネージャーがいるとします。ジャックは2人のプログラマー、ジョン-3、およびモーガン-5を管理します。あなたはすべてのマネージャーに300ドルを与え、それがどれくらいの費用がかかるかを知りたいと思っています。答えは明白ですが、モーガンの従業員のうち2人がマネージャーでもある場合はどうなりますか?
ここに再帰があります。階層の最上位から始めます。夏の費用は0ドルです。あなたはジャックから始め、次に彼に従業員としてマネージャーがいるかどうかを確認します。それらが見つかった場合は、マネージャとして従業員がいるかどうかなどを確認します。あなたがマネージャーを見つけるたびに、夏のコストに300ドルを追加します。ジャックの作業が終了したら、ジョンとその従業員に行き、次にモーガンに行きます。
答えを得るまでにどれだけのサイクルが必要かはわかりませんが、マネージャーの数と予算の数はわかっています。
再帰は、枝と葉を持つツリーであり、それぞれ親と子と呼ばれます。再帰アルゴリズムを使用すると、多かれ少なかれ意識的にデータからツリーを構築しています。
単純な英語では、再帰とは何度か何度か繰り返すことを意味します。
プログラミングの1つの例は、それ自体の中で関数を呼び出すことです。
数値の階乗を計算する次の例を見てください。
public int fact(int n)
{
if (n==0) return 1;
else return n*fact(n-1)
}
基本的にデータ型の各ケースのケースを持つスイッチステートメントで構成される場合、アルゴリズムはデータ型の構造的再帰を示します。
たとえば、型で作業しているとき
tree = null
| leaf(value:integer)
| node(left: tree, right:tree)
構造的再帰アルゴリズムは次の形式になります
function computeSomething(x : tree) =
if x is null: base case
if x is leaf: do something with x.value
if x is node: do something with x.left,
do something with x.right,
combine the results
これは、データ構造で機能するアルゴリズムを記述する最も明白な方法です。
ここで、ペアノ公理を使用して定義された整数(まあ、自然数)を見ると
integer = 0 | succ(integer)
整数の構造的再帰アルゴリズムは次のようになります
function computeSomething(x : integer) =
if x is 0 : base case
if x is succ(prev) : do something with prev
あまりに有名な階乗関数は、この形式の最も平凡な例です。
関数呼び出し自体、または独自の定義を使用します。