Frage
Wie kann ein durch einen rekursiven Algorithmus verursachter Stapelüberlauf behoben werden?
Beispiel
Ich versuche das Project Euler-Problem 14 zu lösen und habe beschlossen, es mit einem rekursiven Algorithmus zu versuchen. Das Programm stoppt jedoch mit einem java.lang.StackOverflowError. Verständlicherweise. Der Algorithmus hat den Stack tatsächlich überlaufen lassen, weil ich versucht habe, eine Collatz-Sequenz für eine sehr große Anzahl zu generieren.
Lösungen
Also habe ich mich gefragt: Welche Standardmethoden gibt es, um einen Stapelüberlauf zu lösen, wenn Ihr rekursiver Algorithmus korrekt geschrieben wurde und der Stapel immer überlaufen würde? Zwei Konzepte, die mir in den Sinn kamen, waren:
- Schwanzrekursion
- Wiederholung
Sind die Ideen (1) und (2) richtig? Gibt es noch andere Möglichkeiten?
Bearbeiten
Es wäre hilfreich, Code zu sehen, vorzugsweise in Java, C #, Groovy oder Scala.
Verwenden Sie das oben erwähnte Project Euler-Problem möglicherweise nicht, damit es nicht für andere verwöhnt wird, sondern verwenden Sie einen anderen Algorithmus. Factorial vielleicht oder so ähnlich.
quelle
Antworten:
Die Tail-Call-Optimierung ist in vielen Sprachen und Compilern vorhanden. In dieser Situation erkennt der Compiler eine Funktion der Form:
Hier kann die Sprache erkennen, dass das zurückgegebene Ergebnis das Ergebnis einer anderen Funktion ist, und einen Funktionsaufruf mit einem neuen Stapelrahmen in einen Sprung umwandeln.
Beachten Sie, dass die klassische Fakultätsmethode:
ist wegen der bei der rückgabe notwendigen inspektion nicht schwanzrufoptimierbar. ( Beispiel für Quellcode und kompilierte Ausgabe )
Um diesen Tail Call zu optimieren,
Kompilieren Sie diesen Code mit
gcc -O2 -S fact.c
(-O2 ist erforderlich, um die Optimierung im Compiler zu ermöglichen, aber mit mehr Optimierungen von -O3 wird es für einen Menschen schwer zu lesen ...)( Beispiel für Quellcode und kompilierte Ausgabe )
Man kann in dem Segment sehen
.L3
, diejne
eher als eincall
(das macht einen Subroutinen - Aufruf mit einem neuen Stapelrahmen).Beachten Sie, dass dies mit C durchgeführt wurde. Die Optimierung von Tail-Aufrufen in Java ist schwierig und hängt von der JVM-Implementierung ab (ich habe jedoch noch keine davon gesehen, da dies schwierig ist und Auswirkungen auf das erforderliche Java-Sicherheitsmodell hat, das Stack-Frames erfordert - was TCO vermeidet) - Tail-Recursion + Java und Tail-Recursion + Optimierung sind gute Tag-Sets zum Durchsuchen. Sie können andere JVM Sprachen sind in der Lage zu optimieren Endrekursion finden besser (try clojure (die die erfordert recur zu Endrekursion optimize) oder scala).
Das gesagt,
Es ist eine gewisse Freude zu wissen, dass Sie etwas richtig geschrieben haben - auf die ideale Art und Weise, wie es getan werden kann.
Und jetzt hole ich mir einen Scotch und ziehe eine deutsche Electronica an ...
Zur allgemeinen Frage "Methoden zur Vermeidung eines Stapelüberlaufs in einem rekursiven Algorithmus" ...
Ein anderer Ansatz besteht darin, einen Rekursionszähler einzuschließen. Dies dient eher zum Erkennen von Endlosschleifen, die durch Situationen verursacht werden, auf die man keinen Einfluss hat (und durch schlechte Codierung).
Der Rekursionszähler hat die Form von
Bei jedem Anruf erhöhen Sie den Zähler. Wenn der Zähler zu groß wird, tritt ein Fehler auf (hier nur eine Rückgabe von -1, in anderen Sprachen möchten Sie möglicherweise eine Ausnahme auslösen). Damit soll verhindert werden, dass bei einer Rekursion, die viel tiefer als erwartet ist und wahrscheinlich eine Endlosschleife darstellt, schlimmere Ereignisse (Speicherfehler) auftreten.
Theoretisch sollte man das nicht brauchen. In der Praxis habe ich schlecht geschriebenen Code gesehen, der dies aufgrund einer Vielzahl von kleinen Fehlern und schlechten Codierungspraktiken getroffen hat (Multithread-Probleme, bei denen etwas außerhalb der Methode geändert wird, wodurch ein anderer Thread in eine Endlosschleife rekursiver Aufrufe gerät).
Verwenden Sie den richtigen Algorithmus und lösen Sie das richtige Problem. Speziell für die Collatz-Vermutung scheint es , dass Sie versuchen, sie auf die xkcd- Weise zu lösen :
Du beginnst bei einer Zahl und machst eine Baumquerung. Dies führt schnell zu einem sehr großen Suchraum. Ein schneller Durchlauf zur Berechnung der Anzahl der Iterationen für die richtige Antwort führt zu etwa 500 Schritten. Dies sollte kein Problem für die Rekursion mit einem kleinen Stapelrahmen sein.
Das Wissen um die rekursive Lösung ist zwar keine schlechte Sache, man sollte jedoch auch erkennen, dass die iterative Lösung um ein Vielfaches besser ist . Eine Reihe von Möglichkeiten, einen rekursiven Algorithmus in einen iterativen zu konvertieren, finden Sie unter Stapelüberlauf auf dem Weg von der Rekursion zur Iteration .
quelle
Beachten Sie, dass die Sprachimplementierung die Optimierung der Schwanzrekursion unterstützen muss. Ich glaube nicht, dass es die großen Java-Compiler tun.
Memoisierung bedeutet, dass Sie sich an das Ergebnis einer Berechnung erinnern, anstatt es jedes Mal neu zu berechnen.
Wenn Sie jede Sequenz mit weniger als einer Million berechnen, wird es am Ende der Sequenzen eine Menge Wiederholungen geben. Memoization macht es zu einer schnellen Suche in der Hash-Tabelle nach vorherigen Werten, anstatt den Stack immer tiefer und tiefer machen zu müssen.
quelle
Ich bin überrascht, dass noch niemand Trampolinspringen erwähnt hat. Ein Trampolin (in diesem Sinne) ist eine Schleife, die Thunk-Return-Funktionen (Continuation-Passing-Stil) iterativ aufruft und zum Implementieren von schwanzrekursiven Funktionsaufrufen in einer stapelorientierten Programmiersprache verwendet werden kann.
Diese Frage Stackoverflow geht in ziemlich viel ausführlicher über die verschiedenen Implementierungen von trampolining in Java: Umgang mit Stackoverflow in Java für Trampoline
quelle
Wenn Sie eine Sprache und einen Compiler verwenden, die rekursive Funktionen erkennen und ordnungsgemäß verarbeiten (dh "den Anrufer durch den Angerufenen ersetzen"), sollte der Stack nicht außer Kontrolle geraten. Diese Optimierung reduziert eine rekursive Methode im Wesentlichen auf eine iterative. Ich glaube nicht, dass Java dies tut, aber ich weiß, dass es Racket tut.
Wenn Sie sich für einen iterativen und nicht für einen rekursiven Ansatz entscheiden, müssen Sie sich nicht mehr lange daran erinnern, woher die Aufrufe stammen, und die Wahrscheinlichkeit eines Stapelüberlaufs (bei rekursiven Aufrufen ohnehin) praktisch ausschließen.
Memoization ist großartig und kann die Gesamtzahl der Methodenaufrufe reduzieren, indem zuvor berechnete Ergebnisse in einem Cache nachgeschlagen werden, da Ihre Gesamtberechnung viele kleinere, wiederholte Berechnungen erfordert. Diese Idee ist großartig - sie ist auch unabhängig davon, ob Sie einen iterativen oder einen rekursiven Ansatz verwenden.
quelle
Sie könnten eine Aufzählung erstellen, die die Rekursion ersetzt ... hier ist ein Beispiel für die Berechnung der Fakultät, die dies tut ... (funktioniert nicht für große Zahlen, da ich im Beispiel nur lange gebraucht habe :-))
Selbst wenn dies keine Speicherung ist, wird auf diese Weise ein Stapelüberlauf verhindert
BEARBEITEN
Es tut mir leid, wenn ich einige von Ihnen verärgert habe. Meine einzige Absicht war es, einen Weg aufzuzeigen, wie ein Stapelüberlauf vermieden werden kann. Ich hätte wahrscheinlich ein vollständiges Codebeispiel schreiben sollen, anstatt nur ein kleines Stück eines schnell geschriebenen und groben Codeauszugs.
Der folgende Code
... ähm ... wenn Sie es ausführen, stellen Sie sicher, dass Ihr Kommando-Shell-Fenster einen Puffer von 9999 Zeilen hat ... die üblichen 300 reichen nicht aus, um die Ergebnisse des folgenden Programms durchzuarbeiten ...
Ich deklariere * 1 statische Variable "instance" in der Faculty-Klasse, um einen Singleton zu speichern. Auf diese Weise erhalten Sie immer dann, wenn Sie "GetInstance ()" der Klasse ausführen, die Instanz, in der alle bereits berechneten Werte gespeichert sind. * 1 statische SortedList, die alle bereits berechneten Werte enthält
Im Konstruktor füge ich auch 2 Sonderwerte der Liste 1 für die Eingänge 0 und 1 hinzu.
quelle
Wie bei Scala können Sie die
@tailrec
Anmerkung zu einer rekursiven Methode hinzufügen . Auf diese Weise stellt der Compiler sicher, dass die Tail Call-Optimierung tatsächlich stattgefunden hat:Das wird also nicht kompiliert (Fakultät):
Die Fehlermeldung lautet:
Auf der anderen Seite:
kompiliert und Tail-Call-Optimierung stattgefunden.
quelle
Eine Möglichkeit, die noch nicht erwähnt wurde, ist die Rekursion, jedoch ohne Verwendung eines Systemstacks. Natürlich können Sie Ihren Heap auch überlaufen lassen, aber wenn Ihr Algorithmus wirklich ein Backtracking in der einen oder anderen Form benötigt (warum überhaupt eine Rekursion verwenden?), Haben Sie keine andere Wahl.
Es gibt stapellose Implementierungen einiger Sprachen, z . B. stapelloses Python .
quelle
Eine andere Lösung wäre, Ihren eigenen Stack zu simulieren und sich nicht auf die Implementierung des Compilers + der Laufzeit zu verlassen. Dies ist keine einfache und auch keine schnelle Lösung, aber theoretisch erhalten Sie StackOverflow nur, wenn Sie nicht genügend Arbeitsspeicher haben.
quelle