再帰か反復か?


226

両方で同じ目的を果たすことができるアルゴリズムで、再帰ではなくループを使用した場合、またはその逆の場合、パフォーマンスに影響がありますか?例:指定された文字列が回文かどうかを確認します。多くのプログラマーが再帰を使用して、単純な反復アルゴリズムがうまく機能する時期を自慢する方法を見てきました。コンパイラーは、何を使用するかを決定する上で重要な役割を果たしますか?


4
@Warrior常にではありません。たとえば、チェスプログラムを使用すると、再帰を読みやすくなります。チェスコードの「反復」バージョンは実際にはスピードを助けませんし、それをより複雑にするかもしれません。
Mateen Ulhaq

12
ハンマーをのこぎりよりも好むのはなぜですか?千枚通しのドライバー?オーガーのノミ?
ウェインコンラッド

3
お気に入りはありません。これらはすべて、単なるツールであり、それぞれに独自の目的があります。私は、「反復は再帰よりも優れているのはどの種類の問題であり、逆もまた同様ですか?」
ウェインコンラッド

9
「再帰について何が良いのか?」...それは再帰的なものです。; o)
Keng

9
誤った前提。再帰は良くありません。実際、それは非常に悪いです。堅牢なソフトウェアを書いている人は、再帰をすべて排除しようとします。なぜなら、末尾呼び出しが最適化されないか、対数的に制限されたレベルの数などでなければ、再帰はほとんど常に悪い種類のスタックオーバーフローを引き起こすからです。
R .. GitHub ICE HELPING ICEを停止する

回答:


181

再帰関数が末尾再帰である(最後の行が再帰呼び出しである)かどうかによっては、再帰がより高価になる可能性があります。テール再帰コンパイラーによって認識され、その反復対応に最適化されます(コード内の簡潔で明確な実装を維持しながら)。

私は、アルゴリズムを最も意味のある方法で記述し、数か月または数年でコードを維持する必要がある貧しい吸盤(自分でも他の誰かでも)にとって最もわかりやすい方法で記述します。パフォーマンスの問題が発生した場合は、コードのプロファイルを作成してから、反復的な実装に移って最適化を検討してください。メモ化動的プログラミングを調べてみてください。


12
帰納法によって正当性が証明できるアルゴリズムは、自然に再帰的な形で記述される傾向があります。末尾再帰がコンパイラーによって最適化されるという事実と相まって、再帰的に表現されるアルゴリズムが増えることになります。
Binil Thomas

15
再:tail recursion is optimized by compilersしかし、すべてのコンパイラは、尾再帰をサポートしてい...
ケビン・メレディス

347

ループにより、プログラムのパフォーマンスが向上する場合があります。再帰により、プログラマーのパフォーマンスが向上する場合があります。あなたの状況でどちらがより重要かを選択してください!


3
@LeighCaldwell:それは私の考えを正確に要約したものだと思います。Pity Omnipotentはupmodしませんでした。確かにあります。:)
アンデターナー

35
答えのフレーズが原因で本に引用されたことをご存知ですか?LOL amazon.com/Grokking-Algorithms-illustrated-programmers-curious/...
Aipi

4
私はこの答えが好きです。そして、私は "Grokking Algorithms"という本が好きです)
Max

したがって、少なくとも私と341人の人間がGrokking Algorithmsの本を読んでいます!
zzfima

78

再帰と反復の比較は、プラスドライバとマイナスドライバを比較するようなものです。ほとんどの場合、平頭のフィリップスヘッドネジ外すことができます、そのネジ用に設計されたドライバーを使用すると簡単です。

一部のアルゴリズムは、設計方法が原因で再帰に適しています(フィボナッチ数列、構造のようなツリーのトラバースなど)。再帰により、アルゴリズムはより簡潔で理解しやすくなります(したがって、共有および再利用が可能になります)。

また、一部の再帰アルゴリズムは「遅延評価」を使用しており、反復兄弟よりも効率的です。これは、ループが実行されるたびではなく、必要なときにのみ高価な計算を実行することを意味します。

それはあなたが始めるのに十分なはずです。私もあなたのためにいくつかの記事と例を掘り下げます。

リンク1: HaskelとPHP(再帰と反復)

