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


286

再帰はループよりもクリーンな場合があることを知っています。反復に対して再帰をいつ使用するべきかについては何も質問していません。これについては多くの疑問がすでにあることを知っています。

私が求めていることは、ある再帰で、これまでより高速ループよりも?私には、ループが常に新しいスタックフレームをセットアップすることがないため、ループを改良して再帰関数よりも速く実行できるように思えます。

一部の並べ替え関数やバイナリツリーなど、再帰がデータを処理する正しい方法であるアプリケーションで、再帰がより高速であるかどうかを具体的に探しています。


3
場合によっては、反復手順または一部の再発の閉じた形式の式が現れるまでに何世紀もかかることがあります。そのときだけ、再帰の方が速いと思います:) lol
Pratik Deoghare

24
自分のために言えば、私は反復を非常に好みます。;-)
イテレータ



@PratikDeoghareいいえ、問題は完全に異なるアルゴリズムを選択することではありません。再帰関数は、ループを使用する同じように機能するメソッドにいつでも変換できます。たとえば、この回答には、再帰形式とループ形式の両方で同じアルゴリズムがあります。一般に、再帰関数の引数のタプルをスタックに入れ、スタックにプッシュして呼び出し、スタックから破棄して関数から戻ります。
TamaMcGlinn

回答:


356

これは、使用されている言語によって異なります。あなたは「言語にとらわれない」を書いたので、いくつか例を挙げましょう。

Java、C、Pythonでは、再帰は新しいスタックフレームの割り当てを必要とするため、反復(一般的に)に比べてかなり高価です。一部のCコンパイラでは、コンパイラフラグを使用してこのオーバーヘッドを排除できます。これにより、特定の種類の再帰(実際には特定の種類の末尾呼び出し)が関数呼び出しではなくジャンプに変換されます。

関数型プログラミング言語の実装では、反復が非常に高価になり、再帰が非常に安くなる場合があります。多くの場合、再帰は単純なジャンプに変換されますが、ループ変数(変更可能)を変更することがありますすると、特に複数の実行スレッドをサポートする実装では、比較的重い操作が必要になるがあります。これらの環境の一部では、ミューテーターとガベージコレクターが同時に実行される可能性がある場合、ミューテーターとガベージコレクターの間の相互作用のために、ミューテーションは高価です。

いくつかのScheme実装では、再帰は一般的にループよりも速いことを知っています。

要するに、答えはコードと実装に依存します。好きなスタイルを使いましょう。関数型言語を使用している場合は、再帰高速になる可能性があります。命令型言語を使用している場合、反復はおそらくより高速です。一部の環境では、両方の方法で同じアセンブリが生成されます(パイプに入れて喫煙します)。

補遺:一部の環境では、最適な代替手段は再帰でも反復でもなく、高次関数です。これらには、「map」、「filter」、および「reduce」(「fold」とも呼ばれます)が含まれます。これらは好ましいスタイルであるだけでなく、よりクリーンであることが多いだけでなく、一部の環境では、これらの関数が自動並列化から最初に(または唯一)向上するため、反復または再帰よりも大幅に高速になる場合があります。Data Parallel Haskellはそのような環境の例です。

リスト内包表記は別の代替手段ですが、これらは通常、反復、再帰、またはより高次の関数の単なる構文糖衣です。


48
私はそれを+1し、 "再帰"と "ループ"は人間がコードに付けた名前だとコメントしたいと思います。パフォーマンスに重要なのは、名前の付け方ではなく、コンパイル/解釈の方法です。再帰は、定義上、数学的な概念であり、スタックフレームやアセンブリに関するものとはほとんど関係ありません。
Pは2010

1
また、再帰は一般に、関数型言語ではより自然なアプローチであり、反復は通常、命令型言語ではより直感的です。パフォーマンスの違いが目立つことはほとんどないので、その特定の言語にとってより自然に感じられるものを使用してください。たとえば、再帰がはるかに単純な場合は、Haskellで反復を使用したくないでしょう。
Sasha Chedygov 2010

