Algorithmen

In der Informatik kann man sich mit einem Teilgebiet, der Algorithmik, beschäftigen. Das hört sich eigentlich trocken und langweilig an: Algorithmen sind Ausführungsvorschriften, die bestimmte Verfahren beschreiben. Beispielsweise Verfahren, mit denen eine Liste von Namen sortiert werden kann. Oder ein Verfahren, durch dessen Hilfe man Zahlen schriftlich addieren kann:

Additionsalgorithmus

Das ist ein relativ einfaches Beispiel, an dem man nicht sehen kann, wie wichtig ein effizienter Algorithmus für ein Problem ist. Deshalb widme ich mich hier einem relativ einfachen Anfängerbeispiel, an dem man genau das sehr gut sehen kann: den Fibonacci-Zahlen und deren Berechnung.

Fibonacci-Zahlen

Die Fibonacci-Zahlen sind nicht nur als eindrucksvolles Beispiel hervorragend geeignet, sondern sind auch noch als Forschungsgebiet an sich sehr interessant: sie laufen einem in der Informatik ständig wieder über den Weg: die ggT-Berechnung nach dem euklidischen Algorithmus beispielsweise nimmt ihr Worst-Case-Verhalten (also die Zahlen, für die der Algorithmus am langsamsten ist) genau bei Anwendung auf zwei Fibonacci-Zahlen an. Auch haben die Fibonacci-Zahlen eine verblüffende Beziehung zum goldenen Schnitt, auf die ich später eingehen werde. Eigentlich "erfunden" wurden sie aber aus einem eher praktisch orientierten Problem: man nimmt an, man hätte ein Paar neugeborener Kaninchen. Dieses Paar wird nach zwei Monaten geschlechtsreif und bekommt ab diesem Zeitpunkt jeden Monat ein weiteres Paar. Für die Nachfahren gelten dieselben Regeln: diese werfen auch nach zwei Monaten das erste Paar Nachkommen, dessen Nachkommen wiederum und so weiter. Wenn man ideale Bedingungen für die Kaninchen annimmt (unbegrenzte Lebenszeit der Kaninchen und genügend Futter) gilt es nun zu bestimmen, wieviele Kaninchen die Herde insgesamt nach n Monaten hat.

Die Rekursionsformel

Rekursion bedeuted, dass ein bestimmtes Problem auf ein oder mehrere kleine Teilprobleme zurückgeführt wird. Programmiertechnisch nennt man Rekursion den Vorgang, wenn eine Funktion sich selbst wieder (mit veränderten Parametern) aufruft. Hier die Formel für die Fibonacci-Zahlen, die relativ einfach zu verstehen ist:

f(0)=0, f(1)=1, f(n)=f(n-1) + f(n-2)

Ansatz 1: Naive Rekursion

Der erste, naive Ansatz ist, das Problem einfach so zu programmieren, wie es gegeben ist: man ersetzt jeweils, wenn in einem Ausdruck der Form fn n nicht 0 oder 1 ist, fn durch "fn-1 + fn-2". In einer Programmiersprache (hier C) sieht das dann so aus:

int fib_rek(int n) {
    if (n==0) return 0;
    if (n==1) return 1;

    return fib_rek(n-1)+fib_rek(n-2);
}

Das Problem wird also schrittweise reduziert. Allerdings wird für große "n" der Rechenaufwand unerträglich, denn der Computer muss unglaublich viele Rechnungen doppelt ausführen:

f(5) = f(1) + f(0) + f(1) + f(1) + f(0) + f(1) + f(0) + f(1) = 5

In Baumstruktur wird schnell deutlich, dass hier eine kaskadenartige Rekursion auftritt, die exponentiellen Aufwand hat; die Basisfälle (f0 und f1) sind in dem Diagramm fett gezeichnet. Weiterhin erkennt man deutlich, dass bereits berechnete Zwischenergebnisse jedes Mal wieder erneut berechnet werden; f3 wird beispielsweise zweimal, f2 viermal ausgerechnet:

Baumdiagramm für f(5)

