web-dev-qa-db-ger.com

Ist Rekursion jemals schneller als Looping?

Ich weiß, dass Rekursion manchmal viel sauberer ist als Schleifen, und ich frage mich nicht, wann ich Rekursion über Iteration verwenden soll, ich weiß, dass es bereits viele Fragen dazu gibt.

Was ich frage ist, ist Rekursion jemals schneller als eine Schleife? Mir scheint, dass Sie eine Schleife immer verfeinern und schneller ausführen können als eine rekursive Funktion, da die Schleife nicht vorhanden ist und ständig neue Stapelrahmen erstellt.

Ich suche speziell, ob die Rekursion in Anwendungen schneller ist, in denen die Rekursion der richtige Weg ist, um mit den Daten umzugehen, z. B. in einigen Sortierfunktionen, in Binärbäumen usw.

266
Carson Myers

Dies hängt von der verwendeten Sprache ab. Sie haben 'language-agnostic' geschrieben, also gebe ich einige Beispiele.

In Java, C und Python ist die Rekursion im Vergleich zur Iteration (im Allgemeinen) ziemlich teuer, da ein neuer Stapelrahmen zugewiesen werden muss. In einigen C-Compilern kann ein Compiler-Flag verwendet werden, um diesen Overhead zu beseitigen, der bestimmte Arten von Rekursionen (tatsächlich bestimmte Arten von Tail-Aufrufen) in Sprünge anstelle von Funktionsaufrufen umwandelt.

In Implementierungen von funktionalen Programmiersprachen kann die Iteration manchmal sehr teuer und die Rekursion sehr billig sein. In vielen Fällen wird die Rekursion in einen einfachen Sprung umgewandelt, aber das Ändern der Schleifenvariablen (die veränderlich ist) erfordert manchmal einige relativ schwere Operationen, insbesondere bei Implementierungen, bei denen Unterstützung mehrerer Ausführungsthreads. In einigen dieser Umgebungen ist die Mutation aufgrund der Interaktion zwischen dem Mutator und dem Garbage Collector teuer, wenn beide gleichzeitig ausgeführt werden.

Ich weiß, dass in einigen Schema-Implementierungen die Rekursion im Allgemeinen schneller ist als die Schleife.

Kurz gesagt, die Antwort hängt vom Code und der Implementierung ab. Verwenden Sie den Stil, den Sie bevorzugen. Wenn Sie eine funktionale Sprache verwenden, ist die Rekursion möglicherweise schneller . Wenn Sie eine imperative Sprache verwenden, ist die Iteration wahrscheinlich schneller. In einigen Umgebungen führen beide Methoden dazu, dass dieselbe Assembly generiert wird (fügen Sie diese in Ihre Pipe ein und rauchen Sie sie).

Nachtrag: In einigen Umgebungen ist die beste Alternative weder die Rekursion noch die Iteration, sondern Funktionen höherer Ordnung. Dazu gehören "Karte", "Filter" und "Verkleinern" (was auch als "Falten" bezeichnet wird). Dies ist nicht nur der bevorzugte Stil, sondern sie sind auch oft übersichtlicher. In einigen Umgebungen sind diese Funktionen die ersten (oder einzigen), die von der automatischen Parallelisierung profitieren. Sie können daher erheblich schneller als Iteration oder Rekursion sein. Data Parallel Haskell ist ein Beispiel für eine solche Umgebung.

Listenverständnisse sind eine weitere Alternative, bei denen es sich jedoch normalerweise nur um syntaktischen Zucker für Iterations-, Rekursions- oder Funktionen höherer Ordnung handelt.

333
Dietrich Epp

ist Rekursion immer schneller als eine Schleife?

Nein, Die Iteration ist immer schneller als die Rekursion. (in einer Von Neumann-Architektur)

Erläuterung:

Wenn Sie die Mindestvorgänge eines generischen Computers von Grund auf neu erstellen, steht "Iteration" als Baustein an erster Stelle und ist weniger ressourcenintensiv als "Rekursion". Ergo ist dies schneller.

Aufbau einer Pseudocomputer-Maschine von Grund auf:

Frage dich selbst: Was brauchst du, um zu berechnen einen Wert, d. H. Einem Algorithmus zu folgen und ein Ergebnis zu erzielen?

