Plecak - programowanie dynamiczne

Wprowadzenie

W poprzednich lekcjach zobaczyliśmy, że algorytm zachłanny nie gwarantuje optymalnego rozwiązania dla problemu plecaka 0/1. Teraz poznamy programowanie dynamiczne - technikę, która zawsze znajduje optymalne rozwiązanie.

Kluczowa idea DP dla plecaka: - Rozwiązujemy mniejsze podproblemy: "Jaka jest maksymalna wartość dla plecaka o pojemności w używając pierwszych i przedmiotów?" - Budujemy rozwiązanie od dołu (tabulation) lub od góry z cache'owaniem (memoization) - Wykorzystujemy optymalną podstrukturę i nakładające się podproblemy


Podejście 1: Rekurencja z memoizacją (top-down)

Wzór rekurencyjny

Dla problemu plecaka 0/1 definiujemy:

dp[i][w] = maksymalna wartość dla plecaka o pojemności w
           używając pierwszych i przedmiotów

Wzór rekurencyjny:
dp[i][w] = max(
    dp[i-1][w],                          // NIE bierzemy przedmiotu i
    dp[i-1][w - weight[i]] + value[i]    // BIERZEMY przedmiot i
)

Warunki brzegowe:
dp[0][w] = 0  dla każdego w (brak przedmiotów → wartość 0)
dp[i][0] = 0  dla każdego i (pojemność 0 → wartość 0)

Decyzja: Dla każdego przedmiotu i możemy: 1. Pominąć go → wartość = dp[i-1][w] 2. Wziąć go (jeśli się mieści) → wartość = dp[i-1][w - weight[i]] + value[i]

Wybieramy maksimum z tych dwóch opcji.


Implementacja z memoizacją

Wyjaśnienie:

dp(2, 50) = max(
    dp(1, 50),           // Pomijamy przedmiot 2 (waga 30, wartość 120)
    dp(1, 20) + 120      // Bierzemy przedmiot 2
)

dp(1, 50) = max(
    dp(0, 50),           // Pomijamy przedmiot 1 (waga 20, wartość 100)
    dp(0, 30) + 100      // Bierzemy przedmiot 1
)

dp(1, 20) = max(
    dp(0, 20),           // Pomijamy przedmiot 1
    dp(0, 0) + 100       // Bierzemy przedmiot 1
)

...

Dzięki memoizacji, każdy stan (i, w) obliczamy tylko raz!

Podejście 2: Tabulation (bottom-up)

Implementacja tabelaryczna

Wizualizacja tablicy DP:

Przedmioty: [10kg/60zł, 20kg/100zł, 30kg/120zł]
W = 50 kg

        w=0  w=10  w=20  w=30  w=40  w=50
i=0      0    0     0     0     0     0
i=1      0   60    60    60    60    60    (przedmiot 0: 10kg, 60zł)
i=2      0   60   100   160   160   160    (przedmiot 1: 20kg, 100zł)
i=3      0   60   100   160   180   220    (przedmiot 2: 30kg, 120zł)
                                      ↑
                                 WYNIK

Krok po kroku dla dp[3][50]:
- Nie bierzemy przedmiotu 2: dp[2][50] = 160
- Bierzemy przedmiot 2: dp[2][50-30] + 120 = dp[2][20] + 120 = 100 + 120 = 220
- max(160, 220) = 220

Drukowanie tablicy DP (debugging)

Wyjście:

Tablica DP:
       w= 0 w= 1 w= 2 w= 3 w= 4 w= 5 w= 6 w= 7 w= 8
i= 0     0    0    0    0    0    0    0    0    0
i= 1     0    0    3    3    3    3    3    3    3
i= 2     0    0    3    4    4    7    7    7    7
i= 3     0    0    3    4    5    7    8    9    9
i= 4     0    0    3    4    5    7    8    9   10

Maksymalna wartość: 10

Rekonstrukcja rozwiązania: Które przedmioty wybrać?

Algorytm backtracingu

Po obliczeniu tablicy DP, możemy zrekonstruować które przedmioty zostały wybrane:

Wyjście:

Maksymalna wartość: 220
Wybrane przedmioty (indeksy): [1, 2]

Szczegóły:
  Przedmiot 1: waga=20, wartość=100
  Przedmiot 2: waga=30, wartość=120

Łączna waga: 50/50

Wyjaśnienie backtracingu:

Start: i=3, w=50, dp[3][50]=220

Krok 1:
  dp[3][50] = 220 ≠ dp[2][50] = 160 → przedmiot 2 WYBRANY
  Aktualizuj: i=2, w=50-30=20

Krok 2:
  dp[2][20] = 100 ≠ dp[1][20] = 60 → przedmiot 1 WYBRANY
  Aktualizuj: i=1, w=20-20=0

Krok 3:
  w=0 → STOP

Wybrane: [2, 1] → po odwróceniu: [1, 2]

Optymalizacja pamięci: O(n·W) → O(W)

Obserwacja

Zauważ, że przy obliczaniu dp[i][w] potrzebujemy tylko: - Bieżącego wiersza: dp[i][...] - Poprzedniego wiersza: dp[i-1][...]

Możemy użyć tylko jednej tablicy 1D zamiast 2D!


Implementacja z jedną tablicą

Wyjaśnienie iteracji od końca:

Przykład krok po kroku:

Przedmioty: [10kg/60zł, 20kg/100zł, 30kg/120zł]
W = 50

