Problem plecakowy

Knapsack problem - problem plecakowy

Podczas wykładu o algorytmach zachłannych postawiliśmy taki problem:

Mamy nn przedmiotów. Każdy ma swoją masę mim_i i wartość wi.w_i. Dysponujemy plecakiem, który jest w stanie pomieścić przedmioty o sumarycznej masie nie większej niż M,M, ponieważ w przeciwnym wypadku rozerwie się. Chcemy zapakować do plecaka przedmioty o jak największej sumarycznej wartości.

Wówczas okazało się, że nie umiemy go rozwiązać metodami zachłannymi. Wracamy do niego, aby się z nim rozprawić - tym razem używając programowania dynamicznego.

Coś prostszego - problem sumy

Dana jest liczba naturalna ww i nn liczb naturalnych a1,a_1, a2,a_2, ..., an.a_n. Chcemy powiedzieć, czy da się wybrać niektóre z liczb aia_i tak, aby sumowały się do w.w.

Rozwiązanie zachłanne

Zachłanne podejścia nie działają, ale umiemy już myśleć dynamicznie - spróbujmy więc rozwiązać ten problem w ten sposób. Niech DP[]DP[] będzie tablicą, w której przechowujemy wyniki. DP[i]=0,DP[i]=0, jeśli liczby ii nie da się utworzyć, a w przeciwnym przypadku DP[i]=1.DP[i]=1.

Stan już zdefiniowaliśmy. Teraz czas na algorytm. Będziemy dodawać elementy po kolei i aktualizować tablicę DP[]DP[] tak, aby w każdym momencie poprawnie mówiła nam, czy daną sumę da się uzyskać.

Pusty zbiór ma sumę zero, więc stanem brzegowym jest DP[0]=1.DP[0]=1. Dla reszty DPDP wynosi na początku 0.0.

Teraz kolej na przejścia. Jakie mamy zależności między naszymi stanami? Rozważmy dodawanie liczby k.k. Wówczas, jeśli umieliśmy utworzyć sumę ss bez tej liczby to z pewnością liczbę k+sk+s też możemy w jakiś sposób utworzyć. Wystarczy bowiem wziąć ten zbiór, który dawał nam ss i dodać do niego liczbę k.k. Więcej przejść nie potrzebujemy: te nam w zupełności wystarczą, aby napisać działający algorytm.

Jest jeszcze mały szczegół, dość typowy dla programowania dynamicznego. Ale o nim przekonamy się, ucząc na błędach.

const int MAX_W = 1000002; bool DP[MAX_W + 1]; bool suma(a[], int n, int w) { DP[0] = 1; for (int i = 1; i <= w; i ++) DP[i] = 0; for (int i = 1; i <= n; i ++) { int k = a[i]; for (int j = 0; j <= w-k; j ++) if (DP[j] == 1) DP[j + k] = 1; } return DP[w]; }

W kodzie powyżej jest pewien błąd, który powoduje że algorytm jest niepoprawny. Znalezienie go to dobry sposób na przetestowanie rozumienia algorytmu. Trafność swojego pomysłu będziesz mógł sprawdzić podczas rozwiązywania zadań do lekcji. Ze swojej strony tylko skomentuję, że to jest jedna z rzeczy, o której łatwo zapomnieć podczas projektowania algorytmu programowania dynamicznego. Oprócz niej zdarza się nie rozważyć przypadków bazowych i wyjść poza tablicę.

Otrzymaliśmy (prawie) działające rozwiązanie w złożoności czasowej O(nw)O(nw) i pamięciowej O(n).O(n). Okazuje się, że możemy poradzić sobie nieznacznie lepiej.

Optymalizacja przy pomocy bitseta

Czytając ten artykuł powinieneś już znać pojęcie bitseta z artykułu o STLu. Użyjemy go, aby nieco poprawić nasz algorytm. Ponieważ tablica DPDP składa się z zer i jedynek, możemy użyć bitseta, aby ją pamiętać. To już daje nam złożoność pamięciową o 3232 razy mniejszej stałej, niż gdybyśmy trzymali DPDP jako tablicę intów. Możemy rownież poprawić złożoność czasową, uważnie analizując działanie naszego algorytmu.

Co dzieje się, gdy dokładamy jedną liczbę kk? Każda jedynka z tablicy DPDP przelatuje o kk miejsc w prawo, jeśli tam jeszcze nie było jedynki. Możemy zapisać tę operację jako złożenie dwóch operacji bitowych - najpierw przesuwamy bitowo o kk tablicę DP,DP, a potem używamy funkcji OR,OR, aby zanotować, że jeśli była jedynka w miejscu ii wcześniej lub znajduje się teraz to chcemy tę jedynkę zostawić. Teraz złażoność naszego programu to nadal O(nw),O(nw), ale działa on 3232 razy szybciej!