以下は、プログラマーがPHPを使用して大きなデータセットを処理しなければならない例です。彼はHaskelで再帰を使用して処理するのがいかに簡単であったかを示していますが、PHPには同じ方法を実行する簡単な方法がなかったため、反復を使用して結果を取得する必要がありました。

http://blog.webspecies.co.uk/2011-05-31/lazy-evaluation-with-php.html

リンク2:再帰をマスターする

再帰の評判の悪さのほとんどは、命令型言語の高コストと非効率性に起因しています。この記事の著者は、再帰アルゴリズムを最適化して、アルゴリズムをより高速かつ効率的にする方法について説明しています。また、従来のループを再帰関数に変換する方法と、末尾再帰を使用する利点についても説明します。彼の締めくくりの言葉は、私が考える私の重要なポイントのいくつかを本当に要約しました:

「再帰的プログラミングは、保守可能で論理的に一貫性のある方法でコードを編成するより良い方法をプログラマーに提供します。」

https://developer.ibm.com/articles/l-recurs/

リンク3:再帰はループよりも速いですか?(回答)

これは、あなたに似たstackoverflow質問の回答へのリンクです。いずれかの再帰に関連したベンチマークのロットまたはループであることを、著者のポイントアウト非常に言語固有。命令型言語は通常、ループを使用すると高速になり、再帰を使用すると低速になり、関数型言語ではその逆になります。このリンクからの主なポイントは、言語にとらわれない/状況の盲目的な感覚で質問に答えることは非常に難しいということです。

再帰はループよりも速いですか?


4
ドライバーの類推が本当に好きだった
jh314


16

再帰呼び出しは通常、メモリアドレスをスタックにプッシュする必要があるため、再帰はメモリ内でよりコストがかかります。そのため、後でプログラムはそのポイントに戻ることができます。

それでも、ツリーを操作するときのように、ループよりも再帰がはるかに自然で読みやすい場合が多くあります。これらの場合、私は再帰に固執することをお勧めします。


5
もちろん、コンパイラーがScalaのように末尾呼び出しを最適化しない限り。
ベンハーディ

11

通常、パフォーマンスの低下は他の方向にあると予想されます。再帰呼び出しは、追加のスタックフレームの構築につながる可能性があります。これに対するペナルティはさまざまです。また、Pythonなどの一部の言語では(より正確には、一部の言語の一部の実装では...)、ツリーデータ構造で最大値を見つけるなど、再帰的に指定する可能性のあるタスクのスタック制限にかなり容易に遭遇する可能性があります。これらの場合、あなたは本当にループを使いたいです。

末尾の再帰を最適化するコンパイラーがあると仮定すると、優れた再帰関数を作成すると、パフォーマンスのペナルティがいくらか軽減されます(また、関数が本当に末尾再帰であることを確認してください---これは多くの人が間違いを犯していることの1つです)オン。)

「エッジ」ケース(ハイパフォーマンスコンピューティング、非常に大きな再帰の深さなど)とは別に、意図を最も明確に表現し、適切に設計され、保守可能なアプローチを採用することをお勧めします。ニーズを特定してから最適化してください。


8

再帰は、反復よりも、複数の小さな断片に分解できる問題の方が優れています。

たとえば、再帰的なフィボナッチアルゴリズムを作成するには、fib(n)をfib(n-1)とfib(n-2)に分解し、両方の部分を計算します。反復では、単一の関数を何度も繰り返すことができます。

しかし、フィボナッチは実際には壊れた例であり、反復は実際にはより効率的だと思います。fib(n)= fib(n-1)+ fib(n-2)およびfib(n-1)= fib(n-2)+ fib(n-3)であることに注意してください。fib(n-1)は2回計算されます!

より良い例は、ツリーの再帰的アルゴリズムです。親ノードの分析の問題は、各子ノードの分析に関する複数の小さな問題に分類できます。フィボナッチの例とは異なり、小さな問題は互いに独立しています。

そうそう-再帰は、複数の、より小さく、独立した、同様の問題に分解できる問題の反復よりも優れています。


1
メモ化により、2回の計算は実際には回避できました。
Siddhartha

7

