↑Rezepte
Die rekursive Definition der Fibonacci-Folge:
def fib(n): return 1 if n < 3 else fib(n - 1) + fib(n - 2)
Lässt man die Werte nun per
for n in range(1, 100): print("{} | {}".format(n, fib(n)))
ausgeben, stellt man fest, dass der Zeitbedarf der Berechnung ab etwa n = 30 in die Größenordnung von Sekunden ansteigt und ab etwa n = 40 Minuten beträgt. Die genauen Werte sind natürlich davon abhängig, wie schnell der Computer rechnen kann und wie der Interpreter implementiert ist.
Man kann zeigen, dass der Berechnungsaufwand exponentiell steigt. Trägt man also die logarithmierte Zeitdauer in Abhängigkeit von n auf, müsste sich eine Gerade ergeben. Bei der Messung sollte man die Zeitdauer über möglichst viele Berechnungen mitteln, um solche statistischen Effekte herauszurechnen, die durch das Betriebssystem und Vorgänge im Prozessor entstehen. Außerdem sollte man dafür sorgen, dass die Startzeit des Interpreters bzw. die Ladezeit des Programms nicht mit in die Messung eingeht.
Zur Reduktion des Berechnungsaufwands kommt nun Memoisierung zur Anwendung. Hierbei werden bereits berechnete Werte in einem Memo oder Cache gespeichert, um sie nicht ständig erneut ermitteln zu müssen. Als Speicher diene zunächst ein Array.
fib_memo = 1000*[None] def fib(n): if n < 3: return 1 else: if fib_memo[n] is None: fib_memo[n] = fib(n - 1) + fib(n - 2) return fib_memo[n]
Eleganter als die Festsetzung einer festen Arraygröße ist jedoch die Nutzung eines dynamischen Arrays oder eines Wörterbuchs. Ersetzt man das Array durch ein Wörterbuch, nimmt das Programm die folgende Gestalt an:
fib_memo = {} def fib(n): if n < 3: return 1 else: if not n in fib_memo: fib_memo[n] = fib(n - 1) + fib(n - 2) return fib_memo[n]
Bei der bisher beschriebenen Vorgehensweise verbleibt noch das Manko, dass die Memoisierung umständlich in jeden neuen Algorithmus eingefügt werden muss. Für den Leser wird der eigentliche Algorithmus dabei quasi mit technischem Balast verschleiert.
Wir beseitigen dieses Manko nun durch Abtrennung der Memoisierung
vom Algorithmus. Eine Funktion memoize
erzeugt hierbei
eine neue Funktion, die die urspüngliche in eine Memoisierung
einhüllt. Dies gelingt folgendermaßen:
def memoize(f): memo = {} def f_memo(n): if not n in memo: memo[n] = f(n) return memo[n] return f_memo def fib(n): return 1 if n < 3 else fib(n - 1) + fib(n - 2) fib = memoize(fib)
Bequemerweise bietet die Sprache Python mit ihrer Dekorateur-Syntax abschließend noch einen syntaktischen Zucker, mit dem sich die Memoisierung ideal notieren lässt:
@memoize def fib(n): return 1 if n < 3 else fib(n - 1) + fib(n - 2)
Es sei erwähnt, dass die Formulierung von memoize
mehrstellige Funktionen einbezieht, wenn man die Argumente der
jeweiligen Funktion zu einem Tupel zusammenfasst. Man betrachte zur
Demonstration die rekursive Berechnung der Binomialkoeffizienten:
@memoize def bc(t): (n, k) = t if k == 0 or k == n: return 1 else: return bc((n - 1, k - 1)) + bc((n - 1, k)) # Pascalsches Dreieck for n in range(0, 10): print([bc((n, k)) for k in range(0, n + 1)])
Mithilfe von Pythons Asterisk-Operator lässt sich
memoize
überdies so verallgemeinern, dass mehrstellige
Funktion explizit einbezogen sind.
def memoize(f): memo = {} def f_memo(*t): if not t in memo: memo[t] = f(*t) return memo[t] return f_memo @memoize def bc(n, k): return 1 if k == 0 or k == n else bc(n - 1, k - 1) + bc(n - 1, k) # Pascalsches Dreieck for n in range(0, 10): print([bc(n, k) for k in range(0, n + 1)])
Die Berücksichtigung benannter Argumente ist ebenfalls technisch machbar, gleichwohl der Mechanismus dadurch abermals ineffizienter wird. Da Wörterbücher in Python als veränderliche Objekte nicht als Schlüssel oder Bestandteil der Schlüssel von Wörterbüchern auftreten dürfen, muss das Wörterbuch der benannten Argumente zuvor in ein sortiertes Tupel umgewandelt werden.
def memoize(f): memo = {} def f_memo(*t, **d): td = tuple(sorted(d.items())) key = (t, td) if not key in memo: memo[key] = f(*t, **d) return memo[key] return f_memo
In Pythons Standardbibliothek ist Memoisierung unter der
Bezeichnung cache
im Modul functools
zu finden.
Besonders elgant wird die Formulierung, wenn die Anfangswerte
an memoize
übergeben werden, womit die Fallunterscheidung
im jeweiligen Algorithmus entfallen darf. Das geht so:
def memoize(memo): def cb(f): def f_memo(n): if not n in memo: memo[n] = f(n) return memo[n] return f_memo return cb @memoize({1: 1, 2: 1}) def fib(n): return fib(n - 1) + fib(n - 2)
Zu erwähnen ist, dass auch der Aufruf der eigenen Funktion innerhalb der Rekursion gegen die memoisierte Variante ersetzt werden muss. Das war möglich, weil die Funktion auch innerhalb der Rekursion über eine globale Variable angesprochen wird. Ist dies nicht gegeben, kann man die Memoisierung alternativ in den Fixpunkt-Kombinator einbauen.
Zunächst der reine Fixpunkt-Kombinator:
def fix(cb): def rec(n): return cb(rec, n) return rec @fix def fib(rec, n): return 1 if n < 3 else rec(n - 1) + rec(n - 2)
Nun bringen wir die Memoisierung ein und berücksichtigen mehrstellige Funktionen wieder mit der Asterisk-Syntax:
def fix(cb): memo = {} def rec(*t): if not t in memo: memo[t] = cb(rec, *t) return memo[t] return rec @fix def fib(rec, n): return 1 if n < 3 else rec(n - 1) + rec(n - 2)
Zur Konstruktion wechselseitiger Rekursion ist ein spezieller Fixpunkt-Kombinator zweckdienlich. Die Funktionen sind hierfür zu einer Struktur zusammenzufassen, die wir durch ein Wörterbuch darstellen.
def fix(cb): rec = {} rec.update(cb(rec)) return rec s = fix(lambda rec: { "even": lambda n: True if n == 0 else rec["odd"](n - 1), "odd": lambda n: False if n == 0 else rec["even"](n - 1) }) even = s["even"] odd = s["odd"]
Die Memoisierung verläuft nun folgendermaßen:
def memo_fn(f): memo = {} def mf(*t): if not t in memo: memo[t] = f(*t) return memo[t] return mf def fix(cb): rec = {} for key, value in cb(rec).items(): rec[key] = memo_fn(value) return rec s = fix(lambda rec: { "even": lambda n: True if n == 0 else rec["odd"](n - 1), "odd": lambda n: False if n == 0 else rec["even"](n - 1) }) even = s["even"] odd = s["odd"]