web-dev-qa-db-ja.com

ループでは実行できない、再帰で実行できるものはありますか?

ループを使用するよりも再帰を使用する方が良い場合もあれば、再帰を使用するよりもループを使用する方が良い場合もあります。 「正しい」ものを選択すると、リソースを節約したり、コードの行数を減らしたりできます。

ループではなく再帰を使用してのみタスクを実行できるケースはありますか?

130
Pikamander2

はいといいえ。結局のところ、再帰がループで計算できないことを計算できるものはありませんが、ループではより多くの配管が必要になります。したがって、再帰がループでできないことの1つは、一部のタスクを非常に簡単にすることです。

木を歩く。再帰的に木を歩くのは馬鹿げています。それは世界で最も自然なことです。ループでツリーを歩くのは、それほど単純ではありません。スタックやその他のデータ構造を維持して、実行した内容を追跡する必要があります。

多くの場合、問題の再帰的な解決策はよりきれいです。それは専門用語であり、重要です。

165
Scant Roger

番号。

計算するために必要な最小限のvery基本に到達するには、ループできる必要があります(これだけでは不十分です。むしろ必要なコンポーネントです)。それは問題ではありませんhow

チューリングマシンを実装できるプログラミング言語は Turing complete と呼ばれます。そして、完成に向かっている言語はたくさんあります。