再帰を使用すると、パフォーマンスが低下します。これは、任意の言語でメソッドを呼び出すと、多くの準備が必要になるためです。呼び出しコードは、戻りアドレス、呼び出しパラメーター、プロセッサーレジスタなどの他のコンテキスト情報をどこかに保存し、戻り時に呼び出されたメソッドは戻り値をポストします。戻り値は呼び出し元によって取得され、以前に保存されたコンテキスト情報が復元されます。反復アプローチと再帰アプローチのパフォーマンスの違いは、これらの操作にかかる時間にあります。

実装の観点から見ると、呼び出しコンテキストの処理にかかる時間が、メソッドの実行にかかる時間と同等である場合に、違いに気付くようになります。再帰的メソッドの実行に呼び出し側のコンテキスト管理部分よりも時間がかかる場合は、コードが一般的に読みやすく、理解しやすく、パフォーマンスの低下に気付かないため、再帰的な方法を使用してください。それ以外の場合は、効率上の理由から繰り返します。


いつもそうだとは限りません。再帰は、末尾呼び出しの最適化を実行できるいくつかのケースでは、反復と同じくらい効率的です。stackoverflow.com/questions/310974/...
シドクシャトリヤ

6

Javaの末尾再帰は現在最適化されていないと思います。LtUと関連リンクに関するこのディスカッション全体に詳細が散りばめられています。これ次のバージョン7の機能である可能性がありますが、スタック検査と組み合わせると特定のフレームが欠落するため、明らかに特定の問題が発生します。スタック検査は、Java 2以降、きめ細かなセキュリティモデルの実装に使用されています。

http://lambda-the-ultimate.org/node/1333


末尾再帰を最適化するJVM for Javaがあります。ibm.com/developerworks/java/library/j-diag8.html
Liran Orevi 2009

5

反復法よりもはるかに洗練されたソリューションを提供する多くの場合があり、一般的な例はバイナリツリーのトラバースです。そのため、維持するのは必ずしも難しくありません。一般に、反復バージョンは通常少し高速です(最適化中に再帰バージョンに置き換わる可能性があります)が、再帰バージョンの方が簡単に理解して正しく実装できます。


5

再帰は、状況によっては非常に役立ちます。たとえば、階乗を見つけるためのコードを考えます

int factorial ( int input )
{
  int x, fact = 1;
  for ( x = input; x > 1; x--)
     fact *= x;
  return fact;
}

再帰関数を使用して考えてみましょう

int factorial ( int input )
{
  if (input == 0)
  {
     return 1;
  }
  return input * factorial(input - 1);
}

この2つを観察すると、再帰がわかりやすいことがわかります。ただし、注意して使用しないと、エラーが発生しやすくなります。もしを見逃したとするとif (input == 0)、コードはしばらくの間実行され、通常はスタックオーバーフローで終了します。


6
実際、反復バージョンの方が理解しやすいと思います。彼一人一人に、私は思います。
Maxpm

@Maxpm、高次再帰ソリューションの方がはるかに優れfoldl (*) 1 [1..n]ています。それだけです。
SKロジック

5

多くの場合、キャッシュが原因で再帰が速くなり、パフォーマンスが向上します。たとえば、これは従来のマージルーチンを使用したマージソートの反復バージョンです。キャッシングによりパフォーマンスが向上するため、再帰的な実装よりも実行が遅くなります。

反復的な実装

public static void sort(Comparable[] a)
{
    int N = a.length;
    aux = new Comparable[N];
    for (int sz = 1; sz < N; sz = sz+sz)
        for (int lo = 0; lo < N-sz; lo += sz+sz)
            merge(a, lo, lo+sz-1, Math.min(lo+sz+sz-1, N-1));
}

再帰的な実装

private static void sort(Comparable[] a, Comparable[] aux, int lo, int hi)
{
    if (hi <= lo) return;
    int mid = lo + (hi - lo) / 2;
    sort(a, aux, lo, mid);
    sort(a, aux, mid+1, hi);
    merge(a, aux, lo, mid, hi);
}

PS-これは、Courseraで提示されたアルゴリズムのコースについてKevin Wayne教授(プリンストン大学)が語ったことです。


4

再帰を使用すると、「反復」ごとに関数呼び出しのコストが発生しますが、ループでは、通常支払う唯一のものは増分/減分です。したがって、ループのコードが再帰的ソリューションのコードよりもはるかに複雑でない場合、ループは通常、再帰よりも優れています。