4
通常、再帰はループにコンパイルされます。ループは下位レベルの構成です。どうして?再帰は通常、一部のデータ構造で十分に確立されているため、初期F代数を誘導し、(再帰的な)計算の構造に関する帰納的引数とともに、終了に関するいくつかのプロパティを証明できます。再帰をコンパイルしてループにするプロセスは、末尾呼び出しの最適化です。
Kristopher Micinski 2012

最も重要なのは、実行されない操作です。「IO」が多いほど、処理が必要になります。データを最初に処理する必要がないため、データの非IOing(別名、インデックス付け)は常にどのシステムにとっても最大のパフォーマンス向上になります。
ジェフフィッシャー

53

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

いいえ、反復は常に再帰より高速です。(フォンノイマンアーキテクチャ)

説明:

汎用コンピューターの最小限の操作を最初から構築する場合、「反復」が最初のビルディングブロックであり、「再帰」よりもリソースの消費が少ないため、ergoの方が高速です。

疑似コンピューティングマシンをゼロから構築する:

あなた自身に質問してください:あなたは何をする必要がありますか計算するために、すなわち、アルゴリズムに従い、結果に到達するには?

最初からコンセプトの階層を確立し、最初に基本的なコアコンセプトを定義してから、それらを使用して第2レベルのコンセプトを構築します。

  1. 最初のコンセプト:メモリセル、ストレージ、状態。何かを行うには、最終結果と中間結果の値を格納する場所が必要です。メモリと呼ばれる「整数」セルの無限配列があるとしましょう、M [0..Infinite]。

  2. 手順:何かを行う-セルを変換し、その値を変更します。状態を変更します。すべての興味深い命令が変換を実行します。基本的な手順は次のとおりです。

    a) メモリセルの設定と移動

    • 値をメモリに保存します。例: store 5 m [4]
    • 値を別の位置にコピーします。例:store m [4] m [8]

    b) 論理演算

    • および、または、xor、not
    • add、sub、mul、div。例:m [7] m [8]を追加
  3. 実行エージェント:最新のCPUのコア。「エージェント」は、命令を実行できるものです。アンエージェントはまた、紙の上のアルゴリズムを以下の人ですることができます。

  4. ステップの順序:一連の命令:つまり、最初にこれを実行し、その後にこれを実行するなど。命令の命令シーケンス。1行の式でも「命令の命令シーケンス」です。特定の「評価順序」を持つ式がある場合は、ステップがあります。これは、単一の合成式でも暗黙の「ステップ」があり、暗黙のローカル変数(「結果」と呼ぶ)があることを意味します。例えば:

    4 + 3 * 2 - 5
    (- (+ (* 3 2) 4 ) 5)
    (sub (add (mul 3 2) 4 ) 5)  
    

    上記の式は、暗黙の「結果」変数を持つ3つのステップを意味します。

    // pseudocode
    
           1. result = (mul 3 2)
           2. result = (add 4 result)
           3. result = (sub result 5)
    

    したがって、中置式でさえ、特定の評価順序があるため、命令の命令シーケンスになります。この式は、特定の順序で行われる一連の操作を意味しstepsがあるため、暗黙的な「結果」中間変数もあります。

  5. 指示ポインタ:一連のステップがある場合は、暗黙の「命令ポインタ」もあります。命令ポインタは次の命令をマークし、命令が読み取られた後、命令が実行される前に進みます。

    この疑似コンピューティングマシンでは、命令ポインタはメモリの一部です。(注:通常、命令ポインターはCPUコアの「特殊レジスター」になりますが、ここでは概念を簡略化し、すべてのデータ(レジスターを含む)が「メモリー」の一部であると想定します)

  6. ジャンプ -注文したステップ数と命令ポインタを取得したら、「ストア」命令を適用して、命令ポインタ自体の値を変更できます。このストア命令の特定の使用を、新しい名前Jumpと呼びます。新しい概念として考えるのがより簡単だから、新しい名前を使用します。指示ポインタを変更することで、エージェントに「ステップxに進む」ように指示しています。

  7. 無限反復さかのぼって、エージェントを特定の数のステップで「繰り返す」ことができます。この時点で、無限の反復があります。

                       1. mov 1000 m[30]
                       2. sub m[30] 1
                       3. jmp-to 2  // infinite loop
    
  8. 条件付き -命令の条件付き実行。「条件付き」句を使用すると、現在の状態(前の命令で設定できる)に基づいて、いくつかの命令の1つを条件付きで実行できます。

  9. 適切な反復条件句を使用すると、ジャンプバック命令の無限ループを回避できます。これで条件付きループが作成され、適切な反復処理が行われました

    1. mov 1000 m[30]
    2. sub m[30] 1
    3. (if not-zero) jump 2  // jump only if the previous 
                            // sub instruction did not result in 0
    
    // this loop will be repeated 1000 times
    // here we have proper ***iteration***, a conditional loop.
    
  10. 命名:データを保持するか、ステップを保持する特定のメモリ位置に名前を付けます。これは単なる「便利さ」です。メモリの場所の「名前」を定義する機能を持つことで、新しい命令を追加することはありません。「命名」はエージェントへの指示ではなく、単に私たちにとって便利です。名前を付けると、コードが(この時点で)読みやすくなり、変更しやすくなります。

       #define counter m[30]   // name a memory location
       mov 1000 counter
    loop:                      // name a instruction pointer location
        sub counter 1
        (if not-zero) jmp-to loop  
    
  11. 1レベルのサブルーチン:頻繁に実行する必要がある一連のステップがあるとします。ステップをメモリ内の指定された位置に保存し、実行する必要があるときその位置にジャンプすることができます(呼び出し)。シーケンスの最後で、実行を続けるには呼び出しポイントに戻る必要があります。このメカニズムで、新しい指示を作成していますコア命令を(サブルーチン)を作成します。

    実装:(新しい概念は不要)

    • 現在の命令ポインタを事前定義されたメモリ位置に保存します
    • ジャンプサブルーチンに
    • サブルーチンの最後で、事前定義されたメモリ位置から命令ポインタを取得し、元の呼び出しの次の命令に効果的にジャンプします

    1レベル実装の問題:サブルーチンから別のサブルーチンを呼び出すことはできません。その場合、返されるアドレス(グローバル変数)が上書きされるため、呼び出しをネストすることはできません。

    持っているサブルーチンのためのより良い実装を:あなたはSTACKを必要とします

  12. スタック:「スタック」として機能するメモリ空間を定義し、スタックに値を「プッシュ」したり、最後の「プッシュ」した値を「ポップ」したりできます。スタックを実装するには、スタックの実際の「先頭」を指すスタックポインター(命令ポインターと同様)が必要です。値を「プッシュ」すると、スタックポインタが減少し、値を格納します。「ポップ」すると、実際のスタックポインターの値が取得され、スタックポインターがインクリメントされます。

  13. サブルーチンこれでスタックができたので、ネストされた呼び出しを可能にする適切なサブルーチン実装できます。実装は似ていますが、事前定義されたメモリ位置に命令ポインタを格納する代わりに、スタックの IPの値を「プッシュ」します。サブルーチンの最後では、スタックから値を「ポップ」するだけで、元の呼び出しの後に命令に効果的にジャンプします。「スタック」を持つこの実装では、別のサブルーチンからサブルーチンを呼び出すことができます。この実装では、コア命令または他のサブルーチンをビルディングブロックとして使用することにより、新しい命令をサブルーチンとして定義するときに、いくつかのレベルの抽象化を作成できます。

  14. 再帰:サブルーチンが自分自身を呼び出すとどうなりますか?これは「再帰」と呼ばれます。

    問題:ローカルの中間結果を上書きすると、サブルーチンがメモリに格納される可能性があります。同じ手順を呼び出し/再利用しているため、中間結果が事前定義されたメモリ位置(グローバル変数)に保存されている場合、ネストされた呼び出しで上書きされます。

    解決策:再帰を可能にするために、サブルーチンはローカルの中間結果をスタックに格納する必要があります。したがって、再帰呼び出し(直接または間接)ごとに、中間結果は異なるメモリ位置に格納されます。

