ループではなく再帰を使用する(比較的)基本的な(大学1年生のCSレベルの学生と考えてください)インスタンスはいつですか?
ループではなく再帰を使用する(比較的)基本的な(大学1年生のCSレベルの学生と考えてください)インスタンスはいつですか?
回答:
私は約2年間大学生にC ++を教え、再帰を扱ってきました。私の経験から、あなたの質問と感情は非常に一般的です。極端な場合、一部の学生は再帰を理解するのが難しいと見なしますが、他の学生はほとんどすべてに再帰を使用したいと考えています。
Daveはそれをうまくまとめていると思います。適切な場所で使用してください。つまり、自然に感じるときに使用します。うまく収まる問題に直面すると、おそらくそれを認識するでしょう。反復的な解決策を思い付くことすらできないように思えます。また、明快さはプログラミングの重要な側面です。他の人(そしてあなたも!)は、作成したコードを読んで理解できるはずです。反復ループは、再帰よりも一見理解しやすいと言っても安全だと思います。
プログラミングやコンピューターサイエンス全般をどれだけよく知っているかはわかりませんが、ここで仮想関数、継承、または高度な概念について話すことは意味がないと強く感じています。私はしばしばフィボナッチ数の計算の古典的な例から始めました。フィボナッチ数は再帰的に定義されるため、ここにうまく収まります。これは理解しやすいですし、必要としない任意の言語の派手な機能を。生徒が再帰の基本的な理解を得た後、以前に構築したいくつかの簡単な関数をもう一度見てみました。以下に例を示します。
文字列に文字が含まれていますか?
これは以前の方法です。文字列を反復処理し、インデックスにが含まれているかどうかを確認します。
bool find(const std::string& s, char x)
{
for(int i = 0; i < s.size(); ++i)
{
if(s[i] == x)
return true;
}
return false;
}
問題は、それを再帰的に実行できるかどうかです。確かに、ここに1つの方法があります。
bool find(const std::string& s, int idx, char x)
{
if(idx == s.size())
return false;
return s[idx] == x || find(s, ++idx);
}
次の自然な質問は、このようにする必要がありますか?おそらくない。どうして?理解するのが難しく、思い付くのが難しいです。したがって、エラーも発生しやすくなります。
一部の問題の解決策は、再帰を使用してより自然に表現されます。
たとえば、2種類のノードを持つツリーデータ構造があると仮定します。整数値を格納するリーフ。ブランチには、フィールドに左右のサブツリーがあります。最低値が左端の葉にあるように、葉が順序付けられていると仮定します。
タスクがツリーの値を順番に印刷することであるとします。これを行うための再帰アルゴリズムは非常に自然です。
class Node { abstract void traverse(); }
class Leaf extends Node {
int val;
void traverse() { print(val); }
}
class Branch extends Node {
Node left, right;
void traverse() { left.traverse(); right.traverse(); }
}
再帰なしで同等のコードを書くことははるかに困難です。それを試してみてください!
より一般的には、再帰はツリーのような再帰的なデータ構造のアルゴリズム、または自然にサブ問題に分割できる問題に対してうまく機能します。たとえば、アルゴリズムを分割して征服してください。
最も自然な環境で再帰を本当に見たい場合は、Haskellのような関数型プログラミング言語を検討する必要があります。このような言語では、ループ構造が存在しないため、すべてが再帰(または高次関数を使用して表現されますが、それは別の話であり、知っておく価値のあるものです)。
関数型プログラミング言語は、最適化された末尾再帰を実行することにも注意してください。これは、必要ない限りスタックフレームを配置しないことを意味します。本質的に、再帰はループに変換できます。実用的な観点からは、自然な方法でコードを記述できますが、反復コードのパフォーマンスを取得できます。記録的には、C ++コンパイラーも末尾呼び出しを最適化するようですので、C ++で再帰を使用することによる追加のオーバーヘッドはありません。
再帰的に実際に生きている誰かから、私はこの主題にいくらかの光を当てようとします。
最初に再帰を導入したとき、それはそれ自体を呼び出す関数であり、基本的にツリートラバーサルなどのアルゴリズムで示されることがわかります。後で、LISPやF#などの言語の関数型プログラミングで多く使用されていることがわかります。私が書いたF#では、書いたもののほとんどは再帰的でパターンマッチングです。
F#などの関数型プログラミングの詳細を学ぶと、F#リストは単一リンクリストとして実装されていることがわかります。つまり、リストの先頭にのみアクセスする操作はO(1)であり、要素アクセスはO(n)です。これを学習すると、データをリストとしてトラバースし、新しいリストを逆順で作成し、リストを逆にしてから関数から戻る傾向があります。これは非常に効果的です。
これについて考え始めると、関数呼び出しが行われるたびに再帰関数がスタックフレームをプッシュし、スタックオーバーフローを引き起こす可能性があることにすぐに気付くでしょう。ただし、末尾呼び出しを実行できるように再帰関数を構築し、コンパイラーが末尾呼び出しのコードを最適化する機能をサポートしている場合。つまり、.NET OpCodes.Tailcallフィールドでは、スタックオーバーフローは発生しません。この時点で、ループを再帰関数として記述し、決定を一致として書き始めます。日if
とwhile
なりました歴史があります。
PROLOGなどの言語でバックトラッキングを使用してAIに移行すると、すべてが再帰的になります。これには命令型コードとはまったく異なる方法で考える必要がありますが、PROLOGが問題に適したツールである場合、大量のコードを記述する必要がなくなり、エラーの数を劇的に減らすことができます。参照:AmziカスタマーeoTek
再帰をいつ使用するかという質問に戻ります。プログラミングの見方の1つは、一方の端にハードウェアを使用し、もう一方の端に抽象的な概念を使用することです。問題のハードウェアに近いが、より多くの私はと命令型言語で考えるif
とwhile
、より抽象的な問題は、より多くの私は、再帰で高レベルの言語で考えます。ただし、低レベルのシステムコードなどの記述を開始し、その有効性を検証したい場合は、再帰に大きく依存する定理証明のようなソリューションが役立ちます。
あなたが見ればジェーン・ストリート、あなたは彼らが関数型言語使用が表示されますOCamlのを。私は彼らのコードを見たことはありませんが、彼らが彼らのコードについて言及していることを読むことから、彼らは無意識に再帰的に考えています。
編集
使用法のリストを探しているので、コードで何を探すべきかという基本的な考え方と、基本を超えたカタモルフィズムの概念にほとんど基づいている基本的な使用法のリストを提供します。
C ++の場合:同じ構造またはクラスへのポインターを持つ構造またはクラスを定義する場合、ポインターを使用するトラバーサルメソッドの再帰を考慮する必要があります。
単純なケースは、一方向のリンクリストです。リストを先頭または末尾から処理し、ポインターを使用して再帰的にリストを走査します。
ツリーは、再帰がよく使用される別のケースです。あまりにも多くの場合、再帰なしでツリートラバーサルが表示される場合は、理由を尋ね始める必要がありますか?それは間違っていませんが、コメントで注意すべきことです。
再帰の一般的な用途は次のとおりです。
他の回答で与えられたものよりも難解なユースケースを提供するために:再帰は、一般的なソースから派生したツリーのような(オブジェクト指向)クラス構造と非常によく混ざります。C ++の例:
class Expression {
public:
// The "= 0" means 'I don't implement this, I let my subclasses do that'
virtual int ComputeValue() = 0;
}
class Plus : public Expression {
private:
Expression* left
Expression* right;
public:
virtual int ComputeValue() { return left->ComputeValue() + right->ComputeValue(); }
}
class Times : public Expression {
private:
Expression* left
Expression* right;
public:
virtual int ComputeValue() { return left->ComputeValue() * right->ComputeValue(); }
}
class Negate : public Expression {
private:
Expression* expr;
public:
virtual int ComputeValue() { return -(expr->ComputeValue()); }
}
class Constant : public Expression {
private:
int value;
public:
virtual int ComputeValue() { return value; }
}
上記の例では再帰を使用しています:ComputeValueは再帰的に実装されています。サンプルを機能させるには、仮想関数と継承を使用します。Plusクラスの左右の部分は正確にはわかりませんが、気にする必要はありません。それは、独自の値を計算できるものです。
上記のアプローチの決定的な利点は、すべてのクラスが独自の計算を処理することです。考えられるすべての部分式の異なる実装を完全に分離します。それらは互いの動作に関する知識を持ちません。これにより、プログラムについての推論が容易になり、プログラムの理解、保守、拡張が容易になります。