Mit diesem Verfahren benötigt man also eine in Abhängigkeit von "n" exponentiell wachsende Anzahl an Additionen; wieviele genau, dafür kann man eine Formel herleiten, was am Ende dieses Textes auch gemacht wird.

Ansatz 2: Lineare Iteration

Nun ist eine Aufgabe der Algorithmik, sich mit Verfahren zu beschäftigen, die den Algorithmus verbessern können. Und eine Methode ist schon offensichtlich: man geht die Zahlen durch und merkt sich jeweils nur die letzten zwei. Dadurch reduziert sich die Anzahl an Additionen drastisch. Für die Basisfälle f0 und f1 sind wie auch in der naiven Rekursion gar keine Additionen notwendig. Für jede Zahl, die größer als 1 ist, benötigt dieser Algorithmus "n-1" Additionen. Die Anzahl an Additionen steigt also linear mit der Problemgröße "n". Hier ein Ansatz für eine mögliche Implementierung:

int fib_iter(int n) {
    int a, b, c;
    int i;
    int sum;

    if (n==0) return 0;
    if (n==1) return 1;

    a=0;
    b=1;
    c=0;
    sum=0;
    for (i=2; i<=n; i++) {
        c = a + b;
        a = b;
        b = c;
    }

    return c;
}

Ein Vergleich der naiven Rerkursion mit der linearen Iteration, bei dem für die Zeitberechnungen angenommen werden soll, dass ein Computer eine Milliarde Additionen in einer Sekunde bewerkstelligen kann:

n Rekursion Additionen Iteration Additionen Rekursion Zeit Iteration Zeit
0 0 0 0 0
1 0 0 0 0
2 1 1 1ns 1ns
3 2 2 2ns 2ns
4 4 3 4ns 3ns
5 7 4 7ns 4ns
6 12 5 12ns 5ns
7 20 6 20ns 6ns
8 33 7 33ns 7ns
9 54 8 54ns 8ns
10 88 9 88ns 9ns
50 20365011073 49 20s ~ 50ns
100 573147844013817084100 99 ~ 18174,4 Jahre ~ 100ns
1000 703303677114228158218352548771835
497701812698363587327426049050871
545371181969335797422494945626117
334877504492417659910881863632654
502236471060120533741212738673391
111981393731255987676900919022452
45323403500
999 223016133027089091266600884313747
938134770642555678376276651779195
695513439234315004256245226289357
348705449166799105755606882176767
663063315277815998776386586337325
9487510761451217616935885715 Jahre
~ 1µs

Und hier abgetragen der tatsächliche Zeitaufwand für die oben angegebenen Implementierungen auf meinem PC:

n Rekursion Zeit Iteration Zeit
10 2 ms 2 ms
20 2 ms 2 ms
30 61 ms 2 ms
40 3.02 s 2 ms
41 4.84 s 2 ms
42 8.23 s 2 ms
43 12.60 s 2 ms
44 20.50 s 2 ms

Man kann also deutlich sehen, dass die iterative Variante quasi keine Zeit im Vergleich zur rekursiven Variante benötigt; die 2ms werden wohl hauptsächlich für die Programminitialisierung verwandt, der eigentliche Rechenaufwand fällt nicht ins Gewicht.

Ansatz 3: Geschlossene Form

Zunächst soll der goldene Schnitt betrachtet werden, ein Problem, dass scheinbar mit den Fibonacci-Zahlen recht wenig zu tun hat. Der goldene Schnitt ist definiert dadurch, dass sich a zu b verhält wie die Summe von a und b zu a. Dies drückt folgende Formel aus:

x^2 - x - 1 = 0

Durch Auflösen nach x nach der quadratischen Lösungsformel kommt man zu zwei Lösungen, die im Folgenden als Phi und Phi-Dach bezeichnet werden:

phi, phidach = (1 +- sqrt(5)) / 2

Nun sei einfach behauptet, dass die n-te Fibonacci-Zahl durch folgende Formel errechnet werden könnte:

f(n) = (phi^n - phidach^n)/sqrt(5)

