Ich weiß, dass Rekursion manchmal viel sauberer ist als Schleifen, und ich frage nichts darüber, wann ich Rekursion über Iteration verwenden soll. Ich weiß, dass es dazu bereits viele Fragen gibt.
Was ich frage ist, ist Rekursion immer schneller als eine Schleife? Mir scheint, Sie könnten eine Schleife immer verfeinern und schneller ausführen als eine rekursive Funktion, da die Schleife nicht ständig neue Stapelrahmen einrichtet.
Ich suche speziell nach einer schnelleren Rekursion in Anwendungen, in denen die Rekursion der richtige Weg ist, um mit den Daten umzugehen, z. B. in einigen Sortierfunktionen, in Binärbäumen usw.
performance
loops
recursion
iteration
Carson Myers
quelle
quelle
Antworten:
Dies hängt von der verwendeten Sprache ab. Sie haben 'sprachunabhängig' geschrieben, deshalb 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 funktionaler 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änderbar ist) erfordert manchmal relativ schwere Operationen, insbesondere bei Implementierungen, die mehrere Ausführungsthreads unterstützen. 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 einen beliebigen Stil. 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 Baugruppe generiert wird (legen Sie diese in Ihr Rohr und rauchen Sie sie).
Nachtrag: In einigen Umgebungen ist die beste Alternative weder Rekursion noch Iteration, sondern Funktionen höherer Ordnung. Dazu gehören "Map", "Filter" und "Reduce" (auch "Fold" genannt). Dies ist nicht nur der bevorzugte Stil, sondern auch häufig sauberer. In einigen Umgebungen sind diese Funktionen die ersten (oder einzigen), die durch die automatische Parallelisierung einen Schub erhalten. Sie können daher erheblich schneller sein als Iteration oder Rekursion. Data Parallel Haskell ist ein Beispiel für eine solche Umgebung.
Listenverständnisse sind eine weitere Alternative, aber dies sind normalerweise nur syntaktische Zucker für Iterations-, Rekursions- oder Funktionen höherer Ordnung.
quelle
Nein, die Iteration ist immer schneller als die Rekursion. (in einer Von Neumann-Architektur)
Erläuterung:
Wenn Sie die Mindestoperationen eines generischen Computers von Grund auf neu erstellen, steht "Iteration" an erster Stelle als Baustein und ist weniger ressourcenintensiv als "Rekursion". Daher ist ergo schneller.
Eine Pseudo-Computer-Maschine von Grund auf neu bauen:
Frage dich : Was brauchst du, um einen Wert zu berechnen , dh um einem Algorithmus zu folgen und ein Ergebnis zu erzielen?
Wir werden eine Hierarchie von Konzepten erstellen, beginnend bei Null und zunächst die grundlegenden Kernkonzepte definieren und dann mit diesen Konzepte der zweiten Ebene erstellen und so weiter.
Erstes Konzept: Speicherzellen, Speicher, Status . Um etwas zu tun, benötigen Sie Orte zum Speichern der endgültigen und mittleren Ergebniswerte. Nehmen wir an, wir haben ein unendliches Array von "ganzzahligen" Zellen, genannt Speicher , M [0..Infinite].
Anleitung: etwas tun - eine Zelle transformieren, ihren Wert ändern. Zustand ändern . Jede interessante Anweisung führt eine Transformation durch. Grundlegende Anweisungen sind:
a) Speicherzellen setzen und verschieben
b) Logik und Arithmetik
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.
Reihenfolge der Schritte: eine Folge von Anweisungen : dh: zuerst ausführen, danach tun usw. Eine zwingende Folge von Anweisungen. Sogar einzeilige Ausdrücke sind "eine zwingende Folge von Anweisungen". Wenn Sie einen Ausdruck mit einer bestimmten "Bewertungsreihenfolge" haben, haben Sie Schritte . Dies bedeutet, dass selbst ein einzelner zusammengesetzter Ausdruck implizite „Schritte“ und eine implizite lokale Variable enthält (nennen wir ihn „Ergebnis“). z.B:
Der obige Ausdruck impliziert 3 Schritte mit einer impliziten "Ergebnis" -Variablen.
Selbst Infix-Ausdrücke sind daher eine zwingende Folge von Anweisungen , da Sie eine bestimmte Auswertungsreihenfolge haben . 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 "Ergebnis".
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 Pseudo-Rechenmaschine ist der Befehlszeiger Teil des Speichers . (Hinweis: Normalerweise ist der Befehlszeiger ein "spezielles Register" in einem CPU-Kern, aber hier werden wir die Konzepte vereinfachen und davon ausgehen, dass alle Daten (einschließlich Register) Teil von "Speicher" sind.)
Springen - Sobald Sie eine geordnete Anzahl von Schritten und einen Anweisungszeiger haben , können Sie die Anweisung " Speichern " anwenden , um den Wert des Anweisungszeigers selbst zu ändern. Wir werden diese spezielle Verwendung der Speicheranweisung mit einem neuen Namen bezeichnen: 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“.
Unendliche Iteration : Durch Zurückspringen können Sie den Agenten jetzt dazu bringen, eine bestimmte Anzahl von Schritten zu "wiederholen". Zu diesem Zeitpunkt haben wir eine unendliche Iteration.
Bedingt - Bedingte Ausführung von Anweisungen. Mit der "bedingten" Klausel können Sie eine von mehreren Anweisungen basierend auf dem aktuellen Status (der mit einer vorherigen Anweisung festgelegt werden kann) bedingt ausführen.
Richtige Iteration : Mit der Bedingungsklausel können wir nun der Endlosschleife der Rücksprunganweisung entkommen . Wir haben jetzt eine bedingte Schleife und dann die richtige Iteration
Benennen : Geben Sie einem bestimmten Speicherort Namen, der Daten oder einen Schritt enthält . Dies ist nur eine "Bequemlichkeit" zu haben. Wir fügen keine neuen Anweisungen hinzu, indem wir "Namen" für Speicherorte definieren können. "Benennen" ist keine Anweisung für den Agenten, sondern nur eine Annehmlichkeit für uns. Durch die Benennung ist Code (an dieser Stelle) leichter zu lesen und leichter zu ändern.
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 speichern und dann zu dieser Position springen , wenn Sie sie ausführen müssen (Aufruf). Am Ende der Sequenz werden Sie brauchen Rückkehr zum Punkt des Aufrufs Ausführung fortzusetzen. Mit diesem Mechanismus erstellen Sie neue Anweisungen (Unterprogramme), indem Sie Kernanweisungen erstellen .
Implementierung: (keine neuen Konzepte erforderlich)
Problem mit der einstufigen Implementierung: Sie können keine andere Unterroutine von einer Unterroutine aus aufrufen. In diesem Fall überschreiben Sie die Rücksprungadresse (globale Variable), sodass Sie keine Anrufe verschachteln können.
Um eine bessere Implementierung für Unterprogramme zu haben: Sie benötigen einen STACK
Stapel : Sie definieren einen Speicherplatz als "Stapel", Sie können Werte auf dem Stapel "pushen" und auch den letzten "gepussten" Wert "platzen". Um einen Stapel zu implementieren Sie benötigen Stapelzeiger (ähnlich dem Instruction Pointer) , die Punkte auf den tatsächlichen „Kopf“ des Stapels. Wenn Sie einen Wert „pushen“, wird der Stapelzeiger dekrementiert und Sie speichern den Wert. Wenn Sie "pop", erhalten Sie den Wert am tatsächlichen Stapelzeiger und dann wird der Stapelzeiger erhöht.
Unterprogramme Jetzt, da wir einen Stapel haben, können wir geeignete Unterprogramme implementieren, die verschachtelte Aufrufe ermöglichen . Die Implementierung ist ähnlich, aber anstatt den Befehlszeiger an einer vordefinierten Speicherposition zu speichern, "pushen" wir den Wert der IP im Stapel . Am Ende der Unterroutine „platzen“ wir einfach den Wert vom Stapel und springen nach dem ursprünglichen Aufruf effektiv zur Anweisung zurück . Diese Implementierung mit einem „Stapel“ ermöglicht das Aufrufen einer Unterroutine von einer anderen Unterroutine. Mit dieser Implementierung können wir mehrere Abstraktionsebenen erstellen, wenn wir neue Anweisungen als Unterroutinen definieren, indem wir Kernanweisungen oder andere Unterroutinen als Bausteine verwenden.
Rekursion : Was passiert, wenn sich ein Unterprogramm selbst aufruft? Dies wird als "Rekursion" bezeichnet.
Problem: Überschreiben der lokalen Zwischenergebnisse, die eine Unterroutine im Speicher speichern kann. Da Sie dieselben Schritte aufrufen / wiederverwenden, werden die Zwischenergebnisse , wenn sie an vordefinierten Speicherorten (globalen Variablen) gespeichert sind, bei den verschachtelten Aufrufen überschrieben.
Lösung: Um eine Rekursion zu ermöglichen, sollten Unterprogramme lokale Zwischenergebnisse im Stapel speichern. Daher werden die Zwischenergebnisse bei jedem rekursiven Aufruf (direkt oder indirekt) an verschiedenen Speicherorten gespeichert.
...
Nachdem wir die Rekursion erreicht haben, hören wir hier auf.
Fazit:
In einer Von Neumann-Architektur ist "Iteration" eindeutig ein einfacheres / grundlegendes Konzept als "Rekursion" . Wir haben eine Form der "Iteration" auf Ebene 7, während "Rekursion" auf Ebene 14 der Konzepthierarchie ist.
Die Iteration im Maschinencode ist immer schneller, da weniger Anweisungen und damit weniger CPU-Zyklen erforderlich sind.
Welches ist besser"?
Sie sollten "Iteration" verwenden, wenn Sie einfache, sequentielle Datenstrukturen verarbeiten, und überall dort, wo eine "einfache Schleife" ausreicht.
Sie sollten "Rekursion" verwenden, wenn Sie eine rekursive Datenstruktur verarbeiten müssen (ich nenne sie gerne "Fraktale Datenstrukturen") oder wenn die rekursive Lösung deutlich "eleganter" ist.
Tipp : 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 überall rekursive Datenstrukturen , Sie sehen sich jetzt eine an: Teile des DOM, die das unterstützen, was Sie lesen, 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, das Dateien und Verzeichnisse enthält ...
quelle
Die Rekursion kann schneller sein, wenn die Alternative darin besteht, einen Stapel explizit zu verwalten, wie in den von Ihnen erwähnten Sortier- oder Binärbaumalgorithmen.
Ich hatte einen Fall, in dem das Umschreiben eines rekursiven Algorithmus in Java langsamer wurde.
Der richtige Ansatz besteht also darin, es zuerst auf natürlichste Weise zu schreiben, nur zu optimieren, wenn die Profilerstellung zeigt, dass es kritisch ist, und dann die vermeintliche Verbesserung zu messen.
quelle
Die Schwanzrekursion ist so schnell wie eine Schleife. In vielen funktionalen Sprachen ist eine Schwanzrekursion implementiert.
quelle
Überlegen Sie, was für jede Iteration und Rekursion unbedingt zu tun ist.
Sie sehen, dass hier nicht viel Raum für Unterschiede ist.
(Ich gehe davon aus, dass Rekursion ein Tail-Call ist und der Compiler sich dieser Optimierung bewusst ist.)
quelle
Die meisten Antworten hier vergessen den offensichtlichen Schuldigen, warum die Rekursion oft langsamer ist als iterative Lösungen. Es ist mit dem Auf- und Abbau von Stapelrahmen verbunden, 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 verschüttet werden, befinden sie sich im Level 1-Cache. In einem rekursiven Algorithmus werden alle Zwischenzustände der Variablen auf dem Stapel gespeichert, was bedeutet, dass sie viel mehr Speicherverluste verursachen. Dies bedeutet, dass selbst wenn es die gleiche Anzahl von Operationen ausführt, es viele Speicherzugriffe in der Hot-Loop gibt und was diese Sache noch schlimmer macht, diese Speicheroperationen eine miese Wiederverwendungsrate haben, die die Caches weniger effektiv macht.
Rekursive TL; DR-Algorithmen weisen im Allgemeinen ein schlechteres Cache-Verhalten auf als iterative.
quelle
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 gehen. Zuerst die rekursive:
Und hier ist dieselbe Funktion, die mithilfe der Iteration implementiert wurde:
Es ist nicht wichtig, die Details des Codes zu verstehen. Nur das
p
sind Knoten und dasP_FOR_EACH_CHILD
macht das Gehen. In der iterativen Version benötigen wir einen expliziten Stapel,st
auf den Knoten geschoben und dann gepoppt und manipuliert werden.Die rekursive Funktion läuft viel schneller als die iterative. Der Grund ist, dass in letzterem für jedes Element ein
CALL
zu der Funktionst_push
benötigt wird und dann ein anderes zust_pop
.Im ersteren haben Sie nur die Rekursive
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 vom Heap gesichert werden, auf den viel langsamer zugegriffen werden kann.Durch sorgfältige Optimierung wie Inlining
st_push
undst_pop
kann ich ungefähr die Parität mit dem rekursiven Ansatz erreichen. Aber zumindest auf meinem Computer sind die Kosten für den Zugriff auf den Heap-Speicher höher als die Kosten für den rekursiven Aufruf.Diese Diskussion ist jedoch meistens umstritten, da rekursives Baumlaufen nicht korrekt ist . Wenn Sie einen ausreichend großen Baum haben, wird Ihnen der Callstack-Speicherplatz ausgehen, weshalb ein iterativer Algorithmus verwendet werden muss.
quelle
Nein, Rekursion ist im Allgemeinen nicht schneller als eine Schleife in einer realistischen Verwendung, die in beiden Formen praktikable Implementierungen aufweist. Ich meine, sicher, Sie könnten Schleifen codieren, die ewig dauern, aber es gibt bessere Möglichkeiten, dieselbe Schleife zu implementieren, die jede Implementierung desselben Problems durch Rekursion übertreffen könnte.
Sie haben den Nagel auf den Kopf getroffen, was den Grund betrifft. Das Erstellen und Zerstören von Stapelrahmen ist teurer als ein einfacher Sprung.
Beachten Sie jedoch, dass ich sagte, "hat tragfähige Implementierungen in beiden Formen". Für Dinge wie viele Sortieralgorithmen gibt es in der Regel keine sehr praktikable Methode, um sie zu implementieren, bei der eine eigene Version eines Stapels nicht effektiv eingerichtet wird, 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 setzt nicht funktionierende Sprachen voraus, in denen die meisten grundlegenden Datentypen veränderbar sind. Es gilt nicht für funktionale Sprachen.
quelle
Nein, in jedem realistischen System ist das Erstellen eines Stapelrahmens immer teurer als ein INC und ein JMP. Aus diesem Grund wandeln wirklich gute Compiler die Schwanzrekursion automatisch in einen Aufruf desselben Frames um, dh ohne Overhead, sodass Sie die besser lesbare Quellversion und die effizientere kompilierte Version erhalten. Ein wirklich sehr guter Compiler sollte sogar in der Lage sein, normale Rekursion in Schwanzrekursion umzuwandeln, wo dies möglich ist.
quelle
Bei der funktionalen Programmierung geht es mehr um " was " als um " wie ".
Die Sprachimplementierer werden einen Weg finden, die Funktionsweise des Codes darunter zu optimieren, wenn wir nicht versuchen, ihn optimierter zu gestalten, als er sein muss. Die Rekursion kann auch in den Sprachen optimiert werden, die die Tail-Call-Optimierung unterstützen.
Aus Sicht des Programmierers ist die Lesbarkeit und Wartbarkeit wichtiger als die Optimierung. Wiederum ist "vorzeitige Optimierung die Wurzel allen Übels".
quelle
Dies ist eine Vermutung. Im Allgemeinen schlägt die Rekursion bei Problemen mit anständiger Größe wahrscheinlich nicht oft oder nie die Schleife, wenn beide wirklich gute Algorithmen verwenden (ohne Implementierungsschwierigkeiten). Sie kann unterschiedlich sein, wenn sie mit einer Sprache mit Tail-Call-Rekursion (und einem Tail- Rekursionsalgorithmus) verwendet wird und mit Schleifen auch als Teil der Sprache) - die wahrscheinlich sehr ähnlich wären und möglicherweise manchmal sogar eine Rekursion bevorzugen würden.
quelle
Nach der Theorie sind es die gleichen Dinge. Rekursion und Schleife mit derselben O () -Komplexität funktionieren mit derselben theoretischen Geschwindigkeit, aber die tatsächliche Geschwindigkeit hängt natürlich von Sprache, Compiler und Prozessor ab. Beispiel mit Potenz der Zahl kann iterativ mit O (ln (n)) codiert werden:
quelle
O(n)
, aber eines kannx
für alle mal länger dauern als das anderen
.