Wir werden eine Hierarchie von Konzepten aufbauen, von Grund auf neu beginnen und an erster Stelle die grundlegenden Kernkonzepte definieren, dann daraus Konzepte der zweiten Ebene erstellen und so weiter.

  1. Erstes Konzept:Speicherzellen, Speicher, Zustand. Um etwas zu tun, benötigen Sie places , um End- und Zwischenergebniswerte zu speichern. Nehmen wir an, wir haben ein unendliches Array von "ganzzahligen" Zellen, genanntMemory, M [0..Infinite].

  2. Anweisungen:etwas tun - eine Zelle transformieren, ihren Wert ändern.alter state. Jede interessante Anweisung führt eine Transformation durch. Grundlegende Anweisungen sind:

    a)Speicherzellen setzen & verschieben

    • speichere einen Wert, z. B .: speichere 5 m [4]
    • kopieren Sie einen Wert an eine andere Position: z. B .: Speichern von m [4] m [8]

    b)Logik und Arithmetik

    • und oder oder nicht
    • addieren, sub, mul, div. z.B. addiere m [7] m [8]
  3. Ein ausführender Agent: ein Kern in einer modernen CPU. Ein "Agent" kann Anweisungen ausführen. Ein Agent kann auch eine Person sein, die dem Algorithmus auf Papier folgt.

  4. Reihenfolge der Schritte: eine Abfolge von Anweisungen: d. H .: mach das zuerst, mach das danach usw. Eine zwingende Abfolge von Anweisungen. Sogar eine Zeile Ausdrücke ist "eine zwingende Folge von Anweisungen". Wenn Sie einen Ausdruck mit einer bestimmten "Auswertungsreihenfolge" haben, haben Sie steps . Dies bedeutet, dass selbst ein einzelner zusammengesetzter Ausdruck implizite "Schritte" und auch eine implizite lokale Variable enthält (nennen wir ihn "Ergebnis"). z.B.:

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

    Der obige Ausdruck impliziert 3 Schritte mit einer impliziten "Ergebnis" -Variable.

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

    Sogar Infix-Ausdrücke sind, da Sie eine bestimmte Reihenfolge der Auswertung haben, eine zwingende Folge von Anweisungen . Der Ausdruck impliziert eine Folge von Operationen, die in einer bestimmten Reihenfolge ausgeführt werden müssen, und da es Schritte gibt, gibt es auch eine implizite Zwischenvariable "result".

  5. Anweisungszeiger: Wenn Sie eine Folge von Schritten haben, haben Sie auch einen impliziten "Anweisungszeiger". Der Befehlszeiger markiert den nächsten Befehl und rückt vor, nachdem der Befehl gelesen wurde, aber bevor der Befehl ausgeführt wird.

    In dieser Pseudocomputer-Maschine ist der Instruction Pointer Teil von Memory . (Hinweis: Normalerweise ist derBefehlszeigerein „Sonderregister“ in einem CPU-Kern, aber hier vereinfachen wir die Konzepte und nehmen an, dass alle Daten (einschließlich Register) Teil von „Speicher“ sind.)

  6. Jump- Sobald Sie eine bestellte Anzahl von Schritten und einen Anweisungszeiger haben, können Sie die Anweisung "store" anwenden, um die zu ändern Wert des Befehlszeigers selbst. Wir werden diese spezielle Verwendung der Speicheranweisung mit einem neuen Namen aufrufen:Jump. Wir verwenden einen neuen Namen, weil es einfacher ist, ihn als neues Konzept zu betrachten. Durch Ändern des Anweisungszeigers weisen wir den Agenten an, „mit Schritt x fortzufahren“.

  7. Infinite Iteration: Durch Zurückspringen, Jetzt können Sie den Agenten eine bestimmte Anzahl von Schritten "wiederholen" lassen. Zu diesem Zeitpunkt haben wirendlose Iteration.

                       1. mov 1000 m[30]
                       2. sub m[30] 1
                       3. jmp-to 2  // infinite loop
    
  8. Conditional- Bedingte Ausführung von Anweisungen. Mit der "conditional" -Klausel können Sie eine von mehreren Anweisungen basierend auf dem aktuellen Status (der mit einer vorherigen Anweisung festgelegt werden kann) bedingt ausführen.

  9. Proper Iteration: Jetzt können wir mit derconditional-Klausel die Endlosschleife derjump back-Anweisung verlassen. Wir haben jetzt einebedingte Schleifeund dannrichtige 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. Naming: Benennen eines bestimmten Speicherorts mit Daten oder einem Schritt . Dies ist nur eine "Annehmlichkeit". Wir fügen keine neuen Anweisungen hinzu, indem wir die Fähigkeit haben, "Namen" für Speicherorte zu definieren. "Benennen" ist keine Anweisung für den Agenten, sondern nur eine Annehmlichkeit für uns. Benennung erleichtert das Lesen und Ändern von Code.

       #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. Einstufiges Unterprogramm: Angenommen, Sie müssen eine Reihe von Schritten häufig ausführen. Sie können die Schritte an einer benannten Position im Speicher ablegen und dann zu dieser Position springen, wenn Sie sie ausführen müssen (Aufruf). Am Ende der Sequenz müssen Sie return zum Punkt von calling zurückgeben, um die Ausführung fortzusetzen. Mit diesem Mechanismus erstellen Sie neue Anweisungen (Unterroutinen), indem Sie Kernanweisungen erstellen.

    Implementierung: (keine neuen Konzepte erforderlich)

    • Speichern Sie den aktuellen Anweisungszeiger an einer vordefinierten Speicherposition
    • springezum Unterprogramm
    • am Ende des Unterprogramms rufen Sie den Anweisungszeiger vom vordefinierten Speicherort ab und springen so effektiv zurück zu der folgenden Anweisung des ursprünglichen call

    Problem mit dereinstufigen-Implementierung: Sie können kein anderes Unterprogramm von einem Unterprogramm aus aufrufen. In diesem Fall wird die zurückgegebene Adresse (globale Variable) überschrieben, sodass Sie Anrufe nicht verschachteln können.

    Um einebessere Implementierung für Unterprogramme zu haben: Sie benötigen einen STACK

  12. Stack: Sie definieren einen Speicherbereich, der als "Stack" fungiert. Sie können Werte auf den Stack "pushen" und den letzten "Push" -Wert "poppen". Um einen Stapel zu implementieren, benötigen Sie einen Stapelzeiger (ähnlich dem Anweisungszeiger), der auf den eigentlichen „Kopf“ des Stapels verweist. Wenn Sie einen Wert „pushen“, wird der Stapelzeiger dekrementiert und Sie speichern den Wert. Wenn Sie "pop" wählen, erhalten Sie den Wert am tatsächlichen Stapelzeiger und dann wird der Stapelzeiger inkrementiert.

  13. UnterprogrammeJetzt, da wir ein Stack haben, können wir geeignete Unterprogramme implementieren geschachtelte Aufrufe zulassen . Die Implementierung ist ähnlich, aber anstatt den Instruction Pointer an einer vordefinierten Speicherposition zu speichern, "pushen" wir den Wert der IP im stack . Am Ende der Unterroutine wird einfach der Wert aus dem Stapel "herausgenommen", und es wird effektiv zur Anweisung nach dem ursprünglichen Aufruf zurückgesprungen. Diese Implementierung mit einem "Stapel" ermöglicht den Aufruf einer Unterroutine von einer anderen Unterroutine. Mit dieser Implementierung können wir mehrere Abstraktionsebenen erstellen, wenn wir neue Anweisungen als Subroutinen definieren, indem wir Kernanweisungen oder andere Subroutinen als Bausteine ​​verwenden.

  14. Rekursion: Was passiert, wenn ein Unterprogramm sich selbst aufruft ?. Dies nennt man "Rekursion".

    Problem: Überschreiben der lokalen Zwischenergebnisse, die ein Unterprogramm speichern kann. Da Sie dieselben Schritte aufrufen/wiederverwenden, werden if das Zwischenergebnis in vordefinierten Speicherorten (globale Variablen) gespeichert und bei verschachtelten Aufrufen überschrieben.

    Lösung: Um eine Rekursion zu ermöglichen, sollten Unterprogramme lokale Zwischenergebnisse im Stapel speichern , daher bei jedem rekursiven Aufruf ( direkt oder indirekt) die Zwischenergebnisse werden an verschiedenen Speicherorten abgelegt.

