web-dev-qa-db-ja.com

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

再帰はループよりもクリーンであることが多いことを知っています。繰り返しよりも再帰を使用するタイミングについては何も質問していません。すでに多くの質問があることは知っています。

私が求めているのは、再帰everはループよりも速いですか?私には、ループが常に新しいスタックフレームを設定していないため、ループを洗練し、再帰関数よりも速く実行できるようになります。

私は特に、再帰がデータを処理する正しい方法であるアプリケーション(一部のソート関数、バイナリツリーなど)で再帰が高速であるかどうかを探しています。

261
Carson Myers

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

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

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

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

つまり、答えはコードと実装に依存します。好みのスタイルを使用してください。関数型言語を使用している場合、再帰mightは高速です。命令型言語を使用している場合、反復はおそらくより高速です。環境によっては、両方の方法で同じアセンブリが生成されます(パイプに入れてスモークします)。

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

リスト内包表記も別の選択肢ですが、これらは通常、反復、再帰、または高階関数のための単なる構文糖です。

326
Dietrich Epp

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

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

説明:

汎用コンピューターの最小限の操作をゼロから構築する場合、「反復」が構築ブロックとして最初に登場し、「再帰」よりもリソース集約度が低く、エルゴは高速です。

ゼロからの擬似コンピューティングマシンの構築:

あなた自身の質問:何をする必要がありますかcompute値、つまり、アルゴリズムに従って結果に到達するために?

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

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

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

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

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

    b)論理演算

    • および、または、xor、not
    • add、sub、mul、div例えばadd m [7] m [8]
  3. 実行エージェント:最新のCPUのcore「エージェント」とは、命令を実行できるものです。Agentは、紙上のアルゴリズムに従う人でもあります。

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

    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)
    

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

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

    この疑似計算機では、命令ポインターはMemoryの一部です。 (注:通常、Instruction PointerはCPUコアの「特別なレジスタ」ですが、ここでは概念を簡素化し、すべてのデータ(レジスタを含む)が「メモリ」の一部であると仮定します)

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

  7. 無限反復jumping back、これで、エージェントに一定のステップ数を「繰り返す」ことができます。この時点で、無限反復

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

  9. 適切な反復条件付き句を使用すると、ジャンプバック命令の無限ループをエスケープできます。 条件付きループがあり、その後proper Iteration

    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. 命名:データを保持する特定のメモリ位置に名前を付けるか、stepを保持します。これは単なる「便利さ」です。メモリの場所の「名前」を定義する能力があるため、新しい命令は追加されません。 「命名」はエージェントへの指示ではなく、私たちにとって便利なことです。Namingは、この時点でコードを読みやすくし、変更しやすくします。

       #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レベルのサブルーチン:頻繁に実行する必要がある一連のステップがあるとします。メモリ内の名前付きの位置にステップを保存し、実行する必要がある場合(呼び出し)、jump /にその位置を保存できます。シーケンスの最後で、returncallingのポイントまで実行して、実行を継続する必要があります。このメカニズムを使用すると、新しい命令を作成します(サブルーチン)コア命令を作成します。

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

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

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

    サブルーチンのより良い実装:スタックが必要です

  12. スタック:「スタック」として機能するメモリスペースを定義し、スタックの値を「プッシュ」し、最後の「プッシュ」値を「ポップ」することもできます。スタックを実装するには、スタックの実際の「ヘッド」を指すStack Pointer(Instruction Pointerと同様)が必要です。 。値を「プッシュ」すると、スタックポインターが減少し、値を保存します。 「ポップ」すると、実際のスタックポインターで値が取得され、スタックポインターがインクリメントされます。

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

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

    問題:サブルーチンがメモリに保存できるローカル中間結果を上書きします。同じステップを呼び出し/再利用しているため、if中間結果は事前定義されたメモリ位置(グローバル変数)に保存され、上書きされますネストされた呼び出し。

    Solution:再帰を許可するには、サブルーチンはローカルの中間結果を保存する必要がありますin the stackしたがって、recursive call(直接または間接)ごとに、中間結果は異なるメモリ位置に保存されます。

...

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

結論:

フォンノイマンアーキテクチャでは、明らかに"Iteration"よりも単純/基本的な概念です。 /「再帰」。レベル7では"反復"の形式を持ち、概念のレベル14では"再帰"階層。

Iterationは、命令が少なく、したがってCPUサイクルが少ないため、マシンコードでは常に高速になります。

どっちがいいですか"?

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

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

アドバイス:仕事に最適なツールを使用しますが、賢明に選択するために各ツールの内部動作を理解します。

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

47
Lucio M. Tato

あなたが言及したソートアルゴリズムやバイナリツリーアルゴリズムのように、スタックを明示的に管理することである場合、再帰はより速くなるでしょう。

Javaで再帰アルゴリズムを書き換えると、遅くなる場合がありました。

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

33
starblue

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

12
mkorpela

それぞれ、反復、再帰に対して絶対に何をしなければならないかを考えてください。

  • 繰り返し:ループの先頭へのジャンプ
  • 再帰:呼び出された関数の先頭へのジャンプ

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

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

12
Pasi Savolainen

ここでのほとんどの答えは、反復ソリューションよりも再帰が遅いことが多い理由を明らかにします。これは、スタックフレームの構築および破棄とリンクしていますが、厳密にはそうではありません。一般に、各再帰の自動変数のストレージには大きな違いがあります。ループを使用した反復アルゴリズムでは、変数は多くの場合レジスタに保持され、こぼれたとしてもレベル1キャッシュに存在します。再帰アルゴリズムでは、変数のすべての中間状態がスタックに格納されます。つまり、メモリへの流出がさらに発生します。つまり、同じ量の操作を行っても、ホットループで大量のメモリアクセスが発生し、さらに悪いことに、これらのメモリ操作の再利用率が低くなり、キャッシュの効率が低下します。

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

8

ここでの答えのほとんどはwrongです。正解は依存するです。たとえば、ツリーをたどる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が必要です。その上にノードがプッシュされ、ポップされて操作されます。

再帰関数は、反復関数よりもはるかに高速に実行されます。その理由は、後者では、各項目について、関数st_PushへのCALLが必要であり、次にst_popへの別のものが必要だからです。

前者では、各ノードに再帰的なCALLのみがあります。

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

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

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

6

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

2
Kilian Foth

関数型プログラミングは、「how」ではなく「what」に関するものです。

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

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

1
noego

一般に、いや、両方の形式で実行可能な実装がある現実的な使用法では、再帰はループよりも高速ではありません。つまり、永遠にかかるループをコーディングすることはできますが、同じループを実装するより良い方法は、再帰を介して同じ問題の実装よりも優れている可能性があります。

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

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

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

1
Amber

これは推測です。一般に、両方が本当に良いアルゴリズムを使用している場合(実装の難易度をカウントしない)、適切なサイズの問題で繰り返しはおそらく頻繁にループを打ち負かすことはありません- tail call recursion =(および言語の一部としてのループを伴う末尾再帰アルゴリズム)-これはおそらく非常に類似しており、場合によっては再帰を好むことさえあります。

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;
  }
0