Informatik · Klasse 11–13 · aktualisiert
Dynamische Programmierung einfach erklärt
Du erkennst, wann dynamische Programmierung passt, baust Fibonacci-Zahlen aus Teilproblemen auf und siehst, warum Greedy nicht immer optimal ist.
0 von 12 Aufgaben gelöst
Kurz gesagt
Dynamische Programmierung löst ein Optimierungsproblem, indem sie es in Teilprobleme zerlegt und Zwischenergebnisse speichert. Man startet bei den kleinsten Teilproblemen und setzt größere daraus zusammen. Greedy wählt dagegen nur den jetzt besten Schritt und findet oft nur ein lokales Optimum.
Ein Programm rechnet dieselbe kleine Aufgabe oft immer wieder neu aus. Schon bei den Fibonacci-Zahlen sind das sehr viele Wiederholungen: Bei einer einfachen rekursiven Variante wären es für n = 43 über 1,4 Milliarden Aufrufe. Dynamische Programmierung vermeidet diese Wiederholungen. Außerdem lernst du, warum die „gierige“ Methode nicht immer die beste Lösung findet.
Deine Lernziele
Hake ab, was du schon kannst. Löst du alle Aufgaben eines Abschnitts, hakt Mela das Ziel für dich ab.
4 Abschnitte
Dynamische Programmierung ist eine Methode für Optimierungsprobleme. Bei einem Optimierungsproblem suchst du die beste Lösung. Die Methode teilt das Problem in Teilprobleme auf und speichert Zwischenresultate systematisch.
Sie kann erfolgreich eingesetzt werden, wenn zwei Dinge zusammenkommen: Das Problem besteht aus vielen gleichartigen Teilproblemen. Und eine optimale Lösung des Problems setzt sich aus optimalen Lösungen der Teilprobleme zusammen. Das nennt man Optimalitätsprinzip von Bellman. Es gilt bei einigen Optimierungsproblemen, nicht bei allen.
Dann rechnest du zuerst die kleinsten Teilprobleme direkt aus und setzt daraus nächstgrößere zusammen, bis das ursprüngliche Problem gelöst ist. Einmal berechnete Teilergebnisse stehen in einer Tabelle. Kommt ein gleichartiges Teilproblem noch einmal vor, nutzt du die gespeicherte Lösung, statt sie neu zu berechnen. Das senkt die Laufzeit.
Definition
Optimalitätsprinzip von Bellman
Bei einigen Optimierungsproblemen setzt sich jede Optimallösung aus optimalen Teillösungen zusammen. Auf diesem Prinzip basieren Algorithmen der dynamischen Programmierung.
Beispiel: Kürzester Weg im Straßennetz
- 1Ein Straßennetz hat die Städte A, B, X und Y.
- 2Der kürzeste Weg P von A nach B führt durch X und Y.
- 3Dann muss P zwischen X und Y auch einen kürzesten Teilweg verwenden.
- 4Sonst könnte man P mit einem kürzeren Teilweg verkürzen. Dann wäre P kein kürzester Weg gewesen.
Merke: Dynamische Programmierung kann erfolgreich eingesetzt werden, wenn ein Optimierungsproblem aus vielen gleichartigen Teilproblemen besteht und sich eine optimale Lösung aus optimalen Teillösungen zusammensetzt.
Teste dich
Die Fibonacci-Folge ist ein gutes Beispiel für Teilprobleme. Fₙ ist die n-te Fibonacci-Zahl. Die kleinsten Teilprobleme sind F₀ = 0 und F₁ = 1. Sie werden direkt festgelegt, ohne zu rechnen.
Alle weiteren Zahlen setzt du aus den beiden vorherigen zusammen: Fₙ = Fₙ₋₁ + Fₙ₋₂ für n ≥ 2. So entsteht aus kleineren Teilproblemen das nächstgrößere.
Was passiert, wenn man diese Regel direkt als rekursives Programm schreibt? Dann wird fib(4) zu fib(3) + fib(2), und fib(3) wird wieder zu fib(2) + fib(1). Die Berechnung von fib(2) kommt also doppelt vor. Bei größeren Zahlen nehmen solche Mehrfachberechnungen exponentiell zu, das heißt: Sie wachsen mit jedem größeren n immer schneller. Bei einer einfachen rekursiven Variante wären es für n = 43 über 1,4 Milliarden Aufrufe. Das wollen wir vermeiden: Jedes Teilergebnis soll nur einmal berechnet werden.
| Teilproblem | Wert | Wie entsteht er? |
|---|---|---|
| F₀ | 0 | direkt festgelegt |
| F₁ | 1 | direkt festgelegt |
| F₂ | 1 | F₁ + F₀ |
| F₃ | 2 | F₂ + F₁ |
Beispiel: Aufrufe bei fib(4)
- 1fib(4)
- 2fib(3) + fib(2)
- 3(fib(2) + fib(1)) + fib(2)
- 4((fib(1) + fib(0)) + fib(1)) + fib(2)
- 5((fib(1) + fib(0)) + fib(1)) + (fib(1) + fib(0))
- 6fib(2) wurde doppelt berechnet.
Teste dich
Es gibt zwei Wege, Mehrfachberechnungen zu vermeiden.
Weg 1: Memoisierung. Du speicherst die Rückgabewerte einer Funktion zwischen, statt sie neu zu berechnen. Anders als bei der dynamischen Programmierung bleibt dabei die grundlegende Vorgehensweise des Verfahrens erhalten: fib(n) ruft weiter fib(n − 1) und fib(n − 2) auf, schaut aber vorher im Speicher nach. In imperativen Programmiersprachen nutzt man dafür üblicherweise ein assoziatives Array, also einen Speicher, in dem zu jedem n der Wert abgelegt wird. Eine Funktion lässt sich nur memoisieren, wenn sie referenziell transparent ist: Bei gleichen Eingaben gibt sie immer dieselben Ausgaben zurück. Beim Fibonacci-Beispiel sinkt die Laufzeit auf O(n), falls sich Speicherreservierung und -initialisierung in O(n) vornehmen lassen. O(n) bedeutet: Die Laufzeit wächst etwa im gleichen Maß wie n.
Weg 2: Aufbauen ab den kleinsten Teilproblemen. Du berechnest zuerst die kleinen Fibonacci-Zahlen und baust daraus die größeren. Dafür reichen zwei Werte: der vorletzte und der letzte. In jedem Durchlauf bildest du daraus den neuen Wert (vorletzter + letzter) und schiebst beide weiter. Jede Berechnung geschieht nur einmal, deshalb ist die Laufzeit O(n). Kostspielige Rekursionen fallen weg.
| Merkmal | Memoisierung | Aufbauen von unten |
|---|---|---|
| Vorgehensweise | bleibt wie im ursprünglichen Verfahren | startet bei den kleinsten Teilproblemen und geht aufwärts |
| Zwischenergebnisse | Rückgabewerte werden zwischengespeichert | Teilergebnisse werden gespeichert, hier genügen die letzten zwei Werte |
| Bei Fibonacci | fib ruft sich weiter selbst auf, schaut aber zuerst im Speicher nach | Schleife berechnet F₂, F₃, … bis Fₙ |
Merke: Die Memoisierung behält die Vorgehensweise bei und merkt sich Rückgabewerte. Das Aufbauen startet bei den kleinsten Teilproblemen und geht aufwärts.
Teste dich
Ein Greedy-Algorithmus (gieriger Algorithmus) wählt Schritt für Schritt den Folgezustand, der zum Zeitpunkt der Wahl das beste Ergebnis verspricht. Er ist oft schnell, löst viele Probleme aber nicht optimal.
Das hängt mit seinem Vorgehen zusammen: Er hat in jedem Schritt nur begrenzt Informationen, und einen ausgeführten Schritt nimmt er nicht mehr zurück. Was jetzt am besten aussieht, kann also später zu einer schlechteren Gesamtlösung führen. Die gefundene Lösung ist nicht notwendigerweise die global optimale, also die beste insgesamt. Oft ist sie nur ein lokales Optimum, also das Beste in der unmittelbaren Umgebung.
Bei der dynamischen Programmierung ist das Vorgehen anders: Sie berechnet optimale Lösungen der kleinsten Teilprobleme und setzt sie zu einer Lösung des nächstgrößeren Teilproblems zusammen. Ein Greedy-Algorithmus trifft dagegen nur die jeweils beste nächste Wahl.
Beispiel: Der Wanderer im Nebel
- 1Ein Wanderer will möglichst hoch hinaus, sieht im Nebel aber nur 5 Meter weit.
- 2Er geht immer zum höchsten Punkt in seiner Umgebung und schaut dort wieder um.
- 3Einen Schritt nimmt er nicht mehr zurück.
- 4Er erreicht wahrscheinlich den Gipfel eines kleinen Hügels statt den eines großen Berges.
- 5Je nach Ausgangssituation kann sich die gefundene Lösung stark unterscheiden.
Definition
Lokales Optimum
Eine Lösung, die in der unmittelbaren Umgebung die beste ist, aber nicht notwendigerweise die beste insgesamt (die global optimale).
Merke: Ein Greedy-Algorithmus wählt nur, was jetzt am besten aussieht, und nimmt Schritte nicht zurück. Seine Lösung ist nicht notwendigerweise global optimal.
Teste dich
Alles auf einen Blick
Dynamische Programmierung
Passende Probleme
Viele gleichartige Teilprobleme, und eine optimale Lösung setzt sich aus optimalen Teillösungen zusammen.
Fibonacci
F₀ = 0 und F₁ = 1 sind direkt bekannt, danach gilt Fₙ = Fₙ₋₁ + Fₙ₋₂.
Speichern
Einmal berechnete Teilergebnisse werden gespeichert und wiederverwendet.
Memoisierung
Die Vorgehensweise bleibt, Rückgabewerte werden zwischengespeichert.
Aufbauen
Von den kleinsten Teilproblemen aufwärts, jede Berechnung nur einmal.
Greedy
Wählt das jetzt beste Ergebnis, nimmt nichts zurück und findet oft nur ein lokales Optimum.
Musteraufgabe · Schritt für Schritt
F₈ von unten aufbauen
- 1Gegeben: F₀ = 0, F₁ = 1 und Fₙ = Fₙ₋₁ + Fₙ₋₂ für n ≥ 2. Gesucht: F₈.
- 2Direkt bekannt sind die kleinsten Teilprobleme F₀ = 0 und F₁ = 1.
- 3F₂ = 1 + 0 = 1 · F₃ = 1 + 1 = 2 · F₄ = 2 + 1 = 3
- 4F₅ = 3 + 2 = 5 · F₆ = 5 + 3 = 8 · F₇ = 8 + 5 = 13
- 5F₈ = 13 + 8 = 21
- 6Jeder Wert wurde nur einmal berechnet, immer aus den beiden letzten Werten.
Fehler finden
Prüfe die Aussagen zu Memoisierung und Aufbauen von unten In einer Zeile steckt ein Fehler. Tippe sie an.
Fehler in Zeile 4Die beiden Verfahren sind vertauscht. Richtig: Die Memoisierung behält die ursprüngliche Vorgehensweise bei. Das Aufbauen startet bei den kleinsten Teilproblemen und setzt daraus größere zusammen.
Lückentext
Wähl in jeder Lücke das passende Wort und prüf dann deine Antworten.
In der dynamischen Programmierung werden zuerst die Teilprobleme direkt berechnet. Die behält die ursprüngliche Vorgehensweise des Verfahrens bei. Ein Greedy-Algorithmus findet bei vielen Problemen nur ein Optimum.
Karteikasten
Erst selbst überlegen, dann umdrehen.
Übung mit Feedback