Inicjalizacja:
dp = [0, 0, 0, ..., 0]  (51 zer)

Po przedmiocie 0 (10kg, 60zł):
dp = [0, 0, ..., 0, 60, 60, 60, ..., 60]
           0-9      10  11  12      50

Po przedmiocie 1 (20kg, 100zł):
dp = [0, ..., 0, 60, ..., 60, 100, 160, 160, ..., 160]
      0-9       10      19   20   30   31       50

Po przedmiocie 2 (30kg, 120zł):
dp = [0, ..., 0, 60, ..., 100, 160, 180, 220, 220, ..., 220]
      0-9       10      20   30   40   50   51       50
                                       ↑
                                    WYNIK

Benchmark: Memoization vs Tabulation vs Optymalizacja

Przykładowe wyniki:

n=20, W=50:
  Memoization          → czas: 0.002134s, wartość: 412
  Tabulation           → czas: 0.001987s, wartość: 412
  Optymalizacja        → czas: 0.000891s, wartość: 412

n=50, W=100:
  Memoization          → czas: 0.012456s, wartość: 987
  Tabulation           → czas: 0.009823s, wartość: 987
  Optymalizacja        → czas: 0.004512s, wartość: 987

n=100, W=200:
  Memoization          → czas: 0.048723s, wartość: 1821
  Tabulation           → czas: 0.035647s, wartość: 1821
  Optymalizacja        → czas: 0.016234s, wartość: 1821

Obserwacje: - Optymalizacja najszybsza (mniej alokacji pamięci) - Tabulation szybsze niż memoization (mniej overhead funkcji) - Wszystkie mają tę samą złożoność O(n·W), ale stałe są różne


Porównanie: Zachłanny vs DP

Przykładowe wyjście:

Algorytm zachłanny:
  Wartość: 1542
  Czas: 0.000032s
  Optymalność: 87.3%

Programowanie dynamiczne:
  Wartość: 1767
  Czas: 0.004521s
  Optymalność: 100.0%

Różnica wartości: 225
Stosunek czasu DP/Zachłanny: 141.28x

Wnioski: - DP jest wolniejsze, ale zawsze optymalne - Zachłanny jest szybki, ale może być nieoptymalny (tutaj: 87.3%) - Wybór zależy od wymagań: szybkość vs dokładność


Wariant: Plecak nieograniczony (Unbounded Knapsack)

Dla plecaka nieograniczonego (każdy przedmiot można wziąć wielokrotnie), algorytm DP się zmienia:

Różnica między 0/1 a unbounded:


Praktyczne zastosowanie: System rekomendacji treści

Przykładowe wyjście:

Rekomendowane treści:
  - Film A: 90 min, engagement 85
  - Tutorial E: 25 min, engagement 50

Łączny engagement: 135
Łączny czas: 115 min (pozostało: 5 min)

Podsumowanie algorytmów plecaka

Algorytm Typ problemu Złożoność czasowa Złożoność pamięciowa Optymalność
Brute Force 0/1, Unbounded O(2^n) O(n) ✅ Tak
Rekurencja naiwna 0/1 O(2^n) O(n) ✅ Tak
Memoization 0/1 O(n·W) O(n·W) ✅ Tak
Tabulation 0/1 O(n·W) O(n·W) ✅ Tak
DP optymalizacja 0/1 O(n·W) O(W) ✅ Tak
Zachłanny Fractional O(n log n) O(n) ✅ Tak
Zachłanny 0/1 O(n log n) O(n) ❌ Nie (≥50%)
DP unbounded Unbounded O(n·W) O(W) ✅ Tak

Co dalej?

Po opanowaniu problemu plecaka z programowaniem dynamicznym, następne kroki to:

  1. Lekcja 52: Najdłuższy wspólny podciąg (LCS)
  2. Kolejny klasyczny problem DP
  3. Zastosowania: diff, plagiarism detection, bioinformatyka
  4. Algorytm O(m·n) z optymalizacją pamięci

  5. Lekcja 53: Najdłuższy rosnący podciąg (LIS)

  6. Problem optymalizacji sekwencji
  7. Rozwiązanie O(n²) i O(n log n)
  8. Zastosowania: analiza danych czasowych

  9. Inne problemy DP:

  10. Najkrótsza ścieżka w grafie (Bellman-Ford, Floyd-Warshall)
  11. Problem wydawania reszty (Coin Change)
  12. Edycyjna odległość (Edit Distance)
  13. Palindromy i inne problemy na stringach

Zadania praktyczne

Zadanie 1: Wizualizacja tablicy DP

Napisz funkcję, która rysuje tabelę DP krok po kroku, pokazując jak wypełniane są poszczególne komórki.

Zadanie 2: Multi-dimensional knapsack

Rozszerz problem plecaka o dwa ograniczenia (np. waga i objętość). Każdy przedmiot ma wagę, objętość i wartość.

Zadanie 3: Plecak z priorytetami

Dodaj priorytety do przedmiotów. Jeśli dwa rozwiązania mają tę samą wartość, wybierz to z wyższą sumą priorytetów.

Zadanie 4: Analiza wrażliwości

Dla danego rozwiązania optymalnego, sprawdź jak zmienia się wynik przy zmianie pojemności plecaka o ±10%.


Następna lekcja: Najdłuższy wspólny podciąg (LCS) - algorytm podobny do plecaka, ale dla stringów