Die dynamische Programmierung kann man anwenden, wenn eine Methode mehrfach mit identischen Argumenten aufgerufen wird. Die Idee der dynamischen Programmmierung besteht darin, das Ergebnis des jeweils ersten Aufrufs in einer Datenstruktur zu speichern. Beim zweiten Aufruf wird dann nicht die gleiche Berechnung noch einmal durchgeführt, sondern das zuvor berechnet Ergebnis wird in der Datenstruktur nachgeschlagen. Damit dieser Ansatz eine Implementierung liefert, die effizienter ist als die ursprüngliche Implementierung, muss das Nachschlagen der Ergebnisse in der Datenstruktur entsprechend effizient sein. Häufig wird für die Datenstruktur daher ein Array verwendet.

Beispiel

Um die Methode der dynamischen Programmierung zu motivieren, die wir uns im Folgenden anschauen, wollen wir uns die Implementierung der Methode fib noch mal etwas genauer anschauen. Wir wollen uns einmal anschauen, welche Aufrufe der Methode fib für den Aufruf fib(5) durchgeführt werden. Bei der Struktur, welche die Aufrufe der Methode darstellt, handelt es sich um einen Baum. Daher wird die Struktur auch als Rekursionsbaum bezeichnet. Jeder Knoten des Baumes ist ein Aufruf der Methode fib. Ein Knoten hat dann jeweils die rekursiven Aufrufe der Methode als Nachfolger. Der Knoten fib(5) hat zum Beispiel die Nachfolger fib(4) und fib(3).

Abbildung 3: Der Rekursionsbaum für fib(5)

Abbildung 3 zeigt, dass viele Aufrufe der Methode fib mehrfach durchgeführt werden. Mit Hilfe der dynamischen Programmierung können wir die mehrfache Berechnung dieser Werte vermeiden.

Bei der dynamischen Programmierung werden die Ergebnisse einer Methode in einer Datenstruktur gespeichert. Wird die Methode dann mit den gleichen Argumenten noch einmal aufgerufen, so wird das Ergebnis nicht erneut berechnet, sondern in der Datenstruktur nachgeschlagen. Wir können diese Methode nutzen, um die Berechnung einer Fibonacci-Zahl effizienter durchzuführen. Um uns die Mehrfachberechnungen zu sparen, merken wir uns einfach die bereits berechneten Werte in einem Array.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static int fibDyn(int n) {
    var memo = new Integer[n + 1];
    return fibDyn(memo, n);
}

private static int fibDyn(Integer[] memo, int n) {
    if (n == 0) {
        return 0;
    } else if (n == 1) {
        return 1;
    } else {
        if (memo[n] == null) {
            memo[n] = fibDyn(memo, n - 1) + fibDyn(memo, n - 2);
        }
        return memo[n];
    }
}

Um zu verstehen, wie die Aufrufe im Array memo nachgeschlagen werden, müssen wir wissen, dass Java die Argumente einer Methode von links nach rechts auswertet. Das heißt, beim Aufruf fibDyn(memo, n - 1) + fibDyn(memo, n - 2) wird der Aufruf fibDyn(memo, n - 1) ausgeführt bevor der Aufruf fibDyn(memo, n - 2) ausgeführt wird. Die folgende Abbildung zeigt die Aufrufstruktur mit Hilfe der dynamischen Programmierung.

Abbildung 4: Der Rekursionsbaum für fibDyn(5)

Da wir die ersten beiden Einträge des Arrays gar nicht verwenden, können wir den Speicherverbrauch der Implementierung noch optimieren, indem wir ein Array der Größe Math.max(0, n - 1) anlegen und jeweils memo[n - 2] nutzen. Wir müssen die Funktion Math.max nutzen, da Java einen Laufzeitfehler wirft, wenn wir versuchen, ein Array mit einer negativen Größe zu erzeugen. Diese Implementierung spart zwei Arrayeinträge, hat aber den Nachteil, dass sie schwieriger zu verstehen ist.

Die Idee der dynamischen Programmierung kann auf verschiedene Arten angewendet werden. Wir beschäftigen uns hier nur damit, wie man dynamische Programmierung anwenden kann und dabei die grundlegende Struktur der Originalimplementierung möglichst erhält. Es gibt aber auch Ansätze, bei denen das Füllen der Datenstruktur und das Auslesen stärker getrennt werden. Zur Implementierung der Fibonacci-Funktion können wir zum Beispiel ein Array mit Zahlen füllen und anschließend im gefüllten Array den entsprechenden Wert nachschlagen.

