(Beachten Sie, dass ich die Frage hier stelle, da es sich eher um die konzeptionelle Mechanik als um ein Codierungsproblem handelt.)
Ich arbeite an einem kleines Programm, das eine Folge von Fibonacci - Zahlen in seinem equasion Verwendung wurde, aber ich merkte , dass , wenn ich eine bestimmte Anzahl überwindet es quälend langsam bekam, googeln um ein wenig ich auf eine Technik , die in Haskell als bekannt gestolpert Memoization
, Sie zeigten Code, der so funktioniert:
-- Traditional implementation of fibonacci, hangs after about 30
slow_fib :: Int -> Integer
slow_fib 0 = 0
slow_fib 1 = 1
slow_fib n = slow_fib (n-2) + slow_fib (n-1)
-- Memorized variant is near instant even after 10000
memoized_fib :: Int -> Integer
memoized_fib = (map fib [0 ..] !!)
where fib 0 = 0
fib 1 = 1
fib n = memoized_fib (n-2) + memoized_fib (n-1)
Meine Frage an euch ist also, wie oder warum funktioniert das?
Liegt es daran, dass es irgendwie gelingt, den größten Teil der Liste zu durchlaufen, bevor die Berechnung aufholt? Aber wenn Haskell faul ist, gibt es nicht wirklich eine Berechnung, die nachholen muss ... Also, wie funktioniert es?
the calculation catches up
? Übrigens ist Memoization nicht spezifisch für Haskell: en.wikipedia.org/wiki/MemoizationAntworten:
Nur um die Mechanik hinter dem eigentlichen Auswendiglernen zu erklären,
erzeugt eine Liste von "Thunks", nicht ausgewerteten Berechnungen. Denken Sie an diese wie ungeöffnete Geschenke, solange wir sie nicht anfassen, laufen sie nicht.
Sobald wir einen Thunk evaluieren, evaluieren wir ihn nie wieder. Dies ist tatsächlich die einzige Form der Mutation in "normalem" Haskell. Die Thunks mutieren, sobald sie ausgewertet wurden, zu konkreten Werten.
Zurück zu Ihrem Code, Sie haben eine Liste von Thunks, und Sie führen diese Baumrekursion immer noch durch, aber Sie rekursieren mit der Liste, und sobald ein Element in der Liste ausgewertet wurde, wird es nie wieder berechnet. So vermeiden wir die Baumrekursion in der naiven Fib-Funktion.
Als tangential interessanter Hinweis ist dies besonders schnell, wenn eine Reihe von Fibonnaci-Zahlen berechnet wird, da diese Liste nur einmal ausgewertet wird. Wenn Sie also
memo_fib 10000
zweimal rechnen , sollte das zweite Mal sofort erfolgen. Dies liegt daran, dass Haskell Argumente für Funktionen nur einmal ausgewertet hat und Sie eine Teilanwendung anstelle eines Lambdas verwenden.TLDR: Durch das Speichern von Berechnungen in einer Liste wird jedes Element der Liste einmal ausgewertet, daher wird jede Fibonnacci-Zahl im gesamten Programm genau einmal berechnet.
Visualisierung:
Sie können also sehen, dass die Auswertung
THUNK_4
viel schneller ist, da die Unterausdrücke bereits ausgewertet wurden.quelle
memo_fib
zweimal mit dem gleichen Wert aufrufe, das zweite Mal sofort, aber wenn ich es mit einem Wert 1 höher aufrufe, ist es Es dauert immer noch ewig zu bewerten (wie sagen wir von 30 bis 31)memo_fib 29
undmemo_fib 30
es wird bereits ausgewertet. Es wird genau so lange dauern, bis diese beiden Zahlen addiert werden. :) Sobald etwas evaluiert wurde, bleibt es evaluiert.Der Punkt, an dem Sie sich merken müssen, ist, niemals dieselbe Funktion zweimal zu berechnen - dies ist äußerst nützlich, um Berechnungen zu beschleunigen, die rein funktional sind, dh ohne Nebenwirkungen, da bei diesen der Prozess vollständig automatisiert werden kann, ohne die Korrektheit zu beeinträchtigen. Dies ist insbesondere dann erforderlich , für Funktionen wie
fibo
, die zu führen Baumrekursion , das heißt exponentiellen Aufwand, wenn naiv implementiert. (Dies ist ein Grund, warum die Fibonacci-Zahlen eigentlich ein sehr schlechtes Beispiel für das Unterrichten von Rekursion sind - fast alle Demo-Implementierungen, die Sie in Tutorials oder Büchern finden, sind für große Eingabewerte unbrauchbar.)Wenn Sie den Ablauf der Ausführung verfolgen, werden Sie feststellen, dass im zweiten Fall der Wert für
fib x
immer verfügbarfib x+1
ist, wenn er ausgeführt wird, und das Laufzeitsystem kann ihn einfach aus dem Speicher lesen und nicht über einen anderen rekursiven Aufruf, während der Die erste Lösung versucht, die größere Lösung zu berechnen, bevor die Ergebnisse für kleinere Werte verfügbar sind. Dies liegt letztendlich daran, dass der Iterator[0..n]
von links nach rechts ausgewertet wird und daher mit beginnt0
, während die Rekursion im ersten Beispiel mit beginntn
und erst dann nachfragtn-1
. Dies führt zu den vielen, vielen unnötigen doppelten Funktionsaufrufen.quelle
memorized_fib 20
zum Beispiel schreiben , Sie tatsächlich nur schreibenmap fib [0..] !! 20
, es immer noch das berechnen müsste gesamter Nummernkreis bis 20, oder fehlt mir hier etwas?fib 2
so oft, dass sich Ihr Kopf dreht - schreiben Sie den Aufrufbaum für einen kleinen Wert wie aufn==5
. Sie werden das Auswendiglernen nie wieder vergessen, wenn Sie gesehen haben, was es Sie rettet.n = 5
, und ich bin gerade an dem Punkt angelangt, an dem ichn == 3
bisher so gut war, aber vielleicht ist es nur mein zwingender Verstand, dies zu denken, aber bedeutet das nicht nur, dassn == 3
man es einfach bekommtmap fib [0..]!!3
? was geht dann in denfib n
zweig des programms ... wo genau bekomme ich die vorteile von vorberechneten daten?memoized_fib
ist in Ordnung. Es ist dasslow_fib
, was dich zum Weinen bringt, wenn du es verfolgst.