1
実際には、コンパイルされたScalaの末尾再帰関数は、バイトコードのループにまとめられます(それらを確認する必要がある場合)(推奨)。関数呼び出しのオーバーヘッドはありません。第2に、末尾再帰関数には、可変変数/副作用や明示的なループを必要としないという利点があり、正確性の証明がはるかに容易になります。
ベンハーディ

4

再帰と反復は、実装するビジネスロジックに依存しますが、ほとんどの場合、互換的に使用できます。理解しやすいので、ほとんどの開発者は再帰を行います。


4

言語によって異なります。Javaでは、ループを使用する必要があります。関数型言語は再帰を最適化します。


3

リストを繰り返し処理しているだけの場合は、繰り返し処理してください。

他のいくつかの回答では、(深さ優先)ツリートラバーサルについて言及しています。非常に一般的なデータ構造に対して行うことは非常に一般的なことなので、これは本当に素晴らしい例です。この問題では、再帰は非常に直感的です。

ここで「検索」メソッドを確認してください:http : //penguin.ewu.edu/cscd300/Topic/BSTintro/index.html


3

再帰は、可能な反復の定義よりも単純です(したがって、より基本的です)。1 組のコンビネーターのみでチューリング完全なシステムを定義できます(そうです、再帰自体もそのようなシステムの派生概念です)。ラムダ計算は、同様に強力な基本システムであり、再帰関数を備えています。しかし、イテレーションを適切に定義したい場合は、最初にもっと多くのプリミティブが必要になります。

コードに関して-いいえ、ほとんどのデータ構造は再帰的であるため、再帰的コードは純粋に反復的なコードよりも実際に理解と保守がはるかに簡単です。もちろん、それを正しく行うためには、少なくとも高次の関数とクロージャーをサポートする言語が必要です。すべての標準のコンビネーターとイテレーターをきちんと取得するためです。もちろん、FC ++などのハードコアユーザーでない限り、複雑な再帰的ソリューションは少し見苦しく見えることがあります。


再帰コードは、特にパラメータの順序が変更されたり、再帰ごとに型が変わったりすると、追跡が非常に困難になる場合があります。反復コードは非常に単純で説明的なものにすることができます。重要なことは、反復可能か再帰的かにかかわらず、最初に読みやすさ(したがって信頼性)をコーディングし、必要に応じて最適化することです。
Marcus Clements

2

(非末尾)再帰では、関数が呼び出されるたびに(もちろん言語によって異なりますが)新しいスタックを割り当てるなどのパフォーマンスヒットがあると思います。


2

「再帰の深さ」に依存します。これは、関数呼び出しのオーバーヘッドが総実行時間にどの程度影響するかによって異なります。

たとえば、次の理由により、古典的な階乗を再帰的に計算することは非常に非効率的です。-データがオーバーフローするリスク-スタックがオーバーフローするリスク-関数呼び出しのオーバーヘッドが実行時間の80%を占める

チェスのゲームで位置分析のための最小-最大アルゴリズムを開発している間、後続のN動作を分析することで、「分析の深さ」を再帰的に実装できます(私が^ _ ^を実行しているため)


ここでugasoftに完全に同意します...再帰の深さに依存します。...反復的な実装の複雑さ...両方を比較して、どちらがより効率的かを確認する必要があります...そのような経験則はありません。 ..
rajya vardhan 2011年

2

再帰?どこから始めれば、wikiは「アイテムを自己相似的な方法で繰り返すプロセスです」と通知します

私がCをやっていた当時、C ++の再帰は "Tail recursion"のようなものでした。また、多くのソートアルゴリズムは再帰を使用しています。クイックソートの例:http : //alienryderflex.com/quicksort/

再帰は、特定の問題に役立つ他のアルゴリズムと同様です。多分あなたはすぐにまたは頻繁に用途を見つけられないかもしれませんが、それが利用可能であることにうれしいでしょう問題があります。


私はコンパイラの最適化を逆に持っていると思います。コンパイラは、スタックの増大を回避するために、可能な場合は再帰関数を反復ループに最適化します。
CoderDennis

フェアポイント、それは後方でした。ただし、それが末尾再帰にまだ適用できるかどうかはわかりません。
Nickz 2013年

2

