Problem plecakowy – czym jest i jak go rozwiązać?

Problem plecakowy to jedno z najbardziej znanych zagadnień optymalizacyjnych w informatyce i matematyce. Wyobraź sobie, że masz plecak o ograniczonej pojemności i zbiór przedmiotów, każdy o określonej wadze i wartości. Twoim zadaniem jest wybrać przedmioty tak, aby zmaksymalizować ich łączną wartość, nie przekraczając pojemności plecaka. Brzmi prosto, prawda? Jednak w praktyce jest to skomplikowany problem, który ma ogromne znaczenie w wielu dziedzinach – od logistyki i transportu, przez finanse, aż po projektowanie chipów komputerowych.

Rodzaje problemu plecakowego

Istnieje kilka wariantów problemu plecakowego, które różnią się założeniami i sposobem rozwiązania:

  1. Problem plecakowy 0-1 – każdy przedmiot możemy albo wziąć w całości (1), albo zostawić (0)
  2. Problem plecakowy z powtórzeniami – możemy wziąć dowolną liczbę kopii każdego przedmiotu
  3. Problem plecakowy ułamkowy – możemy wziąć część przedmiotu (np. połowę)
  4. Problem plecakowy wielowymiarowy – istnieje wiele ograniczeń (np. waga, objętość)

W tym artykule skupimy się głównie na problemie plecakowym 0-1, który jest najbardziej podstawowy i najczęściej spotykany.

Formalna definicja problemu plecakowego 0-1

Załóżmy, że mamy \(n\) przedmiotów, a każdy przedmiot \(i\) ma dwie cechy:

  • wartość \(v_i\) (korzyść z zabrania przedmiotu)
  • wagę \(w_i\) (ile miejsca zajmuje w plecaku)

Pojemność plecaka wynosi \(W\). Naszym celem jest znalezienie takiego podzbioru przedmiotów, aby:

\[ \text{maksymalizować} \sum_{i=1}^{n} v_i \cdot x_i \]

przy ograniczeniu:

\[ \sum_{i=1}^{n} w_i \cdot x_i \leq W \]

gdzie \(x_i\) to zmienna binarna przyjmująca wartość 1, jeśli przedmiot \(i\) jest wybrany, lub 0 w przeciwnym przypadku.

Przykład problemu plecakowego

Wyobraźmy sobie, że wybierasz się na wędrówkę i masz plecak, który może pomieścić maksymalnie 10 kg. Masz do dyspozycji następujące przedmioty:

Przedmiot Waga (kg) Wartość (użyteczność)
Laptop 3 10
Książka 1 3
Aparat fotograficzny 2 9
Butelka wody 3 8
Kanapki 2 7
Telefon 1 6
Powerbank 2 5

Które przedmioty powinieneś wybrać, aby zmaksymalizować wartość bez przekroczenia limitu 10 kg? Spróbujmy rozwiązać ten problem.

Metody rozwiązania problemu plecakowego

1. Podejście zachłanne (greedy approach)

Najprostszym podejściem jest strategia zachłanna: wybieramy przedmioty w kolejności malejącego stosunku wartości do wagi, dopóki plecak nie jest pełny.

Dla naszego przykładu, obliczmy stosunek wartości do wagi dla każdego przedmiotu:

Przedmiot Waga (kg) Wartość Wartość/Waga
Laptop 3 10 3.33
Książka 1 3 3.00
Aparat fotograficzny 2 9 4.50
Butelka wody 3 8 2.67
Kanapki 2 7 3.50
Telefon 1 6 6.00
Powerbank 2 5 2.50

Sortując przedmioty według wartości/wagi:

  1. Telefon: 6.00
  2. Aparat fotograficzny: 4.50
  3. Kanapki: 3.50
  4. Laptop: 3.33
  5. Książka: 3.00
  6. Butelka wody: 2.67
  7. Powerbank: 2.50

Wybieramy przedmioty w tej kolejności aż do zapełnienia plecaka:

  • Telefon: waga 1 kg, wartość 6
  • Aparat fotograficzny: waga 2 kg, wartość 9
  • Kanapki: waga 2 kg, wartość 7
  • Laptop: waga 3 kg, wartość 10
  • Książka: waga 1 kg, wartość 3

Łączna waga: 9 kg, łączna wartość: 35.