...

nachdem wir recursion erreicht haben, hören wir hier auf.

Fazit:

In einer Von Neumann-Architektur ist "Iteration" ein einfacheres/Grundkonzept als "Rekursion" Wir haben eine Form von"Iteration"auf Stufe 7, während"Rekursion"auf Stufe 14 der Begriffshierarchie steht.

Iteration ist im Maschinencode immer schneller, da weniger Anweisungen und daher weniger CPU-Zyklen erforderlich sind.

Welches ist besser"?

  • Sie sollten "Iteration" verwenden, wenn Sie einfache, sequentielle Datenstrukturen verarbeiten, und überall ist eine "einfache Schleife" ausreichend.

  • Sie sollten "Rekursion" verwenden, wenn Sie eine rekursive Datenstruktur verarbeiten müssen (ich nenne sie gerne "Fraktale Datenstrukturen") oder wenn die rekursive Lösung eindeutig "eleganter" ist.

Beratung: Verwenden Sie das beste Werkzeug für den Job, aber verstehen Sie das Innenleben jedes Werkzeugs, um mit Bedacht zu wählen.

Beachten Sie schließlich, dass Sie viele Möglichkeiten haben, die Rekursion zu verwenden. Sie haben Rekursive Datenstrukturen überall sehen Sie jetzt eine: Teile des DOM, die das, was Sie lesen, unterstützen, sind ein RDS, ein JSON-Ausdruck ist ein RDS, das hierarchische Dateisystem in Ihrem Computer ist ein RDS, dh: Sie haben ein Stammverzeichnis, das Dateien und Verzeichnisse enthält, jedes Verzeichnis, das Dateien und Verzeichnisse enthält, jedes dieser Verzeichnisse, die Dateien und Verzeichnisse enthalten ...

