Zum Inhalt
wannacode

Das Rucksackproblem

Kurz gesagt: Das Rucksackproblem wählt Gegenstände mit dem größten Gesamtwert, ohne ein Gewichtslimit zu überschreiten; eine Tabelle der dynamischen Programmierung aus Gegenständen und Gewicht löst es in O(n · W).

Problem

Welche Pakete in die Kiste kommen

Ein Kurier fährt ein Lastenrad, und seine Kiste trägt bis zu 50 kg. Im Lager warten Pakete. Jedes hat ein Gewicht und eine Liefergebühr, und der Kurier bekommt die Gebühr für jedes gelieferte Paket. Ein Paket lässt sich nicht teilen: Es wird ganz mitgenommen oder gar nicht. Welche Pakete soll der Kurier mitnehmen, um am meisten zu verdienen?

Der Programmierer probiert alles aus: für jedes Paket „mitnehmen“ oder „dalassen“. Sobald jedes Paket entschieden ist, prüft das Programm, ob die Ladung passt und was sie einbringt. Die Pakete kommen aus dem Lager. Hier bauen wir sie mit einer Formel, damit sie in jeder Sprache gleich sind: Gewicht von 1 bis 20 kg, Gebühr von 2 bis 40 €.

static long tries = 0;
static int TryAll(int[] weight, int[] fee, int cap, int i, int load, int sum)
{
if (i == weight.Length)
{
tries++;
return load <= cap ? sum : -1;
}
int skip = TryAll(weight, fee, cap, i + 1, load, sum);
int take = TryAll(weight, fee, cap, i + 1, load + weight[i], sum + fee[i]);
return Math.Max(skip, take);
}
foreach (int n in new[] { 10, 20 })
{
int[] weight = new int[n];
int[] fee = new int[n];
for (int i = 0; i < n; i++)
{
weight[i] = 1 + i * 7 % 20;
fee[i] = 2 + i * 11 % 39;
}
tries = 0;
int best = TryAll(weight, fee, 50, 0, 0, 0);
Console.WriteLine($"{n} Pakete: {best} €, {tries} Kombinationen");
}
Ausgabe
10 Pakete: 169 €, 1024 Kombinationen
20 Pakete: 208 €, 1048576 Kombinationen

Jedes Paket verdoppelt die Kombinationen: 10 Pakete ergeben 1.024, 20 ergeben über eine Million. Bei 30 Paketen sind es über eine Milliarde, und bei 50 Paketen sind es 1.125.899.906.842.624. Bei einer Milliarde Kombinationen pro Sekunde sind das etwa 13 Tage, und der Kurier fährt um acht Uhr morgens los.

Die Idee

Eine Tabelle aus Paketen × Kilogramm

Die Rekursion stellt immer wieder dieselben Fragen: „das Beste ab Paket 7 mit 23 kg Platz“ taucht auf vielen verschiedenen Wegen auf. Speicher für das Paar (Paket, Platz) würde die Wiederholungen beenden. Aber auch hier ist die Tabelle von unten nach oben einfacher, und du kennst sie schon. Nur hat ein Teilproblem diesmal zwei Zahlen: wie viele Pakete du betrachtest und wie viele Kilogramm Platz du hast. Die Tabelle ist zweidimensional.

Die vier Dinge der Tabelle:

  • Was eine Zelle bedeutet. best[i][w] ist die höchste Gebühr mit den ersten i Paketen und einer Ladung von höchstens w kg.
  • Woher sie kommt. Entweder ist Paket i nicht dabei, dann ist es best[i − 1][w]. Oder es ist dabei: seine Gebühr plus best[i − 1][w − sein Gewicht], das Beste ohne es für die übrigen Kilogramm. Die Zelle behält das Größere.
  • In welcher Reihenfolge sie gefüllt wird. Zeile für Zeile: Jede Zeile liest nur die Zeile darüber.
  • Wo die Antwort steht. Unten rechts: alle Pakete, die ganze Ladung. Der Start ist Zeile 0, ohne Pakete: überall 0.

Das ist das Rucksackproblem (0/1-Rucksack): Dinge mit Gewicht und Wert, Platz mit einem Limit, und jedes Ding wird ganz genommen oder gar nicht. Es wählt Pakete für eine Tour, Aufgaben für einen Sprint mit begrenzten Stunden, Werbung für begrenzte Sendezeit.