Uwaga: Podejście zachłanne nie zawsze daje optymalne rozwiązanie dla problemu plecakowego 0-1, ale jest szybkie i proste w implementacji. Może dać dobre przybliżenie rozwiązania, szczególnie w problemie plecakowym ułamkowym, gdzie zawsze daje optimum.

2. Programowanie dynamiczne

Programowanie dynamiczne to podejście, które dzieli problem na mniejsze podproblemy i zapamiętuje ich rozwiązania, aby uniknąć ponownego obliczania tych samych wartości. Jest to optymalna metoda rozwiązania problemu plecakowego 0-1.

Definiujemy funkcję \(DP[i][w]\) jako maksymalną wartość, jaką możemy uzyskać, rozważając przedmioty od 1 do \(i\) z ograniczeniem wagi \(w\).

Równanie rekurencyjne:

\[ DP[i][w] = \max(DP[i-1][w], DP[i-1][w-w_i] + v_i) \text{ jeśli } w_i \leq w \]

\[ DP[i][w] = DP[i-1][w] \text{ jeśli } w_i > w \]

Gdzie:

  • \(DP[i-1][w]\) – wartość bez bieżącego przedmiotu
  • \(DP[i-1][w-w_i] + v_i\) – wartość z bieżącym przedmiotem

Dla naszego przykładu stworzymy tabelę DP o wymiarach (7+1) × (10+1), gdzie 7 to liczba przedmiotów, a 10 to pojemność plecaka.

Zamiast przedstawiać całą tabelę, pokażę jak obliczyć kilka przykładowych komórek:

  • \(DP[0][w] = 0\) dla wszystkich \(w\) (brak przedmiotów)
  • \(DP[i][0] = 0\) dla wszystkich \(i\) (brak pojemności)
  • \(DP[1][3] = \max(DP[0][3], DP[0][3-3] + 10) = \max(0, 0+10) = 10\) (dla laptopa i pojemności 3)
  • \(DP[2][3] = \max(DP[1][3], DP[1][3-1] + 3) = \max(10, DP[1][2] + 3)\)

Po wypełnieniu całej tabeli, \(DP[7][10]\) da nam optymalną wartość, która wynosi 36.

Odtwarzając rozwiązanie, otrzymujemy, że powinniśmy wybrać: laptop (10), aparat fotograficzny (9), telefon (6), kanapki (7) i książkę (3), co daje łączną wartość 35 i wagę 9 kg.

Ważne: W przeciwieństwie do metody zachłannej, programowanie dynamiczne zawsze znajduje optymalne rozwiązanie problemu plecakowego 0-1.

3. Przeszukiwanie z nawrotami (backtracking)

Przeszukiwanie z nawrotami to technika, która systematycznie próbuje wszystkie możliwe kombinacje przedmiotów, ale przycina gałęzie poszukiwań, które na pewno nie prowadzą do optymalnego rozwiązania.

Algorytm można opisać następująco:

  1. Rozpocznij z pustym plecakiem
  2. Dla każdego przedmiotu podejmij decyzję: włącz go do plecaka lub nie
  3. Jeśli włączenie przedmiotu przekroczy pojemność plecaka, przytnij tę gałąź
  4. Kontynuuj rekurencyjnie dla pozostałych przedmiotów
  5. Zapamiętaj najlepsze znalezione rozwiązanie

Pseudokod:

function knapsack_backtracking(items, capacity, index, current_weight, current_value):
    if index == length(items):
        return current_value
    
    # Nie bierzemy przedmiotu
    value1 = knapsack_backtracking(items, capacity, index+1, current_weight, current_value)
    
    # Bierzemy przedmiot, jeśli możemy
    if current_weight + items[index].weight <= capacity:
        value2 = knapsack_backtracking(items, capacity, index+1, 
                                       current_weight + items[index].weight, 
                                       current_value + items[index].value)
    else:
        value2 = 0
    
    return max(value1, value2)

Chociaż przeszukiwanie z nawrotami daje optymalne rozwiązanie, jego złożoność czasowa to \(O(2^n)\), co czyni go nieefektywnym dla dużych instancji problemu.

Złożoność obliczeniowa problemu plecakowego

Problem plecakowy jest klasycznym przykładem problemu NP-trudnego. Oznacza to, że nie znamy algorytmu wielomianowego, który rozwiązałby go optymalnie w każdym przypadku. Złożoność poszczególnych metod:

  • Podejście zachłanne: \(O(n \log n)\) - ze względu na sortowanie
  • Programowanie dynamiczne: \(O(n \cdot W)\) - gdzie \(n\) to liczba przedmiotów, a \(W\) to pojemność plecaka
  • Przeszukiwanie z nawrotami: \(O(2^n)\) - w najgorszym przypadku