47
Lucio M. Tato

Die Rekursion ist möglicherweise schneller, wenn die Alternative darin besteht, einen Stapel explizit zu verwalten, wie dies bei den von Ihnen erwähnten Sortier- oder Binärbaumalgorithmen der Fall ist.

Ich hatte einen Fall, in dem ein rekursiver Algorithmus in Java langsamer gemacht wurde.

Der richtige Ansatz besteht also darin, es zunächst auf natürliche Weise zu schreiben, es nur zu optimieren, wenn die Profilerstellung zeigt, dass es wichtig ist, und dann die vermeintliche Verbesserung zu messen.

34
starblue

Schwanzrekursion ist so schnell wie eine Schleife. In vielen funktionalen Sprachen ist die Schwanzrekursion implementiert.

12
mkorpela

Überlegen Sie, was unbedingt für jede Iteration und Rekursion getan werden muss.

  • iteration: Ein Sprung zum Schleifenanfang
  • rekursion: Ein Sprung zum Anfang der aufgerufenen Funktion

Sie sehen, dass hier nicht viel Raum für Unterschiede ist.

(Ich gehe davon aus, dass die Rekursion ein Tail-Call ist und der Compiler diese Optimierung kennt.).

12
Pasi Savolainen

Die meisten Antworten hier vergessen den offensichtlichen Grund, warum die Rekursion oft langsamer ist als iterative Lösungen. Es hängt mit dem Auf- und Abbau von Stapelrahmen zusammen, ist aber nicht genau das. Es ist im Allgemeinen ein großer Unterschied in der Speicherung der automatischen Variablen für jede Rekursion. In einem iterativen Algorithmus mit einer Schleife werden die Variablen häufig in Registern gespeichert, und selbst wenn sie überlaufen, befinden sie sich im Level 1-Cache. In einem rekursiven Algorithmus werden alle Zwischenzustände der Variablen auf dem Stapel gespeichert, was bedeutet, dass viel mehr Daten in den Speicher gelangen. Dies bedeutet, dass selbst bei der gleichen Anzahl von Vorgängen in der Hot-Loop viele Speicherzugriffe ausgeführt werden und dass diese Speichervorgänge eine miese Wiederverwendungsrate aufweisen, wodurch die Caches weniger effektiv werden.

Rekursive TL; DR-Algorithmen haben im Allgemeinen ein schlechteres Cache-Verhalten als iterative.

8

Die meisten Antworten hier sind falsch. Die richtige Antwort ist es kommt darauf an. Hier sind zum Beispiel zwei C-Funktionen, die durch einen Baum laufen. Zuerst die rekursive:

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

Und hier ist die gleiche Funktion mit Iteration implementiert:

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

Es ist nicht wichtig, die Details des Codes zu verstehen. Nur dass p Knoten sind und dass P_FOR_EACH_CHILD macht das Gehen. In der iterativen Version benötigen wir einen expliziten Stapel st, auf den Knoten gepusht und dann gepoppt und manipuliert werden.

Die rekursive Funktion läuft viel schneller als die iterative. Der Grund dafür ist, dass in letzterem für jedes Element ein CALL für die Funktion st_Push wird benötigt und dann noch einer zu st_pop.

Im ersten Fall haben Sie nur den rekursiven CALL für jeden Knoten.