C ++では、再帰関数がテンプレート化されたものである場合、すべての型の推定と関数のインスタンス化がコンパイル時に行われるため、コンパイラーはそれを最適化する機会が多くなります。最新のコンパイラは、可能であれば関数をインライン化することもできます。したがって、-O3または-O2で最適化フラグを使用する場合g++、再帰は反復よりも高速になる可能性があります。反復コードでは、コンパイラーは既に最適化された状態にあるため(十分に記述されている場合)、最適化する機会が少なくなります。

私の場合、私は再帰的および反復的な方法の両方で、Armadilloマトリックスオブジェクトを使用して二乗することにより、マトリックスのべき乗を実装しようとしていました。アルゴリズムはここにあります... https://en.wikipedia.org/wiki/Exponentiation_by_squaring。私の関数はテンプレート化されており1,000,000 12x12、累乗された行列を計算しました10。次の結果が得られました。

iterative + optimisation flag -O3 -> 2.79.. sec
recursive + optimisation flag -O3 -> 1.32.. sec

iterative + No-optimisation flag  -> 2.83.. sec
recursive + No-optimisation flag  -> 4.15.. sec

これらの結果は、c ++ 11フラグ(-std=c++11)付きのgcc-4.8 とIntel mkl付きのArmadillo 6.1を使用して得られました。Intelコンパイラも同様の結果を示します。


1

マイクは正しいです。テール再帰は、JavaコンパイラーやJVMによって最適化されません。あなたは常に次のようなものでスタックオーバーフローを取得します:

int count(int i) {
  return i >= 100000000 ? i : count(i+1);
}

3
Scalaで書いていない限り;-)
Ben Hardy

1

許容されるスタックサイズによっては、深すぎる再帰を利用するとスタックオーバーフローが発生することに注意してください。これを防ぐには、再帰を終了させる基本ケースを提供してください。


1

再帰には、再帰を使用して作成するアルゴリズムにO(n)空間の複雑さがあるという欠点があります。反復的なアプローチはO(1)のスペースの複雑さを持っていますが、これは再帰よりも反復を使用する利点です。では、なぜ再帰を使用するのでしょうか。

下記参照。

反復を使用して同じアルゴリズムを記述する方が少し難しい一方で、再帰を使用してアルゴリズムを記述する方が簡単な場合があります。この場合、反復アプローチに従うことを選択した場合は、自分でスタックを処理する必要があります。


1

反復がアトミックであり、新しいスタックフレームをプッシュよりも桁違いに高価な場合、新しいスレッドを作成し、あなたが複数のコアを持っているし、あなたのランタイム環境は、それらのすべてを使用することができると組み合わせると、その後、再帰的なアプローチは、巨大なパフォーマンス向上をもたらす可能性マルチスレッド。反復の平均数が予測できない場合は、スレッドの割り当てを制御し、プロセスが多すぎるスレッドを作成してシステムを占有するのを防ぐスレッドプールを使用することをお勧めします。

たとえば、一部の言語では、再帰的なマルチスレッドのマージソート実装があります。

ただし、繰り返しになりますが、マルチスレッドは再帰ではなくループで使用できるため、この組み合わせがどの程度うまく機能するかは、OSやそのスレッド割り当てメカニズムなど、より多くの要因に依存します。


0

私の知る限り、Perlは末尾再帰呼び出しを最適化していませんが、偽造することはできます。

sub f{
  my($l,$r) = @_;

  if( $l >= $r ){
    return $l;
  } else {

    # return f( $l+1, $r );

    @_ = ( $l+1, $r );
    goto &f;

  }
}

最初に呼び出されたとき、スタックにスペースを割り当てます。次に、引数を変更し、スタックに何も追加せずにサブルーチンを再起動します。したがって、それは自分自身を呼び出したことがないふりをして、それを反復プロセスに変更します。

" my @_;"または " local @_;" がないことに注意してください。そうした場合、機能しなくなります。


0

Chrome 45.0.2454.85 mだけを使用すると、再帰はかなり速くなるようです。

これがコードです:

(function recursionVsForLoop(global) {
    "use strict";

    // Perf test
    function perfTest() {}

    perfTest.prototype.do = function(ns, fn) {
        console.time(ns);
        fn();
        console.timeEnd(ns);
    };

    // Recursion method
    (function recur() {
        var count = 0;
        global.recurFn = function recurFn(fn, cycles) {
            fn();
            count = count + 1;
            if (count !== cycles) recurFn(fn, cycles);
        };
    })();

    // Looped method
    function loopFn(fn, cycles) {
        for (var i = 0; i < cycles; i++) {
            fn();
        }
    }

    // Tests
    var curTest = new perfTest(),
        testsToRun = 100;

    curTest.do('recursion', function() {
        recurFn(function() {
            console.log('a recur run.');
        }, testsToRun);
    });

    curTest.do('loop', function() {
        loopFn(function() {
            console.log('a loop run.');
        }, testsToRun);
    });

})(window);

結果

//標準のforループを使用して100回実行

ループ実行用に100倍。完了までの時間:7.683ms

//末尾再帰を伴う関数型再帰アプローチを使用して100回実行

100x再帰実行。完了までの時間:4.841ms

以下のスクリーンショットでは、再帰は、テストごとに300サイクルで実行すると、より大きなマージンで再び勝利します。

再帰が再び勝利します!


ループ関数内で関数を呼び出しているため、テストは無効です。これは、ループの最も顕著なパフォーマンス上の利点の1つである命令ジャンプの欠如(関数呼び出し、スタック割り当て、スタックポップなどを含む)を無効にします。ループ内でタスクを実行している(関数と呼ばれるだけではない)場合と、再帰関数内でタスクを実行している場合とでは、結果が異なります。(PSのパフォーマンスは、実際のタスクアルゴリズムの問​​題であり、命令ジャンプは、それらを回避するために必要な計算よりも安価な場合があります)。
Myst

0

これらのアプローチには別の違いがあることがわかりました。シンプルで重要ではないように見えますが、インタビューの準備をしているときに非常に重要な役割を果たし、このテーマが浮上するので、よく見てください。

要約すると、1)反復ポストオーダートラバーサルは容易ではありません。これにより、DFTがより複雑になります。2)再帰により、サイクルチェックが容易になります。

詳細:

再帰的なケースでは、トラバーサルの前後を簡単に作成できます。

かなり標準的な質問を想像してください:「タスクが他のタスクに依存している場合、タスク5を実行するために実行する必要があるすべてのタスクを出力します」

例:

    //key-task, value-list of tasks the key task depends on
    //"adjacency map":
    Map<Integer, List<Integer>> tasksMap = new HashMap<>();
    tasksMap.put(0, new ArrayList<>());
    tasksMap.put(1, new ArrayList<>());

    List<Integer> t2 = new ArrayList<>();
    t2.add(0);
    t2.add(1);
    tasksMap.put(2, t2);

    List<Integer> t3 = new ArrayList<>();
    t3.add(2);
    t3.add(10);
    tasksMap.put(3, t3);

    List<Integer> t4 = new ArrayList<>();
    t4.add(3);
    tasksMap.put(4, t4);

    List<Integer> t5 = new ArrayList<>();
    t5.add(3);
    tasksMap.put(5, t5);

    tasksMap.put(6, new ArrayList<>());
    tasksMap.put(7, new ArrayList<>());

    List<Integer> t8 = new ArrayList<>();
    t8.add(5);
    tasksMap.put(8, t8);

    List<Integer> t9 = new ArrayList<>();
    t9.add(4);
    tasksMap.put(9, t9);

    tasksMap.put(10, new ArrayList<>());

    //task to analyze:
    int task = 5;


    List<Integer> res11 = getTasksInOrderDftReqPostOrder(tasksMap, task);
    System.out.println(res11);**//note, no reverse required**

    List<Integer> res12 = getTasksInOrderDftReqPreOrder(tasksMap, task);
    Collections.reverse(res12);//note reverse!
    System.out.println(res12);

    private static List<Integer> getTasksInOrderDftReqPreOrder(Map<Integer, List<Integer>> tasksMap, int task) {
         List<Integer> result = new ArrayList<>();
         Set<Integer> visited = new HashSet<>();
         reqPreOrder(tasksMap,task,result, visited);
         return result;
    }

private static void reqPreOrder(Map<Integer, List<Integer>> tasksMap, int task, List<Integer> result, Set<Integer> visited) {

    if(!visited.contains(task)) {
        visited.add(task);
        result.add(task);//pre order!
        List<Integer> children = tasksMap.get(task);
        if (children != null && children.size() > 0) {
            for (Integer child : children) {
                reqPreOrder(tasksMap,child,result, visited);
            }
        }
    }
}

