Eine DP-Aufgabe erkennen
Kurz gesagt: Ein Problem ist etwas für die dynamische Programmierung, wenn es überlappende Teilprobleme und optimale Teilstruktur hat und nach „am meisten, am wenigsten, auf wie viele Arten“ fragt; das Rezept ist Zustand, Übergang, Basis, Reihenfolge, Antwort.
Problem
Welche Anfragen soll der Transporter annehmen?
Du hast einen Mietwagen. Für morgen gibt es sechs Transportanfragen, jede mit Start, Ende und Preis. Der Transporter kann nur an einem Ort sein, also können zwei Anfragen, die sich überschneiden, nicht beide angenommen werden. Welche nimmst du an, um am meisten zu verdienen?
Das Erste, was einem einfällt, ist eine gierige Regel: erst die teuerste Anfrage, dann die nächste, die nicht stört. Oder: die, die am frühesten endet, damit mehr Zeit bleibt:
function greedy(jobs, rule) { const taken = []; for (const job of [...jobs].sort(rule)) { const free = taken.every((t) => job.end <= t.start || t.end <= job.start); if (free) taken.push(job); } return taken;}
function show(rule, taken) { const sum = taken.reduce((s, job) => s + job.pay, 0); const names = taken.map((job) => job.name).join(", "); console.log(`${rule}: ${names} = ` + `${sum} €`);}show("Die teuersten zuerst", greedy(jobs, (a, b) => b.pay - a.pay));show("Das früheste Ende zuerst", greedy(jobs, (a, b) => a.end - b.end));Die teuersten zuerst: Flughafen, Sofa = 180 €Das früheste Ende zuerst: Markt, Büro, Reparatur = 170 €Beide Regeln lassen Geld liegen: Es gibt einen Plan, der mehr wert ist. Alle 2⁶ = 64 Teilmengen auszuprobieren funktioniert hier, aber bei 60 Anfragen sind es über eine Trillion. Die Aufgabe ist von derselben Art wie der Rucksack und Diff. Wie erkennst du das selbst, und wie baust du die Lösung?
Die Idee
Die Merkmale
Eine Aufgabe ist etwas für die dynamische Programmierung, wenn sie drei Dinge hat:
- Die Frage betrifft das Beste oder die Anzahl. „Am meisten“, „am wenigsten“, „auf wie viele Arten“, „geht es überhaupt“. Fragt sie nur „finde das Element“ oder „sortiere“, gibt es nichts zu wählen.
- Optimale Teilstruktur. Die beste Lösung des Ganzen wird aus den besten Lösungen kleinerer Teile gebaut. Beim Transporter: Ist die letzte Anfrage im besten Plan, ist der Rest der beste Plan der Anfragen, die vor ihr enden.
- Überlappende Teilprobleme. Dieselben kleineren Teile werden oft gefragt, wie beim naiven Fibonacci. Beim Transporter beginnen „Reparatur“ und „Flughafen“ beide um 15:00 Uhr und fragen dasselbe: das Meiste aus den Anfragen, die um 15:00 Uhr enden.
Sind alle drei da, wird die Antwort für jeden kleinen Teil einmal berechnet und aufgeschrieben.
Die Idee
Das Rezept in fünf Schritten
- Zustand. Was eine Zelle ist: „best[i] ist das meiste Geld aus den ersten i Anfragen, nach dem Ende sortiert“. Ein guter Zustand beschreibt die kleine Aufgabe mit ein oder zwei Zahlen.
- Übergang. Wie die Zelle aus kleineren berechnet wird. Für Anfrage i gibt es zwei Wahlen: sie auslassen (best[i − 1]) oder sie nehmen (ihr Preis + best[p], wobei p die Anzahl der Anfragen ist, die bis zu ihrem Start enden). best[i] ist die größere.
- Basis. Die Zellen, die von nichts abhängen: best[0] = 0, keine Anfragen, kein Geld.
- Reihenfolge. In welcher Reihenfolge gefüllt wird, damit das Nötige schon fertig ist: von links nach rechts, weil i nur i − 1 benutzt und p < i ist.
- Antwort. Welche Zelle die Antwort ist: best[n]. Willst du auch wissen, welche Anfragen, gehst du an den Entscheidungen entlang zurück, wie der Weg zurück im Diff.
Das Sortieren nach dem Ende gehört zum Zustand: So sind die mit der i-ten verträglichen Anfragen genau die ersten p, und „das Beste davon“ ist eine Zelle.
Genauer, für Neugierige
Dasselbe Rezept funktioniert auch von oben nach unten: eine Funktion best(i), die best(i − 1) und best(p) aufruft und das Ergebnis merkt (siehe Memoization). Die Tabelle von unten nach oben und die Rekursion mit Speicher berechnen dieselben Zellen. Die Rekursion liegt näher an den Worten des Übergangs, die Tabelle hat keinen tiefen Stapel.
Map oder ein Array mit undefined für „noch nicht berechnet“. Bei etwa 10.000 verschachtelten Aufrufen gibt es einen RangeError, deshalb ist bei langen Folgen die Tabelle sicherer.p lässt sich mit binärer Suche auf den Enden finden, weil sie sortiert sind: Dann ist das Ganze O(n log n). Die Schleife in der Lektion ist O(n) pro Anfrage, insgesamt O(n²), und für sechs Anfragen reicht das völlig.
Sieh es dir an
Das Rezept im Code
function bestPay(jobs) { const sorted = [...jobs].sort((a, b) => a.end - b.end); const best = new Array(sorted.length + 1).fill(0); for (let i = 1; i <= sorted.length; i++) { const job = sorted[i - 1]; let p = 0; while (sorted[p].end <= job.start) p++; const skip = best[i - 1]; const take = job.pay + best[p]; best[i] = Math.max(skip, take); } return best[sorted.length];}[...jobs].sort(...) sortiert eine Kopie, und die Eingabe bleibt, wie sie war. Die Schleife für p hält spätestens bei der i-ten Anfrage selbst an, weil sie nach ihrem Start endet.const jobs = [ { name: "Sofa", start: 9, end: 12, pay: 80 }, { name: "Flughafen", start: 15, end: 20, pay: 100 }, { name: "Markt", start: 8, end: 10, pay: 60 }, { name: "Lager", start: 12, end: 17, pay: 30 }, { name: "Büro", start: 11, end: 13, pay: 60 }, { name: "Reparatur", start: 15, end: 19, pay: 50 },];console.log("Höchstens: " + `${bestPay(jobs)} €`);Höchstens: 220 €function bestPay(jobs) { const sorted = [...jobs].sort((a, b) => a.end - b.end); const best = new Array(sorted.length + 1).fill(0); for (let i = 1; i <= sorted.length; i++) { const job = sorted[i - 1]; let p = 0; while (sorted[p].end <= job.start) p++; const skip = best[i - 1]; const take = job.pay + best[p]; best[i] = Math.max(skip, take); } return best[sorted.length];}Schritt für Schritt, in Worten
- Sechs Anfragen für einen Transporter. Zustand: Sortiere sie nach dem Ende; best[i] ist das meiste Geld aus den ersten i.
- Basis: best[0] = 0, keine Anfragen, kein Geld. Reihenfolge: von links nach rechts, jede Zelle braucht nur Zellen links von ihr.
- Anfrage 1, Markt 8–10 Uhr, 60 €: keine Anfrage endet vor ihrem Start, p = 0. Auslassen: best[0] = 0. Nehmen: 60 + best[0] = 60. Besser, sie zu nehmen: best[1] = 60.
- Anfrage 2, Sofa 9–12 Uhr, 80 €: keine Anfrage endet vor ihrem Start, p = 0. Auslassen: best[1] = 60. Nehmen: 80 + best[0] = 80. Besser, sie zu nehmen: best[2] = 80.
- Anfrage 3, Büro 11–13 Uhr, 60 €: eine endet bis 11:00 Uhr, p = 1. Auslassen: best[2] = 80. Nehmen: 60 + best[1] = 120. Besser, sie zu nehmen: best[3] = 120.
- Anfrage 4, Lager 12–17 Uhr, 30 €: 2 enden bis 12:00 Uhr, p = 2. Auslassen: best[3] = 120. Nehmen: 30 + best[2] = 110. Besser, sie auszulassen: best[4] = 120.
- Anfrage 5, Reparatur 15–19 Uhr, 50 €: 3 enden bis 15:00 Uhr, p = 3. Auslassen: best[4] = 120. Nehmen: 50 + best[3] = 170. Besser, sie zu nehmen: best[5] = 170.
- Anfrage 6, Flughafen 15–20 Uhr, 100 €: 3 enden bis 15:00 Uhr, p = 3. Auslassen: best[5] = 170. Nehmen: 100 + best[3] = 220. Besser, sie zu nehmen: best[6] = 220.
- Antwort: best[6] = 220 €. Entlang „nehmen“ zurück ergeben sich die Anfragen: Markt, Büro, Flughafen.
220 € aus Markt, Büro und Flughafen: mehr als sowohl „die teuerste zuerst“ als auch „das früheste Ende zuerst“.
Probier es aus
Finde den Fehler
Ein Kollege hat den Übergang kopiert. Das Programm sagt:
function bestPay(jobs) { const sorted = [...jobs].sort((a, b) => a.end - b.end); const best = new Array(sorted.length + 1).fill(0); for (let i = 1; i <= sorted.length; i++) { const job = sorted[i - 1]; let p = 0; while (sorted[p].end <= job.start) p++; const skip = best[i - 1]; const take = job.pay + best[i - 1]; best[i] = Math.max(skip, take); } return best[sorted.length];}const jobs = [ { name: "Sofa", start: 9, end: 12, pay: 80 }, { name: "Flughafen", start: 15, end: 20, pay: 100 }, { name: "Markt", start: 8, end: 10, pay: 60 }, { name: "Lager", start: 12, end: 17, pay: 30 }, { name: "Büro", start: 11, end: 13, pay: 60 }, { name: "Reparatur", start: 15, end: 19, pay: 50 },];console.log("Höchstens: " + `${bestPay(jobs)} €`);Höchstens: 380 €Das ist unmöglich: Alle sechs Anfragen zusammen passen nicht in einen Transporter. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
„Nehmen“ muss beim Besten unter den Anfragen weitermachen, die vor der i-ten enden: best[p]. best[i − 1] kann eine Anfrage enthalten, die noch läuft, wenn die i-te beginnt, und dann ist der Transporter an zwei Orten gleichzeitig. Richtig ist const take = job.pay + best[p];.
Die Idee
Eine zweite Aufgabe: Parken entlang der Strecke
Ein Lastwagen fährt vom Lager zum letzten Parkplatz an der Autobahn. Der Fahrer muss Pausen machen: Von einem Stopp erreicht er höchstens den nächsten oder den übernächsten. Jeder Stopp hat eine Gebühr. Wie wenig zahlt er, wenn er auch am letzten hält?
Die Merkmale: „wie wenig“, und der günstigste Weg zu Stopp i führt über den günstigsten Weg zu i − 1 oder zu i − 2. Das Rezept:
- Zustand: cost[i] ist das Wenigste, das bis Stopp i gezahlt wird, mit Halt dort.
- Übergang: cost[i] = die Gebühr von i + min(cost[i − 1], cost[i − 2]).
- Basis: Vom Lager erreicht er Stopp 0 oder fährt direkt zu Stopp 1, also sind cost[0] und cost[1] einfach ihre Gebühren.
- Reihenfolge: von links nach rechts.
- Antwort: die Kosten des letzten Stopps.
function minFees(fee) { const cost = new Array(fee.length).fill(0); cost[0] = fee[0]; cost[1] = fee[1]; for (let i = 2; i < fee.length; i++) { cost[i] = fee[i] + Math.min(cost[i - 1], cost[i - 2]); } return cost[fee.length - 1];}const parking = [4, 9, 3, 12, 5, 2, 10, 6];console.log("Mindestens: " + `${minFees(parking)} €`);Mindestens: 20 €Probier es aus
Sag die Ausgabe voraus
Vier Parkstopps mit Gebühren von 5 €, 1 €, 4 € und 2 €:
function minFees(fee) { const cost = new Array(fee.length).fill(0); cost[0] = fee[0]; cost[1] = fee[1]; for (let i = 2; i < fee.length; i++) { cost[i] = fee[i] + Math.min(cost[i - 1], cost[i - 2]); } return cost[fee.length - 1];}
const parking = [5, 1, 4, 2];console.log("Mindestens: " + `${minFees(parking)} €`);Die Idee
Eine dritte Aufgabe: Text ohne Leerzeichen
Kunden tippen „sommerschlussverkauf“ ohne Leerzeichen ins Suchfeld, wie bei einem Hashtag. Lässt sich der Text in Wörter des Wörterbuchs zerlegen?
Das Merkmal ist „ist es möglich“: ja oder nein. Das Rezept:
- Zustand: ok[i] heißt „die ersten i Buchstaben lassen sich in Wörter zerlegen“.
- Übergang: ok[i] ist wahr, wenn es ein j < i mit ok[j] gibt und die Buchstaben von j bis i ein Wort des Wörterbuchs sind.
- Basis: ok[0] ist wahr: Der leere Anfang lässt sich immer zerlegen.
- Reihenfolge: i wächst.
- Antwort: ok[n].
function canSplit(text, words) { const n = text.length; const ok = new Array(n + 1).fill(false); ok[0] = true; for (let i = 1; i <= n; i++) { for (let j = 0; j < i && !ok[i]; j++) { ok[i] = ok[j] && words.has(text.slice(j, i)); } } return ok[n];}const words = new Set([ "sommer", "som", "schluss", "verkauf", "schuh", "schuhe", "für", "kinder", "kind",]);for (const text of ["sommerschlussverkauf", "schuhefürkinder", "schuhefürbabys"]) { console.log(text + ": " + (canSplit(text, words) ? "ja" : "nein"));}sommerschlussverkauf: jaschuhefürkinder: jaschuhefürbabys: neinGenauer, für Neugierige
Für jedes i probiert die Schleife bis zu i Stellen für j aus, und jedes Stück wird ausgeschnitten und im Wörterbuch nachgeschlagen. Das Ausschneiden kopiert die Buchstaben (slice liefert einen neuen String, obwohl V8 manchmal nur einen Zeiger in den alten behält), also ist das Ganze bei einem Text aus n Buchstaben bis zu O(n³). Hat das längste Wort des Wörterbuchs L Buchstaben, muss j nur zwischen i − L und i liegen, und die Zeit sinkt auf O(n · L²). Echte Suchmaschinen benutzen einen Trie der Wörterbuchwörter: Sie gehen die Buchstaben ab j entlang und halten an, sobald der Trie endet.
Probier es aus
Ergänze den Übergang
function canSplit(text, words) {
const n = text.length;
const ok = new Array(n + 1).fill(false);
ok[0] = true;
for (let i = 1; i <= n; i++) {
for (let j = 0; j < i && !ok[i]; j++) {
ok[i] = ;
}
}
return ok[n];
}ok[i] wird wahr, wenn sich der Anfang bis j schon zerlegen lässt und das Stück von j bis i ein Wort ist: ok[j] && words.has(text.slice(j, i)).
Die Idee
Sieht nach DP aus, ist es aber nicht
Nicht jede „am meisten“-Aufgabe braucht eine Tabelle.
- Gierig reicht manchmal. Zahlen alle Anfragen des Transporters dasselbe, wird die Frage zu „wie viele Anfragen höchstens“, und die Regel „das früheste Ende zuerst“ liefert genau das Beste, in O(n log n) und ohne Tabelle. Wann gierig nachweislich richtig ist und warum, steht im nächsten Modul. Das Merkmal: In jedem Schritt ist eine Wahl immer mindestens so gut wie jede andere.
- Der Graph hat keine Reihenfolge. Der kürzeste Weg in einer Stadt sieht nach DP aus: Die Distanz zu einer Kreuzung wird aus den Distanzen zu ihren Nachbarn gebaut. Aber die Straßen bilden Schleifen, und „bis A“ hängt von „bis B“ ab, während „bis B“ von „bis A“ abhängt. Es gibt keine Reihenfolge, in der man die Tabelle von links nach rechts füllen könnte. Deshalb ist es dort Dijkstra: Er findet die Reihenfolge unterwegs.
- Keine Überlappung. Wiederholen sich die Teilprobleme nicht, wie beim Mergesort, spart das Merken nichts. Das ist „Teile und herrsche“.
Der Test ist Schritt 4 des Rezepts: Kannst du nicht sagen, in welcher Reihenfolge zu füllen ist, geht es wahrscheinlich um einen Graphen.
Übung
Die Werbetafeln
Löse die Aufgabe in deinem Editor.
Werbetafeln. Am Boulevard stehen sieben Werbetafeln, ab 1 nummeriert, mit einer Monatsmiete von 120 €, 90 €, 60 €, 150 €, 80 €, 40 € und 130 €. Zwei benachbarte Tafeln verdecken sich, also können nicht beide vermietet werden. Schreibe die Lösung nach dem Rezept: Gib die größte Miete aus und welche Tafeln vermietet werden:
Höchstens: 400 €Werbetafeln: 1, 4, 7Tipp: Der Zustand ist „das Meiste aus den ersten i Tafeln“, und der Übergang ist dieselbe Wahl wie beim Transporter: Tafel i auslassen oder sie zusammen mit dem Besten bis i − 2 nehmen.
Weitere Aufgaben
- Auf wie viele Arten. Auf wie viele Arten kann der Lastwagen den letzten Parkstopp erreichen (ein oder zwei Stopps auf einmal)? Nur der Übergang ändert sich: eine Summe statt eines Minimums.
- Die Zerlegung mit Namen. Gib auch die Wörter von „sommerschlussverkauf“ aus: Merke dir für jedes ok[i], woher es kam.
- Die Anfragen mit Namen. Lass
bestPayauch zurückgeben, welche Anfragen anzunehmen sind: Geh von best[n] zurück, wie in der Szene.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Überlappende Teilprobleme · Das Wechselgeldproblem · Das Rucksackproblem · Die längste gemeinsame Teilfolge und Diff
- Aufgaben zum Thema: Dynamische Programmierung