Außerdem ist der Zugriff auf Variablen im Callstack unglaublich schnell. Dies bedeutet, dass Sie aus dem Speicher lesen, der sich wahrscheinlich immer im innersten Cache befindet. Ein expliziter Stapel muss dagegen durch malloc: ed-Speicher aus dem Heap gesichert werden, auf den viel langsamer zugegriffen werden kann.

Mit sorgfältiger Optimierung, wie Inlining st_Push und st_pop, Ich kann mit dem rekursiven Ansatz ungefähr eine Parität erreichen. Aber zumindest auf meinem Computer sind die Kosten für den Zugriff auf den Heapspeicher höher als die Kosten für den rekursiven Aufruf.

Aber diese Diskussion ist meistens umstritten, weil rekursives Baumlaufen falsch ist. Wenn Sie einen Baum haben, der groß genug ist, wird Ihnen der Callstack-Speicherplatz ausgehen, weshalb ein iterativer Algorithmus verwendet werden muss.

6

Nein, in jedem realistischen System ist das Erstellen eines Stack-Frames immer teurer als ein INC und ein JMP. Das ist der Grund, warum wirklich gute Compiler die Endrekursion automatisch in einen Aufruf desselben Frames umwandeln, d. H. Ohne den Overhead, sodass Sie die lesbarere Quellversion und die effizientere kompilierte Version erhalten. Ein wirklich, wirklich guter Compiler sollte sogar in der Lage sein, normale Rekursion in Endrekursion umzuwandeln, wo dies möglich ist.

2
Kilian Foth

Bei der funktionalen Programmierung geht es eher um "was" als um "wie".

Die Sprachimplementierer werden einen Weg finden, um die Funktionsweise des Codes darunter zu optimieren, wenn wir nicht versuchen, ihn weiter zu optimieren, als es nötig ist. Die Rekursion kann auch in den Sprachen optimiert werden, die die Tail-Call-Optimierung unterstützen.

Was vom Standpunkt des Programmierers aus mehr zählt, ist die Lesbarkeit und Wartbarkeit und nicht in erster Linie die Optimierung. Auch hier ist "vorzeitige Optimierung die Wurzel allen Übels".

1
noego

Im Allgemeinen ist die Rekursion nicht schneller als eine Schleife in einer realistischen Verwendung, die in beiden Formen realisierbar ist. Ich meine, klar, Sie könnten Schleifen programmieren, die ewig dauern, aber es gäbe bessere Möglichkeiten, dieselbe Schleife zu implementieren, die eine Implementierung desselben Problems durch Rekursion übertreffen könnte.

Sie treffen den Nagel auf den Kopf in Bezug auf den Grund; Das Erstellen und Zerstören von Stack-Frames ist teurer als ein einfacher Sprung.

Beachten Sie jedoch, dass ich sagte "hat praktikable Implementierungen in beiden Formen". Für Dinge wie viele Sortieralgorithmen gibt es in der Regel keinen sehr praktikablen Weg, um sie zu implementieren, der keine eigene Version eines Stacks erstellt, da untergeordnete "Aufgaben" erzeugt werden, die von Natur aus Teil des Prozesses sind. Daher kann die Rekursion genauso schnell sein wie der Versuch, den Algorithmus über eine Schleife zu implementieren.

Bearbeiten: Diese Antwort geht von nicht funktionalen Sprachen aus, bei denen die meisten grundlegenden Datentypen veränderbar sind. Es gilt nicht für funktionale Sprachen.

1
Amber

Dies ist eine Vermutung. Im Allgemeinen schlägt die Rekursion das Schleifen bei Problemen mit anständiger Größe wahrscheinlich nicht oft oder nie, wenn beide wirklich gute Algorithmen verwenden (ohne Berücksichtigung der Implementierungsschwierigkeiten). Bei Verwendung einer Sprache mit Schwanzaufruf-Rekursion kann dies unterschiedlich sein = (und ein rekursiver Tail-Algorithmus und mit Schleifen auch als Teil der Sprache) - die wahrscheinlich sehr ähnlich sind und möglicherweise manchmal sogar eine Rekursion vorziehen.

0

Theoretisch ist es dasselbe. Rekursion und Schleife mit der gleichen O() Komplexität funktionieren mit der gleichen theoretischen Geschwindigkeit, aber die tatsächliche Geschwindigkeit hängt natürlich von Sprache, Compiler und Prozessor ab. Beispiel mit Potenz der Zahl kann in Iteration codiert werden Weg mit 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