...

再帰に達したら、ここで停止します。

結論:

フォンノイマンアーキテクチャでは、「反復」「再帰」よりも単純で基本的な概念であることは明らかです。レベル7には「反復」という形式があり、「再帰」はは概念階層のレベル14にあります。

反復命令はより少ない命令を意味し、したがってより少ないCPUサイクルを意味するので、は常にマシンコードでより速くなります。

どちらがいいですか"?

  • 単純なシーケンシャルデータ構造を処理する場合、および「単純なループ」が行うすべての場所で、「反復」を使用する必要があります。

  • 再帰的なデータ構造を処理する必要がある場合(「フラクタルデータ構造」と呼びます)、または再帰的なソリューションの方が明らかに「エレガント」な場合は、「再帰」を使用する必要があります。

アドバイス:仕事に最適なツールを使用しますが、賢く選択するために各ツールの内部の仕組みを理解してください。

最後に、再帰を使用する機会がたくさんあることに注意してください。あなたはどこにでも再帰的データ構造を持っています、あなたは今それを見ています:あなたが読んでいるものをサポートするDOMの部分はRDSであり、JSON式はRDSです、あなたのコンピュータの階層ファイルシステムはRDSです、すなわちファイルとディレクトリを含むルートディレクトリ、ファイルとディレクトリを含むすべてのディレクトリ、ファイルとディレクトリを含むすべてのディレクトリ...