const int MAX_W = 1000002; bool sumaBitset(a[], int n, int w) { bitset <MAX_W> DP; //pamietaj, ze bitset ma staly rozmiar DP.reset(); DP.set(0, 1); for (int i = 1; i <= n; i ++) { int k = a[i]; DP |= (DP << k); //|= to operator "zorowania " bitsetu DP //z kolei << odpowiada za przesuniecie bitowe w lewo } }

Dyskretny problem plecakowy - rozwiązanie dynamiczne

To już chyba nasze trzecie podejście do tego problemu. Czas go wreszcie rozwiązać. Przejdziemy przez dokładnie ten sam proces, w jaki myśleliśmy poprzednio. Intuicyjne wydaje się, że dla każdego ii będziemy chcieli stablicować największą możliwą wartość elementów wybranych do plecaka pod warunkiem, że zużyliśmy ii miejsca w plecaku. Oznaczymy to przez DP[i]DP[i] - to nasz stan.

Czego jeszcze potrzebujemy? Stanów bazowych. Na początku nie mamy żadnych elementów, czyli w tablicy DPDP są same zera.

Teraz kolej na przejścia. Zrobimy dokładnie to samo, co poprzednio. Powiedzmy, że rozpatrujemy jj-ty przedmiot o masie mjm_j i wartości wj.w_j. Dla każdej poprzedniej możliwości możemy teraz dodać ten element. Zapiszmy więc: DP[i+mj]=max(DP[i+mj],DP[i]+wj).DP[i + m_j] = max(DP[i + m_j], DP[i] + w_j).

Nie potrzebujemy już nic więcej, aby napisać algorytm. Jest on w pewnym sensie podobny do poprzednich kodów - schemat taki sam, tylko wzory nieco inne.

const int MAX_W = 1000002; const int MAX_N = 1000002; int problemPlecakowy(m[], w[], n) { int DP[MAX_W], m[MAX_N + 1], w[MAX_N + 1]; int W = 0; for (int j = 1; j <= n; j ++) W += m[j]; for (int i = 0; i <= W; i ++) DP[i] = 0; for (int j = 1; j <= n; j ++) for (int i = W-m[j]; i >= 0; i --) DP[i + m[j]] = max(DP[i + m[j]], DP[i] + w[j]); }

Otrzymaliśmy algorytm działający w złożoności O(nW).O(n\cdot W). Umiejętność napisania poprawnego dynamika jest bardzo cenna. Nie wszystkie podejścia da się bowiem łatwo zoptymalizować - czasem trzeba rozważyć to, które ma większe możliwości. A czasem obydwa, łącząc je. Dobrze zobrazuje to poniższy przykład.

Sumy pierwiastkowe

Istnieje jeszcze jedna przydatna optymalizacja do problemu sumy, którą warto znać. Załóżmy, że wszystkie liczby z ciągu aa sumują się do pewnej liczby s.s. Okazuje się, że umiemy wówczas zaproponować algorytm działający w złożoności czasowej O(ss),O(s\sqrt s), łącząc dwa powyżej omówione rozwiązania. Zauważmy, istnieje maksymalnie O(s)O(\sqrt s) różnych elementów, ponieważ suma wszystkich elementów wynosi s,s, a najmniejsza możliwa suma kk różnych elementów to 1+2+3+...+k=k(k+1)/2.1+2+3+...+k=k(k+1) / 2.

Nic nie stoi na przeszkodzie, aby wszystkie takie same elementy rozważyć jednocześnie. Zajmie to nam nie więcej niż O(st),O(\sqrt s \cdot t), gdzie tt to czas rozważenia jednego typu elementów. Spróbujmy to zrobić. Zliczmy w pomocniczej tablicy ILE[]ILE[] ile razy występuje każdy z elementów. Niech liczba mm występuje ww razy. C[i]C[i] będzie mówiło nam, ile minimalnie elementów typu mm potrzebujemy, aby dało się przedstawić ii jako sumę pewnych elementów ze zbioru.

Na początku wszystkie C[i]C[i] przyjmują wartość ,\infty, z wyjątkiem tych i,i, dla których DP[i]DP[i] wynosiło już wcześniej 1.1. Logiczne, prawda?

Potrzebujemy jeszcze przejść. Te będą z kolei bardzo podobne do tych, które powinieneś otrzymać rozwiązując zadanie z poprzedniego akapitu. C[i+m]=min(C[i+m],C[i]+1).C[i+m] = min(C[i+m], C[i]+1). Ten wzór nie jest trudny - mamy do wyboru zostawić poprzednią opcję lub wziąć o jeden więcej element typu m.m. To co? Czas na rozwiązanie.

const int MAX_S = 2003; const int inf = 1000000009; int DP[MAX_S + 1], C[MAX_S + 1], ILE[MAX_S + 1]; int sumaPierwiastkowa(int m[], int n, int w) { int s = 0; for (int j = 1; j <= n; j ++) { s += m[j]; ILE[m[j]] ++; } for (int i = 0; i <= s; i ++) DP[i] = 0; DP[0] = 1; for (int j = 1; j <= s; j ++) if (ILE[j] > 0) { //rozwazamy elementy rodzaju j, ktorych jest ILE[j] //w tym miejscu nie bedziemy wiecej niz sqrt(s) razy for (int i = 0; i <= s; i ++) { if (DP[i] == 0) C[i] = inf; else C[i] = 0; } for (int i = 0; i <= s-j; i ++) C[i + j] = min(C[i + j], C[i] + 1); for (int i = 0; i <= s; i ++) if (C[i] <= ILE[j]) DP[i] = 1; } return DP[w]; }

Zadania