私の好きな言語は、「実際に機能するのか」ということです。チューリングの完全性は [〜#〜] fractran [〜#〜] であり、これは Turing complete です。ループ構造は1つで、チューリングマシンを実装できます。したがって、計算可能なものはすべて、再帰のない言語で実装できます。したがって、単純なループではできない計算可能性の観点から、再帰によって得られるなしはありません。

これは実際にはいくつかのポイントに要約されます:

  • 計算可能なものはすべてチューリングマシンで計算可能です
  • チューリングマシンを実装できるすべての言語(チューリングコンプリートと呼ばれます)は、他の言語ができるすべてのものを計算できます。
  • 再帰に欠ける言語のTuringマシンがある(そして、他のesolangの一部に入るとのみが再帰する他のマシンがある)ので、ループでは実行できない再帰で実行できること(そして、再帰では実行できないループでは実行できないこと)。

これは、ループではなく再帰で、または再帰ではなくループでより簡単に考えられるいくつかの問題のあるクラスがあると言っているのではありません。ただし、これらのツールも同様に強力です。

そして私はこれを「esolang」の極限に持っていきましたが(主にチューリングが完全で、かなり奇妙な方法で実装されていることがわかるため)、これはesolangがオプションであることを意味するものではありません。全体 誤ってチューリングが完了したもののリスト には、Magic the Gathering、Sendmail、MediaWikiテンプレート、およびScala型システムが含まれます。これらの多くは、実際に実用的なことを行う場合に最適です。これらのツールを使用して計算可能なあらゆるものを計算できるというだけです。


この同等性は、 tail call として知られる特定のタイプの再帰に入るときに特に興味深いものになる可能性があります。

あなたが持っている場合、たとえば、次のように書かれた階乗法があります:

int fact(int n) {
    return fact(n, 1);
}

int fact(int n, int accum) {
    if(n == 0) { return 1; }
    if(n == 1) { return accum; }
    return fact(n-1, n * accum);
}

このタイプの再帰はループとして書き直されます-スタックは使用されません。このようなアプローチは、多くの場合、記述される同等のループよりもエレガントで理解しやすいことが多いですが、繰り返しになりますが、再帰呼び出しごとに同等のループが記述され、ループごとに再帰呼び出しが記述されます。

単純なループを末尾呼び出しの再帰呼び出しに変換することが複雑になり、もっと理解しにくい場合もあります。


理論的な側面を知りたい場合は、 Church Turing thesis を参照してください。また、CS.SEで church-turing-thesis が役立つこともあります。

79
user40980

ループではなく再帰を使用してのみタスクを実行できるケースはありますか?

常に再帰アルゴリズムをループに変えることができます。これは、Last-In-First-Outデータ構造(AKAスタック)を使用して一時的な状態を格納します。再帰呼び出しは正確に現在の状態をスタックに格納し、アルゴリズムを続行するためです。その後、状態を復元します。だから短い答えは:いいえ、そのようなケースはありません

ただし、「はい」については議論することができます。具体的で簡単な例を見てみましょう:マージソート。データを2つの部分に分割し、それらの部分をマージソートしてから、それらを組み合わせる必要があります。パーツでマージソートを行うために、実際のプログラミング言語の関数呼び出しでマージソートを行わなくても、実際に関数呼び出しを行うのと同じ機能を実装する必要があります(状態を自分のスタックにプッシュし、異なる開始パラメーターでループを開始し、後でスタックから状態をポップします)。

再帰呼び出しを自分で実装する場合、それは再帰ですか?「プッシュ状態」と「最初にジャンプ」および「ポップ状態」のステップとして別々ですか?そしてその答えは次のとおりです:いいえ、それはまだ再帰と呼ばれていません、それは 明示的なスタックによる反復(確立された用語を使用する場合)。


これは「タスク」の定義にも依存することに注意してください。タスクがソートである場合、多くのアルゴリズムを使用してそれを実行できます。その多くは、いかなる種類の再帰も必要としません。タスクがmerge sortなどの特定のアルゴリズムを実装することである場合、上記のあいまいさが適用されます。

質問を考えてみましょう。一般的なタスクはありますが、再帰のようなアルゴリズムしかありません。質問の下での@WizardOfMenloのコメントから、 Ackermann関数 はその簡単な例です。そのため、再帰の概念は、それが別のコンピュータープログラム構造(明示的なスタックによる反復)で実装できる場合でも、それ自体で成り立ちます。

31
hyde

「再帰」をどの程度厳密に定義するかによって異なります。

コールスタック(またはプログラムの状態を維持するためのメカニズムが使用されているもの)を厳密に含める必要がある場合は、常にそれを含まないものに置き換えることができます。実際、自然に再帰を多用する言語では、末尾呼び出しの最適化を多用するコンパイラーを使用する傾向があるため、記述内容は再帰的ですが、実行内容は反復的です。

しかし、再帰呼び出しを行い、その再帰呼び出しに再帰呼び出しの結果を使用する場合を考えてみましょう。

public static BigInteger Ackermann(BigInteger m, BigInteger n)
{
  if (m == 0)
    return  n+1;
  if (n == 0)
    return Ackermann(m - 1, 1);
  else
    return Ackermann(m - 1, Ackermann(m, n - 1));
}

最初の再帰呼び出しを反復させるのは簡単です:

public static BigInteger Ackermann(BigInteger m, BigInteger n)
{
restart:
  if (m == 0)
    return  n+1;
  if (n == 0)
  {
    m--;
    n = 1;
    goto restart;
  }
  else
    return Ackermann(m - 1, Ackermann(m, n - 1));
}

次に、gotoをクリーンアップして削除し velociraptors とダイクストラの陰影を避けます。

public static BigInteger Ackermann(BigInteger m, BigInteger n)
{
  while(m != 0)
  {
    if (n == 0)
    {
      m--;
      n = 1;
    }
    else
      return Ackermann(m - 1, Ackermann(m, n - 1));
  }
  return  n+1;
}

ただし、他の再帰呼び出しを削除するには、いくつかの呼び出しの値をスタックに格納する必要があります。

public static BigInteger Ackermann(BigInteger m, BigInteger n)
{
  Stack<BigInteger> stack = new Stack<BigInteger>();
  stack.Push(m);
  while(stack.Count != 0)
  {
    m = stack.Pop();
    if(m == 0)
      n = n + 1;
    else if(n == 0)
    {
      stack.Push(m - 1);
      n = 1;
    }
    else
    {
      stack.Push(m - 1);
      stack.Push(m);
      --n;
    }
  }
  return n;
}

さて、ソースコードを検討すると、再帰的なメソッドは確かに反復的なものになっています。

これが何にコンパイルされているかを考慮して、再帰を実装しないコードにコールスタックを使用するコードを変更しました(そうすることで、非常に小さな値でもスタックオーバーフロー例外をスローするコードが、単に返すのに耐えられないほど長い時間がかかります(参照してください Ackerman関数がスタックをオーバーフローしないようにするにはどうすればよいですか

再帰の一般的な実装方法を考慮して、コールスタックを使用するコードを、別のスタックを使用して保留中の操作を保持するコードに変更しました。したがって、その低いレベルで考えると、それはまだ再帰的であると主張できます。

そして、そのレベルでは、実際には他の方法はありません。したがって、そのメソッドを再帰的であると考える場合、実際にそれなしでは実行できないことはあります。通常、そのようなコードには再帰的なラベルを付けていません。用語recursionは、特定の一連のアプローチをカバーし、それらについて話す方法を提供し、それらの1つを使用しなくなったため、有用です。

もちろん、これらはすべて、選択肢があることを前提としています。再帰呼び出しを禁止する言語と、反復に必要なループ構造が不足している言語の両方があります。

20
Jon Hanna

古典的な答えは「いいえ」ですが、「はい」がより良い答えであると思う理由を詳しく説明させてください。


先に進む前に、計算能力と複雑さの観点から、次のことを考えてみましょう。

  • ループ時に補助スタックを許可されている場合、答えは「いいえ」です。
  • ループ時に追加のデータが許可されていない場合、答えは「はい」です。

では、片方の足を練習用の土地に置き、もう片方の足を理論用の土地に置いておきましょう。


呼び出しスタックはcontrol構造ですが、手動スタックはdataです構造。コントロールとデータは等しくない概念ですが、それらはであるという意味で(= /// =)同等です計算可能性または複雑さの観点から、相互に削減(または相互に「エミュレート」)。

この区別が重要になるのはいつですか?実際のツールを使用しているとき。次に例を示します。

N-way mergesortを実装しているとしましょう。 forループがあり、各Nセグメントを通過し、それらに対して別々にmergesortを呼び出して、結果をマージする場合があります。

これをOpenMPとどのように並列化できますか?

再帰的な領域では、非常に簡単です。単に#pragma omp parallel for 1からNへのループを完了すると、完了です。反復領域では、これを行うことはできません。スレッドを手動で生成し、適切なデータを手動で渡して、何をすべきかがわかるようにする必要があります。

一方、他のツールもあります(自動ベクトライザーなど、#pragma vector)ループで動作しますが、再帰ではまったく役に立ちません。

つまり、2つのパラダイムが数学的に同等であることを証明できるからといって、実際にはそれらが等しいということではありません。 1つのパラダイム(たとえば、ループ並列化)で自動化するのが簡単な問題は、他のパラダイムで解決するのがはるかに難しい場合があります。

つまり:1つのパラダイムのツールは、他のパラダイムに自動的に変換されません。

したがって、問題を解決するためのツールが必要な場合、そのツールが特定の種類のアプローチでしか機能しない可能性があり、その結果、数学的に問題を解決できたとしても、別のアプローチでは問題を解決できない可能性があります。どちらかの方法で解決されます。

9
user541686

理論的な推論はさておき、(ハードウェアまたは仮想)マシンの観点から、再帰とループがどのように見えるかを見てみましょう。再帰は、一部のコードの実行を開始して完了時に戻ることができる制御フロー(信号と例外が無視された場合の単純化したビュー)と、他のコード(引数)に渡されてから返されるデータの組み合わせですそれ(結果)。通常、明示的なメモリ管理は含まれませんが、戻りアドレス、引数、結果、中間ローカルデータを保存するためにstackメモリが暗黙的に割り当てられます。

ループは、制御フローとローカルデータの組み合わせです。これを再帰と比較すると、この場合のデータ量は固定されていることがわかります。この制限を破る唯一の方法は、割り当て(および解放)できるdynamicメモリー(heapとも呼ばれます)を使用することです必要なときはいつでも。

要約する:

  • 再帰ケース=制御フロー+スタック(+ヒープ)
  • ループケース=制御フロー+ヒープ

制御フロー部分がかなり強力であると仮定すると、唯一の違いは、利用可能なメモリタイプにあります。したがって、4つのケースが残ります(表現力は括弧内にリストされています)。

  1. スタックもヒープもありません。再帰と動的構造は不可能です。 (再帰=ループ)
  2. スタック、ヒープなし:再帰はOK、動的構造は不可能です。 (再帰>ループ)
  3. スタックなし、ヒープ:再帰は不可能、動的構造はOKです。 (再帰=ループ)
  4. スタック、ヒープ:再帰および動的構造は問題ありません。 (再帰=ループ)

ゲームのルールが少し厳しく、再帰的な実装がループの使用を許可されていない場合、代わりに次のようになります。

  1. スタックもヒープもありません。再帰と動的構造は不可能です。 (再帰<ループ)
  2. スタック、ヒープなし:再帰はOK、動的構造は不可能です。 (再帰>ループ)
  3. スタックなし、ヒープ:再帰は不可能、動的構造はOKです。 (再帰<ループ)
  4. スタック、ヒープ:再帰および動的構造は問題ありません。 (再帰=ループ)

前のシナリオとの主な違いは、スタックメモリが不足しているため、ループなしで再帰を実行しても、コード行よりも多くのステップを実行できないことです。

8

はい。再帰を使用して簡単に実行できる一般的なタスクがいくつかありますが、ループだけでは不可能です。

  • スタックオーバーフローの原因。
  • 完全にわかりにくい初心者プログラマー。
  • 実際にO(n ^ n)である高速関数を作成する。
2
jpa

再帰関数とプリミティブ再帰関数には違いがあります。プリミティブ再帰関数は、ループを使用して計算される関数で、ループの実行が開始される前に、各ループの最大反復回数が計算されます。 (そして、ここでの「再帰的」は再帰の使用とは何の関係もありません)。

プリミティブ再帰関数は、再帰関数よりも厳密には強力ではありません。再帰の最大深度を事前に計算する必要がある再帰を使用する関数を使用した場合も、同じ結果が得られます。

1
gnasher729

C++でプログラミングしていて、c ++ 11を使用している場合、再帰を使用して実行する必要のあることが1つあります。それはconstexpr関数です。しかし、標準では、これは この答え で説明されているように512に制限されています。この場合、関数をconstexprにすることはできないため、ループを使用することはできませんが、これはc ++ 14で変更されています。

1
BЈовић
  • 再帰呼び出しが再帰関数の最初または最後のステートメント(条件チェックを除く)である場合、ループ構造に変換するのは非常に簡単です。
  • ただし、関数が他のことを行う場合再帰呼び出しの前後の場合、ループに変換するのは面倒です。
  • 関数にmultiple再帰呼び出しがある場合、ループだけを使用するコードに変換することはほとんど不可能です。データに追いつくためにいくつかのスタックが必要になります。再帰的には、コールスタック自体がデータスタックとして機能します。
0
Gulshan