2
あなたは、あなたの進行が1)必要であり、2)あなたがやったところでそれが止まると想定しています。しかし、1)それは必要ではありません(たとえば、受け入れられた回答で説明されているように、再帰をジャンプに変えることができるため、スタックは必要ありません)、および2)ここで停止する必要はありません(たとえば、最終的には2番目のステップで導入したように変更可能な状態がある場合、ロックが必要になる可能性がある並行処理に到達するため、すべてが遅くなりますが、機能的/再帰的ソリューションのような不変のソリューションはロックを回避するため、より高速/より並列になる可能性があります) 。
hmijailは、2017

2
「再帰をジャンプに変えることができる」は誤りです。本当に役立つ再帰をジャンプに変えることはできません。末尾呼び出しの「再帰」は特殊なケースで、コンパイラーによってループに単純化できる「再帰として」何かをコーディングします。また、「不変」と「再帰」を融合させています。これらは直交する概念です。
Lucio M. Tato 2017

「本当に役立つ再帰をジャンプに変えることはできません」->末尾呼び出しの最適化はどういうわけか役に立たないのですか?また、不変と再帰は直交するかもしれませんが、ループループを可変カウンターとリンクします。ステップ9を見てください。ループと再帰は根本的に異なる概念だと思っているようです。そうではありません。stackoverflow.com/questions/2651112/…–
hmijailが辞任を悼む'30

@hmijail「役に立つ」より良い言葉は「本当」だと思います。末尾再帰は、関数呼び出し構文を使用して無条件分岐、つまり反復を偽装しているため、真の再帰ではありません。真の再帰は、バックトラックスタックを提供します。ただし、末尾再帰はまだ表現力があるため、便利です。コードの正確性を分析するのを容易または容易にする再帰のプロパティは、末尾呼び出しを使用して表現されるときに、反復コードに付与されます。それは時々余分なパラメータのようなテールバージョンの余分な複雑さによってわずかに相殺されますが。
Kaz

34

再帰は、あなたが言及したソートまたはバイナリツリーアルゴリズムのように、スタックが明示的に管理されている場合、より高速になる可能性があります。

Javaで再帰的アルゴリズムを書き換えると速度が低下する場合がありました。

したがって、適切なアプローチは、最初に最も自然な方法で記述し、プロファイリングがそれが重要であると示した場合にのみ最適化し、次に想定される改善を測定することです。


2
最初にそれを最も自然な方法で書く」ための+1 、特に「プロファイリングが重要であることが示された場合にのみ最適化する
TripeHound

2
+1は、ハードウェアスタックがソフトウェア、手動で実装されたヒープ内スタックよりも高速である可能性があることを認めます。すべての「いいえ」の回答が正しくないことを効果的に示す。
sh1

12

末尾再帰はループと同じくらい高速です。多くの関数型言語には、末尾再帰が実装されています。