Sieh es dir an

Mitnehmen oder dalassen

Die letzte Tour des Tages: In der Kiste sind noch 6 kg Platz, und vier Pakete warten. Achte auf die zwei Zellen, die jede Zelle liest: die darüber („dalassen“) und den Pfeil aus der Zeile darüber mit der Gebühr („mitnehmen“):

static List<int> Pack(int[] weight, int[] fee, int cap)
{
int n = weight.Length;
int[,] best = new int[n + 1, cap + 1];
for (int i = 1; i <= n; i++)
{
int kg = weight[i - 1];
int paid = fee[i - 1];
for (int w = 0; w <= cap; w++)
{
best[i, w] = best[i - 1, w];
if (kg <= w)
{
int take = best[i - 1, w - kg] + paid;
best[i, w] = Math.Max(best[i, w], take);
}
}
}
List<int> taken = new List<int>();
for (int i = n, w = cap; i > 0; i--)
{
if (best[i, w] != best[i - 1, w])
{
taken.Insert(0, i);
w -= weight[i - 1];
}
}
return taken;
}
Schritt für Schritt, in Worten
  1. Pack(weight, fee, 6): best[i][w] ist die höchste Gebühr mit den ersten i Paketen und höchstens w kg. Zeile 0 hat keine Pakete, also steht dort nur 0.
  2. Paket i dalassen: Das Beste bleibt, was die Zeile darüber für dieselben kg hat, best[i − 1][w]. Paket 1 wiegt 4 kg, also ist es unter 4 kg nur das.
  3. Paket i mitnehmen: seine Gebühr plus das Beste der Zeile darüber für die übrigen kg, best[i − 1][w − kg]. Bei 4 kg: 10 € + 0 = 10 € schlägt 0.
  4. Zeile 3, 6 kg: Paket 3 (3 kg, 6 €) plus das Beste für 3 kg ohne es, 7 €: 13 € > 10 €. Zwei günstigere Pakete schlagen das teuerste pro kg.
  5. Zurück von der Ecke unten rechts: Dasselbe wie oben heißt, das Paket wurde dagelassen, anders heißt, es wurde genommen. Paket 4 nicht genommen, Paket 3 genommen, Paket 2 genommen, Paket 1 nicht genommen: 2 + 3 = 13 €.
int[] weight = { 4, 3, 3, 1 };
int[] fee = { 10, 7, 6, 2 };
Console.WriteLine(string.Join(" + ", Pack(weight, fee, 6)));
Ausgabe
2 + 3
In C# ist die Tabelle ein rechteckiges Array int[,], und eine Zelle schreibt man best[i, w], mit zwei Indizes in einem Klammerpaar.

Die Idee

Welche Pakete

Die Tabelle sagt, wie viel, und der Kurier muss wissen, welche. Die Antwort steckt in der Tabelle selbst. Du beginnst in der Ecke unten rechts. Ist die Zelle gleich der darüber, wird Paket i dafür nicht gebraucht: Du gehst eine Zeile nach oben. Ist sie anders, wurde Paket i genommen: Du gehst eine Zeile nach oben und um sein Gewicht nach links. Und so weiter bis Zeile 0.

Die 20 Pakete von heute und eine Kiste von 50 kg:

static int Paid(int[] fee, List<int> taken)
{
int sum = 0;
foreach (int p in taken)
{
sum += fee[p - 1];
}
return sum;
}
int[] weight = new int[20];
int[] fee = new int[20];
for (int i = 0; i < 20; i++)
{
weight[i] = 1 + i * 7 % 20;
fee[i] = 2 + i * 11 % 39;
}
List<int> taken = Pack(weight, fee, 50);
Console.WriteLine($"{string.Join(" + ", taken)}: {Paid(fee, taken)} €");
Ausgabe
1 + 4 + 7 + 8 + 10 + 11 + 13 + 14: 208 €

Die Pakete wiegen 48 kg und bringen 208 €, am meisten von allen über einer Million Kombinationen. Die Tabelle hat 20 Paketzeilen mit je 51 Zellen, insgesamt 1.020 Zellen.