Für diese Ausführungen genügt es, dass die Formel einfach vom Himmel fällt und wir beweisen können, dass sie auch stimmt. Die Herleitung ist etwas komplizierter. Für die Interessierten seien hier nur folgende Stichworte genannt, mit denen man bei Google sicher weiter kommt: erzeugende Funktion von fn finden, Partialbruchzerlegung machen, also eine Z-Transformation durchführen, siehe auch allgemeine Lösung C-rekursiver Folgen.

Nun weiter zur oben genannter Formel. Wir beweisen diese durch vollständige Induktion, ein Beweisverfahren, das vielleicht in der Schule schon einmal angewandt wurde. Der Induktionsanfang:

Induktionsanfang: f(0)=0, f(1)=1

Für f0 und f1 stimmen die Werte also. Wir berechnen nun fn-1+fn-2:

Induktionsschritt: f(n-1)+f(n-2) = (phi^(n-1)-phidach^(n-1))/sqrt(5) + (phi^(n-1)-phidach^(n-2))/sqrt(5)

Wir nutzen nun zweimal die Tatsache aus, dass Phi sowie Phi-Dach Lösungen der Gleichung des goldenen Schnitts, sind. Es gilt also, dass Phi2 = Phi+1 und PhiDach2 = PhiDach+1. Dies berücksigend werfen wir in einer Nebenrechnung einen Blick auf den Term Phi-1 + Phi-2:

Nebenrechnung: phi^-1 + phi^-2 = 1

Wir sehen, dass der Term 1 ergibt. Für PhiDach wird dies nicht explizit gezeigt, sämtliche Schritte sind absolut analog: denn auch PhiDach genügt der Gleichung des goldenen Schnitts. Nun setzen wir die in der Nebenrechnung gewonnenen Erkenntnisse in die Gleichung 2 ein:

(phi^(n-1) - phidach^(n-1))/sqrt(5) + (phi^(n-1) - phidach^(n-2))/sqrt(5) = (phi^n - phidach^n)/sqrt(5) -> QED

Und schon steht die Lösung da! Wir haben soeben also bewiesen, dass die Formel stimmt. So können wir für sämtliche Werte für "n" die Lösung sofort berechnen, da wir eine geschlossene Form haben.

Zurück zur Kaskadenrekursion

Um noch einmal auf die Kaskadenrekursion zurückzukommen, die als erster Ansatz gewählt wurde: Oben habe ich geschrieben, dass ich hier auf die Formel eingehen werde, mit der man bestimmen kann, wie viele Additionen zur Berechnung der n-ten Fibonacci-Zahl notwendig sind. Erstaunlicherweise ähnelt diese Gleichung der Fibonacci-Gleichung erstaunlich. Man kann auf die geschlossene Form kommen, indem man die Schritte absolut analog zur Herleitung der geschlossenen Form der Fibonacci-Zahlen anwendet. Denn die Aufwandsfunktion genügt folgender Rekursionsgleichung:

a(0)=0, a(1)=0, a(n) = 1 + a(n-1) + a(n-2)

Ohne Beweis der Korrektheit sei hier die Lösung präsentiert:

a(n) = (phi^(n+1) - phidach^(n+1))/sqrt(5) - 1

Also ist der Aufwand, um die n-te Fibonacci-Zahl nach dem Verfahren der naiven Rekursion zu berechnen genau um eins weniger als die n+1-te Fibonacci-Zahl selbst!

Schlusswort

Hier wurde jetzt also gezeigt, dass es sich durchaus lohnen kann, ein bischen Zeit in einen Algorithmus zu investieren, damit hinterher eine ungleich bessere Lösung herauskommt. Auch wurde sicherlich deutlich, dass die guten Lösungen für Probleme keineswegs offensichtlich sind, sondern dass deren Herleitung oft höhere Mathematik benötigt. Dennoch ist es wichtig, so wissen, dass viele Algorithmen verbessert werden können; Schließlich hat nicht jeder ein Paar Tausend Jahre Zeit, um sich die hundertste Fibonacci-Zahl anzeigen zu lassen...