Um noch einmal den Ansatz der dynamischen Programmierung zu motivieren, wollen wir uns Gedanken über die Laufzeiten der beiden Implementierungen machen. Zuerst geben wir eine Rekurrenz für die Laufzeit der Methode fib an. Um das Problem etwas zu vereinfachen, gehen wir davon aus, dass alle konstanten Aufwände, die in der Methode vorkommen, identisch sind. Wir könnten für die Konstante c1 hier zum Beispiel das Maximum der tatsächlichen Konstanten nutzen.

Tfib:NRTfib(n)={c1falls n=0c1falls n=1c1+Tfib(n1)+Tfib(n2)sonst

Mithilfe der starken Induktion wollen wir nun zeigen, dass Tfib(n)O(2n) gilt. Dazu müssen wir zeigen, dass ein cR mit c>0 und ein n0N existieren, so dass Tfib(n)c2n für alle nN mit nn0 gilt. Wir zeigen zunächst eine Hilfsaussage.

Beh.: nN:n1Tfib(n)c12n

Bew.:

Zur Lesbarkeit nennen wir die Funktion Tfib im Folgenden T.

Wir zeigen die Aussage per starker Induktion mit n0=1.

Induktionsschritt:

Sei nN mit n1 und es gelte mN:mn0m<nT(m)c12n. Dann gilt

1. Fall: n=1

T(1)=c1=c11Regel (1): 0c112c12=c121

2. Fall: n=2

T(2)=c1+T(1)+T(0)=c1+c1+c1=c13c14Regel (1): 0c134=c122

3. Fall: n>2

T(n)=c+T(n1)+T(n2)c+c2n1+T(n2)Regel (2): Induktionshypothese mit m=n1c+c2n1+c2n2Regel (2): Induktionshypothese mit m=n2c+c2n1+c2n2+c(2n21)Regel (2): 0c(2n21)=c+c2n1+c2n2+c2n2c=c2n1+c2n2+c2n2=c2n1+2c2n2=c2n1+c2n1=c2n

Für einen der Schritte benötigen wir die Eigenschaft 02n21, also 12n2. In diesem Fall gilt 3n, also 1n2 und somit nach Regel (4) 2=212n2.

Dieser Beweis zeigt, dass die Methode fib eine exponentielle Laufzeit hat.

Einschub: Größenordnungen

Wir haben im Kapitel Komplexität die obere Schranke kennengelernt. Dabei handelt es sich, wie der Name schon sagt, aber nur um eine obere Schranke. Wenn die Laufzeit eines Algorithmus in O(n2) liegt, kann der Algorithmus zum Beispiel eine lineare Laufzeit haben. Das heißt, durch den vorherigen Beweis wissen wir nur, dass die Laufzeit von fib nicht schlechter als exponentiell ist. Wir wissen aber nicht, ob die Laufzeit von fib vielleicht linear oder quadratisch ist. Tatsächlich könnte die Laufzeit von fib damit immer noch irgendeine polynomielle Laufzeit sein.

Intuitiv möchten wir auch ausdrücken können, dass die Lösung für ein bestimmtes Problem genau exponentiell viele Schritte benötigt. Um diese Eigenschaft formal auszudrücken, definieren wir zuerst den Begriff der unteren Schranke.

Definition 3 (Asymptotische untere Schranke)

Sei f:NR. Dann definieren wir

Ω(f):={g:NRfasg}.

Beispiel 3 (Quadratische Komplexität)

Es gilt 2n2+3nΩ(n2). Das heißt, pΩ(q), wobei q(n)=n2 und p(n)=2n2+3n. Wir können zum Beispiel c=12 und n0=0 wählen, denn es gilt die folgende Ungleichung.

q(n)=n2n2+32nRegel (4): 032n=12(2n2+3n)=cp(n).

Für diese Ungleichung benötigen wir noch 032n. Es gilt 032 und 0n und nach Regel (1) gilt somit 320=032n.

Die Funktion p ist sowohl in O(n2) als auch in Ω(n2), das heißt, sie ist durch quadratisches Wachstum nach unten und oben beschränkt. Um auszudrücken, dass eine Funktion zum Beispiel asymptotisch quadratisch wächst, definieren wir den Begriff der scharfen Schranke.

Definition 3 (Asymptotisch scharfe Schranke)

Sei f:NR. Dann definieren wir

Θ(f):=O(f)Ω(f).