Probier es aus

Ergänze

Paket i kommt hinein. Wie viele Kilogramm bleiben für die anderen Pakete?

static List<int> Pack(int[] weight, int[] fee, int cap)
{
    int n = weight.Length;
    int[,] best = new int[n + 1, cap + 1];
    for (int i = 1; i <= n; i++)
    {
        int kg = weight[i - 1];
        int paid = fee[i - 1];
        for (int w = 0; w <= cap; w++)
        {
            best[i, w] = best[i - 1, w];
            if (kg <= w)
            {
                int take = best[i - 1, ] + paid;
                best[i, w] = Math.Max(best[i, w], take);
            }
        }
    }
    List<int> taken = new List<int>();
    for (int i = n, w = cap; i > 0; i--)
    {
        if (best[i, w] != best[i - 1, w])
        {
            taken.Insert(0, i);
            w -= weight[i - 1];
        }
    }
    return taken;
}

Die Idee

Warum nicht das teuerste pro Kilogramm

Es klingt vernünftig: Du rechnest die Gebühr pro Kilogramm jedes Pakets aus und nimmst das teuerste, das passt, dann das nächste. Auf der Tour mit 6 kg kostet Paket 1 2,50 € pro Kilogramm, Paket 2 etwa 2,33, Paket 3 2 und Paket 4 2.

static List<int> Greedy(int[] weight, int[] fee, int cap)
{
bool[] used = new bool[weight.Length];
List<int> taken = new List<int>();
while (true)
{
int pick = -1;
double rate = 0;
for (int i = 0; i < weight.Length; i++)
{
double perKg = (double)fee[i] / weight[i];
if (!used[i] && weight[i] <= cap && perKg > rate)
{
pick = i;
rate = perKg;
}
}
if (pick < 0)
{
return taken;
}
used[pick] = true;
taken.Add(pick + 1);
cap -= weight[pick];
}
}
int[] weight = { 4, 3, 3, 1 };
int[] fee = { 10, 7, 6, 2 };
List<int> fast = Greedy(weight, fee, 6);
List<int> best = Pack(weight, fee, 6);
Console.WriteLine($"nach €/kg: {string.Join(" + ", fast)} = {Paid(fee, fast)} €");
Console.WriteLine($"Tabelle: {string.Join(" + ", best)} = {Paid(fee, best)} €");
Ausgabe
nach €/kg: 1 + 4 = 12 €
Tabelle: 2 + 3 = 13 €

Paket 1 kommt zuerst hinein und nimmt 4 kg. Es bleiben 2 kg, und nur Paket 4 passt hinein. Die Pakete 2 und 3 hätten die 6 kg ganz gefüllt und mehr eingebracht. Die Regel wählt, ohne zurückzuschauen, und ihre erste Wahl schließt die bessere Kombination aus. Die Tabelle probiert für jedes Paket und jede Menge Platz beide Möglichkeiten aus.

Könnte man Pakete zerschneiden (Sand, Treibstoff), wäre „das teuerste pro Kilogramm zuerst“ richtig. Wann eine solche Regel funktioniert und wann sie scheitert, ist die Lektion „Wann ein gieriger Algorithmus scheitert“.

Die Idee

Eine Zeile statt einer Tabelle

Jede Zeile liest nur die Zeile darüber. Du brauchst also nicht die ganze Tabelle: Eine Zeile best[0..W] reicht, Paket für Paket überschrieben. Wenn nur die Summe gebraucht wird, wird das Programm kürzer:

static int BestFee(int[] weight, int[] fee, int cap)
{
int[] best = new int[cap + 1];
for (int i = 0; i < weight.Length; i++)
{
for (int w = cap; w >= weight[i]; w--)
{
int take = best[w - weight[i]] + fee[i];
best[w] = Math.Max(best[w], take);
}
}
return best[cap];
}
int[] weight = { 4, 3, 3, 1 };
int[] fee = { 10, 7, 6, 2 };
Console.WriteLine(BestFee(weight, fee, 6) + " €");
Ausgabe
13 €