35
末尾呼び出しの最適化が実装されている場合、末尾再帰ループ同じくらい高速になります。c2.com
Joachim Sauer

12

反復と再帰のそれぞれについて、絶対に何をしなければならないかを検討してください。

  • 反復:ループの先頭へのジャンプ
  • 再帰:呼び出された関数の先頭へのジャンプ

ここに違いの余地はあまりないことがわかります。

(再帰は末尾呼び出しであり、コンパイラーはその最適化を認識していると想定しています)。


9

ここでのほとんどの答えは、再帰が反復的なソリューションよりも遅いことが多いという明らかな原因を忘れています。スタックフレームのビルドアップとティアダウンに関連していますが、それは正確ではありません。一般に、再帰ごとの自動変数の格納には大きな違いがあります。ループのある反復アルゴリズムでは、変数はレジスターに保持されることが多く、流出した場合でも、レベル1キャッシュに常駐します。再帰的アルゴリズムでは、変数のすべての中間状態がスタックに格納されます。つまり、メモリにさらに多くのスピルが発生します。つまり、同じ量の操作を行ったとしても、ホットループで大量のメモリアクセスが発生し、さらに悪いことに、これらのメモリ操作は再利用率が低く、キャッシュの効果が低くなります。

TL; DR再帰アルゴリズムは、一般に、反復アルゴリズムよりもキャッシュの動作が劣ります。


6

ここでの答えのほとんどは間違っています。正解は、状況によって異なります。たとえば、ツリーをウォークスルーする2つのC関数を次に示します。最初に再帰的なもの:

static
void mm_scan_black(mm_rc *m, ptr p) {
    SET_COL(p, COL_BLACK);
    P_FOR_EACH_CHILD(p, {
        INC_RC(p_child);
        if (GET_COL(p_child) != COL_BLACK) {
            mm_scan_black(m, p_child);
        }
    });
}

そして、これは反復を使用して実装された同じ関数です:

static
void mm_scan_black(mm_rc *m, ptr p) {
    stack *st = m->black_stack;
    SET_COL(p, COL_BLACK);
    st_push(st, p);
    while (st->used != 0) {
        p = st_pop(st);
        P_FOR_EACH_CHILD(p, {
            INC_RC(p_child);
            if (GET_COL(p_child) != COL_BLACK) {
                SET_COL(p_child, COL_BLACK);
                st_push(st, p_child);
            }
        });
    }
}

コードの詳細を理解することは重要ではありません。それpがノードであり、それP_FOR_EACH_CHILDがウォーキングを行います。反復バージョンでは、stノードがプッシュされ、ポップされて操作される明示的なスタックが必要です。

再帰関数は、反復関数よりもはるかに高速に実行されます。その理由は、後者では、各アイテムCALLに対して、関数へのa st_pushが必要であり、次に別のへの必要があるためですst_pop

前者では、CALL各ノードの再帰のみが可能です。

さらに、コールスタック上の変数へのアクセスは非常に高速です。これは、常に最も内側のキャッシュにある可能性が高いメモリから読み取ることを意味します。一方、明示的なスタックは、mallocアクセスがはるかに遅いヒープから:edメモリーにバッキングする必要があります。

インライン化st_pushやなどの慎重な最適化によりst_pop、再帰的アプローチとほぼ同等に到達できます。しかし、少なくとも私のコンピューターでは、ヒープメモリにアクセスするコストは再帰呼び出しのコストよりも大きくなっています。

ただし、再帰的なツリーウォーキングは正しくないため、この説明はほとんど意味がありません。ツリーが十分に大きい場合は、コールスタックスペースが不足するため、反復アルゴリズムを使用する必要があります。


私は同様の状況に遭遇したこと、およびヒープ上の手動スタックよりも再帰の方が速い状況があることを確認できます。特に、関数呼び出しのオーバーヘッドの一部を回避するためにコンパイラーで最適化がオンになっている場合。
while1fork 2017年