private static List<Integer> getTasksInOrderDftReqPostOrder(Map<Integer, List<Integer>> tasksMap, int task) {
    List<Integer> result = new ArrayList<>();
    Set<Integer> visited = new HashSet<>();
    reqPostOrder(tasksMap,task,result, visited);
    return result;
}

private static void reqPostOrder(Map<Integer, List<Integer>> tasksMap, int task, List<Integer> result, Set<Integer> visited) {
    if(!visited.contains(task)) {
        visited.add(task);
        List<Integer> children = tasksMap.get(task);
        if (children != null && children.size() > 0) {
            for (Integer child : children) {
                reqPostOrder(tasksMap,child,result, visited);
            }
        }
        result.add(task);//post order!
    }
}

再帰的なpost-order-traversalでは、結果を後で反転する必要がないことに注意してください。子供が最初に印刷され、質問のあなたのタスクが最後に印刷されました。すべて順調。再帰的なpre-order-traversal(これも上に示しています)を行うことができ、結果リストの逆転が必要になります。

反復的なアプローチではそれほど単純ではありません!反復(1スタック)アプローチでは、事前順序付けトラバーサルしか実行できないため、最後に結果の配列を逆にする必要があります。

    List<Integer> res1 = getTasksInOrderDftStack(tasksMap, task);
    Collections.reverse(res1);//note reverse!
    System.out.println(res1);

    private static List<Integer> getTasksInOrderDftStack(Map<Integer, List<Integer>> tasksMap, int task) {
    List<Integer> result = new ArrayList<>();
    Set<Integer> visited = new HashSet<>();
    Stack<Integer> st = new Stack<>();


    st.add(task);
    visited.add(task);

    while(!st.isEmpty()){
        Integer node = st.pop();
        List<Integer> children = tasksMap.get(node);
        result.add(node);
        if(children!=null && children.size() > 0){
            for(Integer child:children){
                if(!visited.contains(child)){
                    st.add(child);
                    visited.add(child);
                }
            }
        }
        //If you put it here - it does not matter - it is anyway a pre-order
        //result.add(node);
    }
    return result;
}

シンプルに見えますか?

しかし、それはいくつかのインタビューの罠です。

それは次のことを意味します:再帰的なアプローチでは、深さ優先トラバーサルを実装し、必要な順序を事前または事後的に選択できます(「結果リストに追加する」の場合は、単に「印刷」の場所を変更することによって) )。反復(1スタック)アプローチを使用すると、予約注文のトラバーサルのみを簡単に行うことができるため、子を最初に印刷する必要がある場合(下のノードから上に印刷を開始する必要がある場合のほとんどすべての状況)-トラブル。問題が発生した場合は後で元に戻すことができますが、アルゴリズムに追加されます。そして、インタビュアーが彼の時計を見ている場合、それはあなたにとって問題になるかもしれません。注文後の反復走査を行うには複雑な方法がありますが、それらは存在しますが、単純ません。例:https://www.geeksforgeeks.org/iterative-postorder-traversal-using-stack/

したがって、結論として、インタビューでは再帰を使用します。管理と説明が簡単です。緊急の場合でも、注文前から注文後のトラバーサルに簡単に移動できます。反復を使用すると、それほど柔軟ではありません。

私は再帰を使用して、次のように言います。

再帰のもう1つのプラス-グラフのサイクルを回避/通知する方が簡単です。

例(preudocode):

dft(n){
    mark(n)
    for(child: n.children){
        if(marked(child)) 
            explode - cycle found!!!
        dft(child)
    }
    unmark(n)
}

0

再帰として、または練習として書くのは楽しいかもしれません。

ただし、コードを本番環境で使用する場合は、スタックオーバーフローの可能性を考慮する必要があります。

テール再帰の最適化はスタックオーバーフローを排除できますが、そうすることのトラブルを乗り越えて、環境で最適化することで信頼できることを知っておく必要があります。

アルゴリズムが再帰するたびに、データサイズまたは n削減ですか?