Wichtig ist die Richtung: Die Kilogramm laufen von oben nach unten, von W bis zum Gewicht des Pakets. Zelle w liest best[w − Gewicht], das links von ihr liegt. Von oben nach unten ist diese Zelle in dieser Runde noch nicht überschrieben, stammt also aus der Zeile darüber, ohne das aktuelle Paket. So kommt das Paket höchstens einmal hinein.

Probier es aus

Sag die Ausgabe voraus

5 kg Platz und drei Pakete: 2 kg für 3 €, 3 kg für 4 €, 4 kg für 5 €. Was gibt das Programm aus?

static int BestFee(int[] weight, int[] fee, int cap)
{
int[] best = new int[cap + 1];
for (int i = 0; i < weight.Length; i++)
{
for (int w = cap; w >= weight[i]; w--)
{
int take = best[w - weight[i]] + fee[i];
best[w] = Math.Max(best[w], take);
}
}
return best[cap];
}
int[] weight = { 2, 3, 4 };
int[] fee = { 3, 4, 5 };
Console.WriteLine(BestFee(weight, fee, 5) + " €");
Antwort

Probier es aus

Finde den Fehler

Ein Kollege hat die Richtung der Kilogramm umgedreht. Für die Tour mit 6 kg sagt sein Programm:

int[] weight = { 4, 3, 3, 1 };
int[] fee = { 10, 7, 6, 2 };
Console.WriteLine(BestFee(weight, fee, 6) + " €");
Ausgabe
14 €

Und das Beste sind 13 €. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Sieh es dir an

Miss es: jede Kombination gegen die Tabelle

Wir zählen die geprüften Kombinationen und die Vergleiche „mitnehmen oder dalassen“ in der Zeile. Die Kiste hat 50 kg. Über 20 Paketen werden die Kombinationen nicht ausgeführt: Es sind genau 2ⁿ, also ist die Schätzung mit „≈“ exakt.

  • Jede Kombination O(2ⁿ)
  • Tabelle (bis 50 kg) O(n)
Algorithmusn = 10n = 20n = 30n = 40
Jede Kombination1.0241.048.576≈ 1.073.741.824≈ 1.099.511.627.776
Tabelle (bis 50 kg)4258101.2351.620
Der Code läuft in deinem Browser, in JavaScript. Die Zahl der Operationen ist in jeder Sprache gleich, weil der Algorithmus derselbe ist. Die Zeit hängt von der Maschine ab und dient nur zum Vergleich. „≈“ ist eine Schätzung anhand der Kurve: Ein so großes n führen wir nicht aus, damit die Seite nicht einfriert.
static int BestFee(int[] weight, int[] fee, int cap)
{
int[] best = new int[cap + 1];
for (int i = 0; i < weight.Length; i++)
{
for (int w = cap; w >= weight[i]; w--)
{
int take = best[w - weight[i]] + fee[i];
best[w] = Math.Max(best[w], take);
}
}
return best[cap];
}

Jedes neue Paket verdoppelt die Kombinationen und fügt der Tabelle eine Zeile hinzu: höchstens 50 Vergleiche. Bei 40 Paketen macht die Tabelle 1.620 Vergleiche, während die Kombinationen über eine Billion sind. Deshalb wächst die Tabelle auch geradlinig: Bei festen 50 kg ist sie O(n). Den Preis von W erklärt „Genauer“ weiter unten.

Genauer, für Neugierige

Dieselben Zahlen in deiner Sprache:

static long tries = 0;
static int TryAll(int[] weight, int[] fee, int cap, int i, int load, int sum)
{
if (i == weight.Length)
{
tries++;
return load <= cap ? sum : -1;
}
int skip = TryAll(weight, fee, cap, i + 1, load, sum);
int take = TryAll(weight, fee, cap, i + 1, load + weight[i], sum + fee[i]);
return Math.Max(skip, take);
}
static int BestFee(int[] weight, int[] fee, int cap)
{
int[] best = new int[cap + 1];
for (int i = 0; i < weight.Length; i++)
{
for (int w = cap; w >= weight[i]; w--)
{
tries++;
int take = best[w - weight[i]] + fee[i];
best[w] = Math.Max(best[w], take);
}
}
return best[cap];
}
foreach (int n in new[] { 10, 20 })
{
int[] weight = new int[n];
int[] fee = new int[n];
for (int i = 0; i < n; i++)
{
weight[i] = 1 + i * 7 % 20;
fee[i] = 2 + i * 11 % 39;
}
tries = 0;
TryAll(weight, fee, 50, 0, 0, 0);
long all = tries;
tries = 0;
BestFee(weight, fee, 50);
Console.WriteLine($"{n} Pakete: Kombinationen {all}, Tabelle {tries}");
}
Ausgabe
10 Pakete: Kombinationen 1024, Tabelle 425
20 Pakete: Kombinationen 1048576, Tabelle 810

