Was ist dynamische Programmierung ?
Wie unterscheidet es sich von Rekursion, Memoisierung usw.?
Ich habe den Wikipedia-Artikel darüber gelesen , aber ich verstehe ihn immer noch nicht wirklich.
Was ist dynamische Programmierung ?
Wie unterscheidet es sich von Rekursion, Memoisierung usw.?
Ich habe den Wikipedia-Artikel darüber gelesen , aber ich verstehe ihn immer noch nicht wirklich.
Antworten:
Dynamische Programmierung ist, wenn Sie das Wissen der Vergangenheit nutzen, um die Lösung eines zukünftigen Problems zu vereinfachen.
Ein gutes Beispiel ist das Lösen der Fibonacci-Sequenz für n = 1.000.002.
Dies wird ein sehr langer Prozess sein, aber was ist, wenn ich Ihnen die Ergebnisse für n = 1.000.000 und n = 1.000.001 gebe? Plötzlich wurde das Problem leichter zu bewältigen.
Dynamische Programmierung wird häufig bei Zeichenfolgenproblemen verwendet, z. B. beim Problem der Zeichenfolgenbearbeitung. Sie lösen eine Teilmenge (n) des Problems und verwenden diese Informationen dann, um das schwierigere ursprüngliche Problem zu lösen.
Mit der dynamischen Programmierung speichern Sie Ihre Ergebnisse im Allgemeinen in einer Art Tabelle. Wenn Sie die Antwort auf ein Problem benötigen, verweisen Sie auf die Tabelle und prüfen, ob Sie bereits wissen, um was es sich handelt. Wenn nicht, verwenden Sie die Daten in Ihrer Tabelle, um sich einen Sprung in die Antwort zu geben.
Das Cormen-Algorithmus-Buch enthält ein großartiges Kapitel über dynamische Programmierung. UND es ist kostenlos bei Google Books! Schau es dir hier an.
Dynamische Programmierung ist eine Technik, mit der vermieden wird, dass in einem rekursiven Algorithmus das gleiche Teilproblem mehrfach berechnet wird.
Nehmen wir das einfache Beispiel der Fibonacci-Zahlen: Finden der n- ten Fibonacci-Zahl, definiert durch
F n = F n-1 + F n-2 und F 0 = 0, F 1 = 1
Der offensichtliche Weg, dies zu tun, ist rekursiv:
def fibonacci(n):
if n == 0:
return 0
if n == 1:
return 1
return fibonacci(n - 1) + fibonacci(n - 2)
Die Rekursion führt viele unnötige Berechnungen durch, da eine bestimmte Fibonacci-Zahl mehrmals berechnet wird. Eine einfache Möglichkeit, dies zu verbessern, besteht darin, die Ergebnisse zwischenzuspeichern:
cache = {}
def fibonacci(n):
if n == 0:
return 0
if n == 1:
return 1
if n in cache:
return cache[n]
cache[n] = fibonacci(n - 1) + fibonacci(n - 2)
return cache[n]
Ein besserer Weg, dies zu tun, besteht darin, die Rekursion insgesamt zu beseitigen, indem die Ergebnisse in der richtigen Reihenfolge ausgewertet werden:
cache = {}
def fibonacci(n):
cache[0] = 0
cache[1] = 1
for i in range(2, n + 1):
cache[i] = cache[i - 1] + cache[i - 2]
return cache[n]
Wir können sogar konstanten Platz nutzen und nur die notwendigen Teilergebnisse auf dem Weg speichern:
def fibonacci(n):
fi_minus_2 = 0
fi_minus_1 = 1
for i in range(2, n + 1):
fi = fi_minus_1 + fi_minus_2
fi_minus_1, fi_minus_2 = fi, fi_minus_1
return fi
Wie wende ich dynamische Programmierung an?
Die dynamische Programmierung funktioniert im Allgemeinen bei Problemen mit einer inhärenten Reihenfolge von links nach rechts, wie z. B. Zeichenfolgen, Bäume oder ganzzahlige Sequenzen. Wenn der naive rekursive Algorithmus nicht mehrmals dasselbe Teilproblem berechnet, hilft die dynamische Programmierung nicht weiter.
Ich habe eine Sammlung von Problemen erstellt, um die Logik besser zu verstehen: https://github.com/tristanguigue/dynamic-programing
if n in cache
wie im Beispiel von oben nach unten oder fehlt mir etwas?
Memoization ist das Speichern von vorherigen Ergebnissen eines Funktionsaufrufs (eine echte Funktion gibt bei gleichen Eingaben immer dasselbe zurück). Es macht keinen Unterschied für die algorithmische Komplexität, bevor die Ergebnisse gespeichert werden.
Rekursion ist die Methode einer Funktion, die sich selbst aufruft, normalerweise mit einem kleineren Datensatz. Da die meisten rekursiven Funktionen in ähnliche iterative Funktionen konvertiert werden können, spielt dies auch für die algorithmische Komplexität keine Rolle.
Dynamische Programmierung ist der Prozess, bei dem einfach zu lösende Teilprobleme gelöst und daraus die Antwort erstellt werden. Die meisten DP-Algorithmen befinden sich in der Laufzeit zwischen einem Greedy-Algorithmus (falls vorhanden) und einem exponentiellen Algorithmus (alle Möglichkeiten auflisten und den besten finden).
Es ist eine Optimierung Ihres Algorithmus, die die Laufzeit verkürzt.
Während ein Greedy-Algorithmus normalerweise als naiv bezeichnet wird , weil er möglicherweise mehrmals über denselben Datensatz ausgeführt wird, vermeidet die dynamische Programmierung diese Gefahr durch ein tieferes Verständnis der Teilergebnisse, die gespeichert werden müssen, um die endgültige Lösung zu erstellen.
Ein einfaches Beispiel ist das Durchlaufen eines Baums oder einer Grafik nur durch die Knoten, die zur Lösung beitragen würden, oder das Einfügen der bisher gefundenen Lösungen in eine Tabelle, damit Sie nicht immer wieder dieselben Knoten durchlaufen müssen.
Hier ist ein Beispiel für ein Problem, das für die dynamische Programmierung geeignet ist, von UVAs Online-Richter: Edit Steps Ladder.
Ich werde kurz auf den wichtigen Teil der Analyse dieses Problems eingehen, der aus dem Buch Programmierherausforderungen stammt. Ich schlage vor, dass Sie es sich ansehen.
Schauen Sie sich dieses Problem genau an. Wenn wir eine Kostenfunktion definieren, die uns sagt, wie weit zwei Zeichenfolgen voneinander entfernt sind, müssen wir zwei die drei natürlichen Arten von Änderungen berücksichtigen:
Ersetzung - Ändern Sie ein einzelnes Zeichen von Muster "s" in ein anderes Zeichen im Text "t", z. B. "Schuss" in "Punkt".
Einfügen - Fügen Sie ein einzelnes Zeichen in das Muster "s" ein, damit es mit dem Text "t" übereinstimmt, z. B. "vor" in "agog" ändern.
Löschen - Löschen Sie ein einzelnes Zeichen aus dem Muster "s", damit es mit dem Text "t" übereinstimmt, z. B. "Stunde" in "unser" ändern.
Wenn wir festlegen, dass jede dieser Operationen einen Schritt kostet, definieren wir den Bearbeitungsabstand zwischen zwei Zeichenfolgen. Wie berechnen wir das?
Wir können einen rekursiven Algorithmus definieren, indem wir beobachten, dass das letzte Zeichen in der Zeichenfolge entweder übereinstimmen, ersetzt, eingefügt oder gelöscht werden muss. Wenn Sie die Zeichen in der letzten Bearbeitungsoperation abschneiden, bleibt bei einer Paaroperation ein Paar kleinerer Zeichenfolgen übrig. Sei i und j das letzte Zeichen des relevanten Präfixes von bzw. t. Nach der letzten Operation gibt es drei Paare kürzerer Zeichenfolgen, die der Zeichenfolge nach einer Übereinstimmung / Ersetzung, Einfügung oder Löschung entsprechen. Wenn wir die Kosten für die Bearbeitung der drei Paare kleinerer Zeichenfolgen kennen würden, könnten wir entscheiden, welche Option zur besten Lösung führt, und diese Option entsprechend auswählen. Wir können diese Kosten durch die großartige Sache lernen, die Rekursion ist:
#define MATCH 0 /* enumerated type symbol for match */ #define INSERT 1 /* enumerated type symbol for insert */ #define DELETE 2 /* enumerated type symbol for delete */ int string_compare(char *s, char *t, int i, int j) { int k; /* counter */ int opt[3]; /* cost of the three options */ int lowest_cost; /* lowest cost */ if (i == 0) return(j * indel(’ ’)); if (j == 0) return(i * indel(’ ’)); opt[MATCH] = string_compare(s,t,i-1,j-1) + match(s[i],t[j]); opt[INSERT] = string_compare(s,t,i,j-1) + indel(t[j]); opt[DELETE] = string_compare(s,t,i-1,j) + indel(s[i]); lowest_cost = opt[MATCH]; for (k=INSERT; k<=DELETE; k++) if (opt[k] < lowest_cost) lowest_cost = opt[k]; return( lowest_cost ); }
Dieser Algorithmus ist korrekt, aber auch unglaublich langsam.
Wenn Sie auf unserem Computer laufen, dauert es einige Sekunden, um zwei Zeichenfolgen mit 11 Zeichen zu vergleichen, und die Berechnung verschwindet in einem Niemals-Niemals-Land auf etwas längerem.
Warum ist der Algorithmus so langsam? Es dauert exponentiell, weil es Werte immer wieder neu berechnet. An jeder Position in der Zeichenfolge verzweigt sich die Rekursion auf drei Arten, was bedeutet, dass sie mit einer Geschwindigkeit von mindestens 3 ^ n wächst - sogar noch schneller, da die meisten Aufrufe nur einen der beiden Indizes reduzieren, nicht beide.
Wie können wir den Algorithmus praktisch machen? Die wichtige Beobachtung ist, dass die meisten dieser rekursiven Aufrufe Dinge berechnen, die bereits zuvor berechnet wurden. Woher wissen wir? Nun, es kann nur | s | geben · | T | mögliche eindeutige rekursive Aufrufe, da nur so viele unterschiedliche (i, j) Paare als Parameter für rekursive Aufrufe dienen.
Indem wir die Werte für jedes dieser (i, j) Paare in einer Tabelle speichern, können wir vermeiden, sie neu zu berechnen, und sie einfach nach Bedarf nachschlagen.
Die Tabelle ist eine zweidimensionale Matrix m, in der jedes der | s | · | t | Zellen enthält die Kosten für die optimale Lösung dieses Teilproblems sowie einen übergeordneten Zeiger, der erklärt, wie wir zu diesem Ort gekommen sind:
typedef struct { int cost; /* cost of reaching this cell */ int parent; /* parent cell */ } cell; cell m[MAXLEN+1][MAXLEN+1]; /* dynamic programming table */
Die dynamische Programmierversion weist drei Unterschiede zur rekursiven Version auf.
Erstens erhält es seine Zwischenwerte mithilfe der Tabellensuche anstelle von rekursiven Aufrufen.
** Zweitens ** wird das übergeordnete Feld jeder Zelle aktualisiert, sodass wir die Bearbeitungssequenz später rekonstruieren können.
** Drittens, ** Drittens wird es mit einer allgemeineren
cell()
Zielfunktion instrumentiert, anstatt nur m [| s |] [| t |] .cost zurückzugeben. Dies ermöglicht es uns, diese Routine auf eine breitere Klasse von Problemen anzuwenden.
Eine ganz besondere Analyse dessen, was erforderlich ist, um die optimalsten Teilergebnisse zu erzielen, macht die Lösung zu einer "dynamischen".
Hier ist eine alternative, vollständige Lösung für dasselbe Problem. Es ist auch eine "dynamische", obwohl seine Ausführung anders ist. Ich schlage vor, dass Sie überprüfen, wie effizient die Lösung ist, indem Sie sie dem Online-Richter von UVA vorlegen. Ich finde es erstaunlich, wie effizient ein so schweres Problem angegangen wurde.
Die Schlüsselbits der dynamischen Programmierung sind "überlappende Unterprobleme" und "optimale Unterstruktur". Diese Eigenschaften eines Problems bedeuten, dass eine optimale Lösung aus den optimalen Lösungen für seine Unterprobleme besteht. Beispielsweise weisen Probleme mit kürzesten Wegen eine optimale Unterstruktur auf. Der kürzeste Weg von A nach C ist der kürzeste Weg von A zu einem Knoten B, gefolgt vom kürzesten Weg von diesem Knoten B nach C.
Um ein Problem mit dem kürzesten Weg zu lösen, gehen Sie genauer vor:
Da wir von unten nach oben arbeiten, haben wir bereits Lösungen für die Unterprobleme, wenn es darum geht, sie zu verwenden, indem wir sie auswendig lernen.
Denken Sie daran, dass dynamische Programmierprobleme sowohl überlappende Unterprobleme als auch eine optimale Unterstruktur aufweisen müssen. Das Erzeugen der Fibonacci-Sequenz ist kein dynamisches Programmierproblem. Es verwendet Memoization, weil es überlappende Unterprobleme hat, aber keine optimale Unterstruktur (weil es kein Optimierungsproblem gibt).
Dynamische Programmierung
Definition
Dynamische Programmierung (DP) ist eine allgemeine Algorithmus-Entwurfstechnik zum Lösen von Problemen mit überlappenden Unterproblemen. Diese Technik wurde in den 1950er Jahren vom amerikanischen Mathematiker „Richard Bellman“ erfunden.
Schlüsselidee
Die Schlüsselidee besteht darin, Antworten auf überlappende kleinere Unterprobleme zu speichern, um eine Neuberechnung zu vermeiden.
Dynamische Programmiereigenschaften
Ich bin auch sehr neu in der dynamischen Programmierung (ein leistungsfähiger Algorithmus für bestimmte Arten von Problemen).
In den einfachsten Worten denken Sie einfach an dynamische Programmierung als rekursiven Ansatz unter Verwendung der Vorkenntnisse
Vorwissen ist hier am wichtigsten. Behalten Sie die Lösung der bereits vorhandenen Unterprobleme im Auge.
Betrachten Sie dieses grundlegendste Beispiel für dp aus Wikipedia
Finden der Fibonacci-Sequenz
function fib(n) // naive implementation
if n <=1 return n
return fib(n − 1) + fib(n − 2)
Lassen Sie uns den Funktionsaufruf mit say n = 5 abbrechen
fib(5)
fib(4) + fib(3)
(fib(3) + fib(2)) + (fib(2) + fib(1))
((fib(2) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1))
(((fib(1) + fib(0)) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1))
Insbesondere wurde fib (2) dreimal von Grund auf neu berechnet. In größeren Beispielen werden viel mehr Werte von fib oder Unterproblemen neu berechnet, was zu einem exponentiellen Zeitalgorithmus führt.
Versuchen wir es jetzt, indem wir den Wert, den wir bereits in einer Datenstruktur gefunden haben, beispielsweise eine Karte, speichern
var m := map(0 → 0, 1 → 1)
function fib(n)
if key n is not in map m
m[n] := fib(n − 1) + fib(n − 2)
return m[n]
Hier speichern wir die Lösung von Unterproblemen in der Karte, falls wir sie noch nicht haben. Diese bereits berechnete Technik zum Speichern von Werten wird als Memoization bezeichnet.
Versuchen Sie schließlich für ein Problem zunächst, die Zustände zu finden (mögliche Unterprobleme und überlegen Sie sich den besseren Rekursionsansatz, damit Sie die Lösung des vorherigen Unterproblems in weitere verwenden können).
Dynamische Programmierung ist eine Technik zum Lösen von Problemen mit überlappenden Unterproblemen. Ein dynamischer Programmieralgorithmus löst jedes Unterproblem nur einmal und speichert dann seine Antwort in einer Tabelle (Array). Vermeiden Sie die Arbeit, die Antwort jedes Mal neu zu berechnen, wenn das Unterproblem auftritt. Die Grundidee der dynamischen Programmierung lautet: Vermeiden Sie es, dasselbe Material zweimal zu berechnen, indem Sie normalerweise eine Tabelle mit bekannten Ergebnissen von Unterproblemen führen.
Die sieben Schritte bei der Entwicklung eines dynamischen Programmieralgorithmus sind wie folgt:
6. Convert the memoized recursive algorithm into iterative algorithm
ein obligatorischer Schritt? Dies würde bedeuten, dass seine endgültige Form nicht rekursiv ist?
Kurz gesagt, der Unterschied zwischen Rekursionsmemoisierung und dynamischer Programmierung
Dynamische Programmierung verwendet, wie der Name schon sagt, den zuvor berechneten Wert, um die nächste neue Lösung dynamisch zu erstellen
Anwendung der dynamischen Programmierung: Wenn Ihre Lösung auf einer optimalen Unterstruktur und einem überlappenden Unterproblem basiert, ist in diesem Fall die Verwendung des zuvor berechneten Werts hilfreich, damit Sie ihn nicht neu berechnen müssen. Es ist ein Bottom-up-Ansatz. Angenommen, Sie müssen fib (n) berechnen. In diesem Fall müssen Sie nur den zuvor berechneten Wert von fib (n-1) und fib (n-2) addieren.
Rekursion: Unterteilen Sie Ihr Problem grundsätzlich in kleinere Teile, um es problemlos zu lösen. Beachten Sie jedoch, dass eine Neuberechnung nicht vermieden wird, wenn derselbe Wert zuvor in einem anderen Rekursionsaufruf berechnet wurde.
Memoisierung: Grundsätzlich wird das Speichern des alten berechneten Rekursionswerts in der Tabelle als Memoisierung bezeichnet, wodurch eine Neuberechnung vermieden wird, wenn er bereits durch einen vorherigen Aufruf berechnet wurde, sodass jeder Wert einmal berechnet wird. Bevor wir berechnen, prüfen wir, ob dieser Wert bereits berechnet wurde oder nicht, wenn er bereits berechnet wurde. Dann geben wir ihn aus der Tabelle zurück, anstatt ihn neu zu berechnen. Es ist auch ein Top-Down-Ansatz
Hier ist ein einfaches Python - Code Beispiel Recursive
, Top-down
,Bottom-up
Ansatz für Fibonacci - Reihe:
def fib_recursive(n):
if n == 1 or n == 2:
return 1
else:
return fib_recursive(n-1) + fib_recursive(n-2)
print(fib_recursive(40))
def fib_memoize_or_top_down(n, mem):
if mem[n] is not 0:
return mem[n]
else:
mem[n] = fib_memoize_or_top_down(n-1, mem) + fib_memoize_or_top_down(n-2, mem)
return mem[n]
n = 40
mem = [0] * (n+1)
mem[1] = 1
mem[2] = 1
print(fib_memoize_or_top_down(n, mem))
def fib_bottom_up(n):
mem = [0] * (n+1)
mem[1] = 1
mem[2] = 1
if n == 1 or n == 2:
return 1
for i in range(3, n+1):
mem[i] = mem[i-1] + mem[i-2]
return mem[n]
print(fib_bottom_up(40))