1
7ノードのバイナリツリーの予約注文トラバーサルを10 ^ 8回行いました。再帰25ns。明示的なスタック(バウンドチェックされているかどうか-大きな違いはありません)〜15ns。再帰は、単に押したりジャンプしたりするだけでなく、さらに多くのこと(レジスタの保存と復元+(通常はより厳密なフレーム配置))を行う必要があります。(そして、動的にリンクされたライブラリのPLTを使用するとさらに悪化します。)明示的なスタックをヒープに割り当てる必要はありません。最初のフレームが通常の呼び出しスタックにあるobstackを実行できるため、最初のブロックを超えない最も一般的なケースでキャッシュの局所性を犠牲にすることはありません。
PSkocik

3

一般的に、いいえ、再帰は、両方の形式で実行可能な実装を持つ現実的な使用法のループよりも速くはありません。つまり、無限にかかるループをコード化することはできますが、再帰によって同じ問題の実装よりも優れた同じループを実装するより良い方法があるでしょう。

あなたは理由について頭に釘を打ちました。スタックフレームの作成と破棄は、単純なジャンプよりもコストがかかります。

ただし、「両方の形式で実行可能な実装がある」と言ったことに注意してください。多くの並べ替えアルゴリズムのようなものについては、本質的にプロセスの一部である子「タスク」の生成のため、スタックの独自のバージョンを効果的にセットアップしない、それらを実装する非常に実行可能な方法がない傾向があります。したがって、再帰は、ループを介してアルゴリズムを実装しようとするのと同じくらい高速です。

編集:この回答は、ほとんどの基本的なデータ型が変更可能な非関数型言語を想定しています。関数型言語には適用されません。


そのため、再帰が頻繁に使用される言語のコンパイラでは、再帰のいくつかのケースが最適化されることがよくあります。たとえばF#では、.tailオペコードを使用して再帰関数の末尾を完全にサポートすることに加えて、再帰関数がループとしてコンパイルされていることがよくあります。
em70

うん。尾の再帰は、両方の世界で最高の場合があります。再帰的なタスクを実装するための機能的に「適切な」方法と、ループを使用するパフォーマンスです。
アンバー

1
これは一般に正しくありません。一部の環境では、ミューテーション(GCと相互作用)は、末尾再帰よりもコストがかかります。これは、余分なスタックフレームを使用しない出力で、より単純なループに変換されます。
ディートリッヒエップ2010

2

現実的なシステムでは、いいえ、スタックフレームの作成は、INCおよびJMPよりも常にコストがかかります。そのため、本当に優れたコンパイラは、末尾再帰を自動的に同じフレームへの呼び出しに変換します。つまり、オーバーヘッドなしで、より読みやすいソースバージョンとより効率的なコンパイルバージョンを取得します。A 本当に、本当に良いコンパイラでもそれが可能である末尾再帰に通常の再帰を変換することができるはずです。


1

関数型プログラミングは、「どのように」ではなく「何を」に重点を置いています

言語の実装者は、コードを必要以上に最適化しようとしない限り、コードがその下でどのように機能するかを最適化する方法を見つけるでしょう。再帰は、末尾呼び出しの最適化をサポートする言語内でも最適化できます。

プログラマーの観点からさらに重要なのは、そもそも最適化というよりも可読性と保守性です。繰り返しますが、「時期尚早の最適化はすべての悪の根源です」。


0

これは推測です。一般的に再帰は、両方が本当に良いアルゴリズムを使用している場合(実装の難易度を数えない場合)、まともなサイズの問題でループを頻繁に打つことはありませんが、末尾呼び出し再帰(および末尾再帰アルゴリズム)を使用する言語で使用する場合は異なる場合がありますループも言語の一部として)-これはおそらく非常によく似ていて、場合によっては再帰を好むこともあります。


0

理論によれば、同じことです。同じO()複雑度の再帰とループは、同じ理論上の速度で動作しますが、実際の速度は言語、コンパイラ、およびプロセッサに依存します。数値のべき乗の例は、O(ln(n))を使用して反復的にコーディングできます。

  int power(int t, int k) {
  int res = 1;
  while (k) {
    if (k & 1) res *= t;
    t *= t;
    k >>= 1;
  }
  return res;
  }

1
Big Oは「比例」です。したがって、どちらもですがO(n)、一方の方が他方よりも時間かかる場合がありxますn
ctrl-alt-delor 2015年
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.