Übung

Die Tour aus der Eingabe

Löse die Aufgabe in deinem Editor.

Tour. Lies in der ersten Zeile die Ladung in kg, in der zweiten die Gewichte der Pakete und in der dritten die Gebühren ein. Gib aus, welche Pakete der Kurier mitnehmen soll und wie viel das einbringt. Nutze Pack und Paid aus der Lektion.

Eingabe
15
5 6 3 4 7 2
11 14 6 9 15 3
Ausgabe
1 + 2 + 4: 34 €

Weitere Aufgaben

  • Und wie viele kg. Gib auch aus, wie viel die mitgenommenen Pakete wiegen und wie viel Platz übrig bleibt.
  • Eilige Pakete. Paket 2 muss auf jeden Fall raus. Wie bringst du das ins Problem, ohne eine neue Tabelle zu schreiben? (Tipp: Nimm es von der Liste und senke die Ladung um sein Gewicht.)
  • Zwei Kisten. Das Rad hat zwei Kisten zu 25 kg. Warum reicht es nicht, das Problem für 50 kg zu lösen?

Genauer

Genauer: der Preis von W

Warum „O(n · W)“ nicht „immer schnell“ heißt.

Genauer, für Neugierige

Pseudopolynomielle Zeit. Die Tabelle hat n · W Zellen. n ist die Anzahl der Pakete, aber W ist der Wert der Kapazität, nicht die Anzahl der Dinge in der Eingabe. Sind die Gewichte in Gramm, wird aus der 50-kg-Kiste W = 50.000, und die Tabelle ist tausendmal größer. Ist die Kapazität eine Milliarde (Bytes auf einer Festplatte, Cent in einem Budget), passt die Tabelle nicht in den Speicher, bei gleicher Anzahl Pakete. Eine Laufzeit, die ein Polynom in n und im Wert von W ist, aber nicht in der Länge von W in Ziffern, heißt pseudopolynomiell. Jede zusätzliche Ziffer in W multipliziert die Arbeit mit 10. Deshalb ist bei riesigem W und wenigen Paketen das Ausprobieren von Kombinationen mit gutem Abschneiden manchmal schneller.

Speicher. Die ganze Tabelle sind (n + 1) · (W + 1) Zahlen, eine Zeile W + 1. Aber die Zeile allein sagt nicht, welche Pakete: Der Weg zurück braucht alle Zeilen oder wenigstens ein „genommen“-Bit für jede Zelle.

Wenn es unbegrenzt Pakete gibt. Gibt es von jeder Sorte beliebig viele (Wasserkisten zu 6 kg, jede für 4 €), ist „von oben nach unten“ jetzt falsch, und „von unten nach oben“ ist genau das, was du brauchst: Jede Sorte darf noch einmal hinein. Es ist dieselbe Schleife wie beim Wechselgeld, wo jede Münze viele Male benutzt werden darf. Der Fehler aus „Finde den Fehler“ ist die richtige Lösung eines anderen Problems.

Test

Teste dich

1. Was bedeutet die Zelle best[i][w] in der Rucksack-Tabelle?
2. Die Zelle best[i][w] wählt zwischen zwei Dingen. Zwischen welchen?
3. Die Pakete sind 4 kg für 10 €, 3 kg für 7 €, 3 kg für 6 € und 1 kg für 2 €, und es gibt 6 kg Platz. Was nimmt die Regel „das teuerste pro Kilogramm, das noch passt“?
4. In der Tabelle mit einer Zeile läuft die Kapazität von oben nach unten. Was passiert, wenn sie von unten nach oben läuft?
5. Die Tabelle ist O(n · W). Warum ist das nicht „immer schnell“?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·