Dynamische Programmierung
Dynamische Programmierung (englisch: Dynamic programming, abgekürzt DP) ist eine Methode, die in Mathematik, Managementwissenschaft, Informatik, Wirtschaftswissenschaften und Bioinformatik verwendet wird und komplexe Probleme löst, indem sie das ursprüngliche Problem in relativ einfache Teilprobleme zerlegt.
Stellen Sie sich vor, Sie steigen eine
nTreppe mit n Stufen. Jedes Mal können Sie1eine Stufe oder2Stufen.Also, wie viele verschiedene Möglichkeiten gibt es, bis zur Spitze des Gebäudes zu steigen?
Wenn Sie vom obersten Ziel (der
nn-ten Stufe) ausgehen, mag es kompliziert erscheinen.Aber wenn wir unsere Perspektive ändern:Um die
nn. Um Stufe n zu erreichen, können Sie nurn-1einen Schritt nach oben von Stufe n-1 aus gehen, oder von dern-2Ebene, indem man zwei Stufen nach oben steigt.. Um also dienDie Anzahl der Möglichkeiten, Stufe n zu erreichen, ist gleich der Anzahl der Möglichkeiten, Stufen-1n-1 plus die Anzahl der Möglichkeiten, Stufen-2Ebene, die Anzahl der Möglichkeiten.
Diese Art vonZerlegen Sie ein großes Problem in mehrere überlappende Teilprobleme und vermeiden Sie wiederholte Berechnungen, indem Sie die Lösungen der Teilprobleme speichern, wodurch das ursprüngliche Problem effizient gelöst wird.Die Methode ist die Dynamische Programmierung (kurz DP).
Kernidee
Der Kern der dynamischen Programmierung istdas Merken der bereits berechneten Lösungen. 。
Dynamische Programmierung wird üblicherweise verwendet, um Probleme mit den folgenden Eigenschaften zu lösen:
- überlappende Teilprobleme: Während des rekursiven Lösungsprozesses kann dasselbe Teilproblem mehrfach berechnet werden.
- Optimale Substruktur: Die optimale Lösung eines Problems enthält die optimalen Lösungen seiner Teilprobleme.
Wir können ein einfaches Flussdiagramm verwenden, um den Lösungsansatz der dynamischen Programmierung zu verstehen:
Die obige Abbildung zeigt die typischen Schritte zum Lösen eines Problems der dynamischen Programmierung: Beginnen Sie mit der Definition des Problems, finden Sie durch Analyse die Teilproblemstruktur, formalisieren Sie diese dann mit Zuständen und Zustandsübergangsgleichungen, und erhalten Sie schließlich die Antwort durch klare Anfangsbedingungen und Berechnungsreihenfolge.
Von der Rekursion zur dynamischen Programmierung: das Treppensteigproblem
Lass uns das klassische"Climbing Stairs"-Problem verwenden, um den Optimierungsprozess von der Rekursion zur dynamischen Programmierung nachzuvollziehen.
Problembeschreibung
Angenommen, du steigst Treppen. Du benötigstnStufen, bevor du oben ankommst. Jedes Mal kannst du1oder2Stufen steigen. Auf wie viele verschiedene Arten kannst du nach oben steigen?
Methode 1: Brute-Force-Rekursion (ineffizient)
Basierend auf der Analyse am Anfang können wir direkt die rekursive Formel schreiben:f(n) = f(n-1) + f(n-2)wobei,f(1) = 1, f(2) = 2。
Beispiel
"""Rekursive Lösung"""
if n == 1:
return 1
if n == 2:
return 2
return climb_stairs_recursive(n - 1) + climb_stairs_recursive(n - 2)
# Test
print(f"Anzahl der Möglichkeiten, zur 5. Stufe zu steigen (Rekursion): {climb_stairs_recursive(5)}") # Ausgabe: 8
Analyse der Nachteile: Diese Rekursion beinhaltet eine große Menge wiederholter Berechnungen. Zum Beispiel die Berechnung vonf(5)erfordertf(4)undf(3), Berechnungf(4)erfordert wiederumf(3)undf(2), hierf(3)Es wird zweimal berechnet. Wennnn ist groß, die Zeitkomplexität ist exponentiell.O(2^n), was äußerst ineffizient ist.
Methode 2: Rekursion mit Memoisierung (Top-down)
Wir können ein Array (ein "Memo") verwenden, um bereits berechnete Ergebnisse zu speichern und wiederholte Berechnungen zu vermeiden.
Beispiel
"""Rekursion mit Memoisierung (Top-down)"""
# Initialisiere die Memoisierungstabelle; -1 bedeutet nicht berechnet
memo = [-1] * (n + 1)
def helper(x: int) -> int:
# Basisfall
if x == 1:
return 1
if x == 2:
return 2
# Falls bereits berechnet, Ergebnis direkt zurückgeben
if memo[x] != -1:
return memo[x]
# Andernfalls berechne es und speichere es in der Memoisierungstabelle
memo[x] = helper(x - 1) + helper(x - 2)
return memo[x]
return helper(n)
print(f"Anzahl der Möglichkeiten, zur 5. Stufe zu gelangen (Memoisierung): {climb_stairs_memo(5)}") # Ausgabe: 8
Die Zeitkomplexität dieser Methode reduziert sich aufO(n), da jedes Teilproblem nur einmal berechnet wird. Die Speicherkomplexität istO(n). Dies ist bereits ein Ansatz der dynamischen Programmierung, genannt"Top-Down"oder"memoisierte Suche"。
Methode 3: Iteratives Array (Bottom-up, Standard-DP)
Wir starten direkt beim kleinsten Teilproblem und arbeiten uns Schritt für Schritt zum ursprünglichen Problem vor. Üblicherweise wird ein Array verwendetdpum Zustände zu speichern.
Beispiel
"""Dynamische Programmierung (Bottom-up)"""
if n <= 2:
return n
# 1. Definiere das Zustandsarray: dp[i] repräsentiert die Anzahl der Möglichkeiten, zur i-ten Stufe zu gelangen
dp = [0] * (n + 1)
# 2. Bestimme die Anfangsbedingungen
dp[1] = 1
dp[2] = 2
# 3. Zustandsübergang: leite rekursiv gemäß der Zustandsübergangsgleichung ab
for i in range(3, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
# 4. Gib das Endergebnis zurück
return dp[n]
print(f"Anzahl der Möglichkeiten, zur 5. Stufe zu steigen (DP): {climb_stairs_dp(5)}") # Ausgabe: 8
Methode 4: Speicheroptimierung (rollendes Array)
Beobachte die Zustandsübergangsgleichungdp[i] = dp[i-1] + dp[i-2], stellen wir fest, dass der aktuelle Zustand nur von den beiden vorherigen Zuständen abhängt. Daher müssen wir nicht das gesamtedpArray speichern; verwenden Sie einfach zwei Variablen, um sie rollierend zu aktualisieren.
Beispiel
"""Dynamische Programmierung (speicheroptimierte Version)"""
if n <= 2:
return n
# Verwenden Sie nur zwei Variablen, um dp[i-1] und dp[i-2] darzustellen
prev, curr = 1, 2 # Entsprechen dp[1], dp[2]
for i in range(3, n + 1):
# Den neuen aktuellen Wert berechnen
new_curr = prev + curr
# Variablen rollierend aktualisieren, Vorbereitung für die nächste Schleifeniteration
prev, curr = curr, new_curr
return curr
print(f"Anzahl der Möglichkeiten, zur 5. Stufe zu gelangen (optimierte DP): {climb_stairs_optimized(5)}") # Ausgabe: 8
Nach der Optimierung fällt die Speicherkomplexität vonO(n)aufO(1)。
Problemlösungsframework für dynamische Programmierung
Mithilfe des Treppensteigproblems können wir einen allgemeingültigen Vier-Schritt-Prozess zur Lösung von Problemen der dynamischen Programmierung zusammenfassen:
Schritt 1: Den Zustand definieren
Verwenden Sie ein oder mehrere Arrays (normalerweise genanntdp) um die Lösungen von Teilproblemen darzustellen. Der Schlüssel liegt darin, klar herauszufinden:dp[i]oderdp[i][j]Was repräsentiert es?
Beim Treppensteigproblem definieren wir
dp[i]als "Aufstieg zuridie Gesamtzahl der Möglichkeiten, Stufe n zu erreichen.
Schritt 2: Die Zustandsübergangsgleichung bestimmen
findedp[i]mit dem vorherigen Zustand (wie z. B.dp[i-1], dp[i-2]die Beziehung zwischen ihnen. Dies ist der Kern und die Schwierigkeit der dynamischen Programmierung.
Beim Treppensteigen-Problem lautet die Zustandsübergangsgleichung:
dp[i] = dp[i-1] + dp[i-2]。
Schritt 3: Bestimme die Anfangsbedingungen (Basisfall).
Die Lösungen der kleinsten, unteilbaren Teilprobleme. Dies ist der Ausgangspunkt der Rekurrenz und muss manuell definiert werden.
Beim Treppensteigen-Problem lauten die Anfangsbedingungen:
dp[1] = 1,dp[2] = 2。
Schritt 4: Bestimme die Berechnungsreihenfolge und berechne
Bestimme, ob es sich um "Top-down" (memoisierte Rekursion) oder "Bottom-up" (iterative Rekurrenz) handelt, und stelle sicher, dass beim Berechnen vondp[i], alle Teilprobleme, von denen es abhängt, bereits berechnet wurden.
Beim Treppensteigen-Problem verwenden wir einen Bottom-up-Ansatz und beginnen bei
i=3Schleife bisi=n。
Klassisches Beispiel: Fibonacci-Folge
Die Fibonacci-Folge ist das direkteste Beispiel für dynamische Programmierung; ihre Definition selbst ist die Zustandsübergangsgleichung.
Problem: Finde die n-te Fibonacci-ZahlnGlied.F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2) (n>=2)
Beispiel
"""Berechne Fibonacci-Zahlen mit dynamischer Programmierung"""
if n < 2:
return n
# Speicheroptimierte Implementierung
prev, curr = 0, 1 # F(0), F(1)
for _ in range(2, n + 1):
prev, curr = curr, prev + curr
return curr
# Testdaten
test_n = 10
print(f"Das {test_n}. Glied der Fibonacci-Folge ist: {fibonacci(test_n)}") # Ausgabe: 55
Fortgeschrittenes Beispiel: Münzwechsel
Dies ist ein typischeres Problem, das optimale Teilstruktur demonstriert.
Problembeschreibung: Gegeben ist ein ganzzahliges Arraycoins, das Münzen unterschiedlicher Nennwerte repräsentiert, und eine Ganzzahlamount, die den Gesamtbetrag repräsentiert. Berechne und gib die minimale Anzahl von Münzen zurück, die benötigt wird, um den Gesamtbetrag zu bilden. Wenn keine Kombination von Münzen den Gesamtbetrag bilden kann, gib-1. Du kannst annehmen, dass die Menge jeder Münze unbegrenzt ist.
Problemanalyse und DP-Design
- Zustand definieren:
dp[i]steht für das Bilden des Gesamtbetragsidie minimale Anzahl benötigter Münzen. - Zustandsübergangsgleichung: Für den Betrag
i, können wir über jeden Münznennwert iterierencoin. Wenncoin <= i, dann den Betrag bildeniEine mögliche Vorgehensweise ist: zuerst den Betrag bildeni - coin, dann eine Münze hinzufügencoinNennwert der Münze. Was wir finden müssen, ist das Minimum unter allen Möglichkeiten. Daher kann die Gleichung geschrieben werden als:dp[i] = min(dp[i], dp[i - coin] + 1), für alle, die erfüllencoin <= ivoncoin。 - Anfangsbedingungen:
dp[0] = 0, das Bilden von Betrag 0 erfordert 0 Münzen. Die anderendp[i]auf eine sehr große Zahl initialisiert (z. B.amount + 1oderfloat('inf')), was bedeutet, dass er vorerst nicht gebildet werden kann. - Berechnungsreihenfolge: von unten nach oben, von
i=1Berechne bisi=amount。
Code-Implementierung
Beispiel
"""Münzwechsel - Dynamische Programmierung"""
# Initialisiere das dp-Array, wobei dp[i] die minimale Anzahl an Münzen für Betrag i darstellt
# Setze einen unerreichbaren Anfangswert amount + 1
dp = [amount + 1] * (amount + 1)
dp[0] = 0 # Wenn der Betrag 0 ist, werden 0 Münzen benötigt
# Äußere Schleife: über alle Betragszustände iterieren
for i in range(1, amount + 1):
# Innere Schleife: über alle Münzoptionen iterieren
for coin in coins:
# Wenn der aktuelle Münznennwert kleiner oder gleich dem aktuellen Betrag ist, die Verwendung dieser Münze in Betracht ziehen
if coin <= i:
# Zustandsübergang: dp[i] ist die Lösung ohne Verwendung der aktuellen Münze und
# der kleinere Wert unter den Lösungen, die die aktuelle Münze verwenden (d. h. dp[i-coin] + 1)
dp[i] = min(dp[i], dp[i - coin] + 1)
# Wenn dp[amount] nicht aktualisiert wurde (noch der Anfangswert), bedeutet das, dass der Betrag nicht gebildet werden kann
return dp[amount] if dp[amount] <= amount else -1
# Testdaten
coins = [1, 2, 5]
amount = 11
print(f"Die minimale Anzahl an Münzen, die benötigt wird, um den Gesamtbetrag {amount} zu bilden, ist: {coin_change(coins, amount)}") # Ausgabe: 3 (5+5+1)
Übungsaufgaben
Bitte versuchen Sie, die folgenden Probleme selbstständig zu lösen, um Ihr Verständnis der dynamischen Programmierung zu festigen:
Übung 1: Maximale Teilsumme
Gegeben ist ein ganzzahliges Arraynums, bitte finden Sie ein zusammenhängendes Teilarray mit der maximalen Summe und geben Sie dessen maximale Summe zurück.
Beispiel:
输入:nums = [-2,1,-3,4,-1,2,1,-5,4] 输出:6 解释:连续子数组 [4,-1,2,1] 的和最大,为 6.
Hinweis:
- Definition
dp[i]als "mit deridie maximale Summe eines zusammenhängenden Teilarrays, das mit dem Element endet". - Zustandsübergangsgleichung:
dp[i] = max(nums[i], dp[i-1] + nums[i])。 - Die endgültige Antwort ist alles
dp[i]das Maximum unter ihnen.
Übung 2: Längste aufsteigende Teilfolge
Gegeben ist ein ganzzahliges Arraynums, finde die Länge der längsten streng monoton steigenden Teilfolge.
Beispiel:
输入:nums = [10,9,2,5,3,7,101,18] 输出:4 解释:最长递增子序列是 [2,3,7,101],因此长度为 4.
Hinweis:
- Definition
dp[i]als "mit deridie Länge der längsten aufsteigenden Teilfolge, die mit der Zahl endet". - Für jedes
i, muss man durchlaufenjfrom0aufi-1, wennnums[i] > nums[j], danndp[i] = max(dp[i], dp[j] + 1)。 - Anfangs enthält jede
dp[i]mindestens 1 (enthält nur sich selbst).