Warto zauważyć, że programowanie dynamiczne jest pseudowielomianowe - jego złożoność zależy od wartości \(W\), a nie od rozmiaru wejścia potrzebnego do zapisania \(W\).

Przykład implementacji w języku Python

Poniżej znajduje się przykładowa implementacja problemu plecakowego 0-1 z wykorzystaniem programowania dynamicznego w języku Python:

def knapsack_dp(values, weights, capacity):
    n = len(values)
    # Inicjalizacja tablicy DP
    dp = [[0 for _ in range(capacity + 1)] for _ in range(n + 1)]
    
    # Wypełnianie tablicy DP
    for i in range(1, n + 1):
        for w in range(capacity + 1):
            if weights[i-1] <= w:
                dp[i][w] = max(dp[i-1][w], dp[i-1][w-weights[i-1]] + values[i-1])
            else:
                dp[i][w] = dp[i-1][w]
    
    # Odtwarzanie rozwiązania
    selected_items = []
    w = capacity
    for i in range(n, 0, -1):
        if dp[i][w] != dp[i-1][w]:
            selected_items.append(i-1)
            w -= weights[i-1]
    
    return dp[n][capacity], selected_items

# Przykładowe dane
values = [10, 3, 9, 8, 7, 6, 5]
weights = [3, 1, 2, 3, 2, 1, 2]
capacity = 10

max_value, selected = knapsack_dp(values, weights, capacity)
print(f"Maksymalna wartość: {max_value}")
print(f"Wybrane przedmioty (indeksy): {selected}")

Praktyczne zastosowania problemu plecakowego

Problem plecakowy ma wiele praktycznych zastosowań w różnych dziedzinach:

  1. Finanse i inwestycje - alokacja zasobów finansowych, wybór portfela inwestycyjnego
  2. Logistyka i transport - optymalne załadowanie ciężarówek, kontenerów, statków
  3. Planowanie produkcji - wybór produktów do wytworzenia przy ograniczonych zasobach
  4. Cięcie materiałów - minimalizacja odpadów przy cięciu papieru, metalu, tkanin
  5. Projektowanie komputerów - optymalizacja układów scalonych, alokacja pamięci
  6. Budżetowanie projektów - wybór projektów do realizacji przy ograniczonym budżecie

Warianty problemu plecakowego

Problem plecakowy z powtórzeniami (Unbounded Knapsack Problem)

W tym wariancie każdy przedmiot może być wybrany dowolną liczbę razy. Równanie rekurencyjne dla programowania dynamicznego wygląda następująco:

\[ DP[w] = \max_{i=1}^{n} \{DP[w-w_i] + v_i\} \text{ jeśli } w_i \leq w \]

Problem plecakowy ułamkowy (Fractional Knapsack Problem)

W tym wariancie możemy wziąć część przedmiotu. Jest to jedyny wariant, dla którego strategia zachłanna (wybieranie przedmiotów według stosunku wartości do wagi) daje zawsze optymalne rozwiązanie.

Problem plecakowy wielowymiarowy (Multidimensional Knapsack Problem)

W tym wariancie mamy wiele ograniczeń (np. waga, objętość, długość). Jest to jeszcze trudniejszy problem do rozwiązania.

Podsumowanie

Problem plecakowy to fascynujące zagadnienie optymalizacyjne o wielu zastosowaniach praktycznych. Chociaż należy do klasy problemów NP-trudnych, istnieją skuteczne metody jego rozwiązywania:

  • Podejście zachłanne - szybkie, ale nie zawsze optymalne (oprócz wariantu ułamkowego)
  • Programowanie dynamiczne - optymalne, efektywne dla umiarkowanych rozmiarów problemu
  • Przeszukiwanie z nawrotami - optymalne, ale nieefektywne dla dużych instancji

Zrozumienie problemu plecakowego i metod jego rozwiązywania jest przydatne nie tylko w informatyce, ale także w wielu dziedzinach wymagających optymalizacji zasobów. Myślenie w kategoriach problemu plecakowego może pomóc ci podejmować lepsze decyzje w codziennym życiu, gdy musisz wybierać najcenniejsze opcje przy ograniczonych zasobach.