データのサイズを減らすかn、再帰するたびに半分にする場合は、一般に、スタックオーバーフローを心配する必要はありません。たとえば、プログラムがスタックオーバーフローするために深さが4,000レベルまたは10,000レベルである必要がある場合、プログラムがスタックオーバーフローするためにデータサイズは約2 4000である必要があります。この点を考慮すると、最近、最大のストレージデバイスは2 61バイトを保持できます。このようなデバイスが2 61ある場合、2 122データサイズしか処理しません。宇宙のすべての原子を見ている場合、それは2 84未満であると推定されます。宇宙の誕生から140億年前と推定されて以来、宇宙のすべてのデータとその状態をミリ秒ごとに処理する必要がある場合、それは2 153にすぎない可能性があります。したがって、プログラムが2 4000を処理できる場合データの単位またはn、ユニバース内のすべてのデータを処理でき、プログラムはスタックオーバーフローしません。2 4000程度の大きな数値を処理する必要がない場合(4000ビットの整数)のは、通常、スタックオーバーフローを心配する必要はありません。

ただし、データのサイズや減らす場合はn、一定量であなたは再帰たびに、あなたはときに、プログラムがうまく実行時にスタックオーバーフローに実行することができますnです1000が、いくつかの状況では、ときn単になり、20000

したがって、スタックオーバーフローの可能性がある場合は、それを反復的な解決策にしてください。


-1

Haskellのデータ構造を「帰納」によって設計することで、あなたの質問に答えます。これは、再帰に対する一種の「デュアル」です。そして、この二元性がどのように素晴らしいことにつながるかを示します。

単純なツリーのタイプを紹介します。

data Tree a = Branch (Tree a) (Tree a)
            | Leaf a
            deriving (Eq)

この定義は、「ツリーはブランチ(2つのツリーを含む)またはリーフ(データ値を含む)である」と解釈できます。したがって、葉は一種の最小限のケースです。ツリーが葉ではない場合、2つのツリーを含む複合ツリーである必要があります。これらは唯一のケースです。

木を作ってみましょう:

example :: Tree Int
example = Branch (Leaf 1) 
                 (Branch (Leaf 2) 
                         (Leaf 3))

ここで、ツリーの各値に1を追加するとします。これを行うには、次を呼び出します。

addOne :: Tree Int -> Tree Int
addOne (Branch a b) = Branch (addOne a) (addOne b)
addOne (Leaf a)     = Leaf (a + 1)

まず、これは実際には再帰的な定義であることに注意してください。データコンストラクタのBranchとLeafをケースとして使用し(Leafは最小限であり、これらは唯一の可能なケースであるため)、関数は必ず終了します。

addOneを反復的なスタイルで書くには何が必要ですか?任意の数のブランチにループするのはどのようなものですか?

また、この種の再帰は、「ファンクター」の観点から除外されることがよくあります。以下を定義することで、ツリーをファンクタにすることができます。

instance Functor Tree where fmap f (Leaf a)     = Leaf (f a)
                            fmap f (Branch a b) = Branch (fmap f a) (fmap f b)

そして定義:

addOne' = fmap (+1)

代数的データ型のカタモルフィズム(またはフォールド)などの他の再帰スキームを除外できます。カタモフィズムを使用して、次のように書くことができます。

addOne'' = cata go where
           go (Leaf a) = Leaf (a + 1)
           go (Branch a b) = Branch a b

-2

スタックオーバーフローは、メモリ管理が組み込まれていない言語でプログラミングしている場合にのみ発生します。それ以外の場合は、関数(または関数呼び出し、STDLbsなど)に何かがあることを確認してください。再帰がなければ、GoogleやSQLなど、大きなデータ構造(クラス)やデータベースを効率的に並べ替える必要のある場所は存在しません。

再帰は、ファイルを繰り返し処理したい場合に使用する方法です。この方法で確実に確認できます* | ?grep * 'は機能します。特にパイプでのちょっとした再帰(ただし、他の人が使用できるようにする場合は、非常に多くのシステムコールを実行しないでください)。

高水準言語やclang / cppでも、バックグラウンドで同じように実装できます。


1
「スタックオーバーフローは、メモリ管理が組み込まれていない言語でプログラミングしている場合にのみ発生します」-意味がありません。ほとんどの言語は限られたサイズのスタックを使用するので、再帰はすぐに失敗します。
StaceyGirl 2017年
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.