Programowanie Dynamiczne
Problem plecakowy
Podczas wykładu o algorytmach zachłannych postawiliśmy taki problem:
Mamy przedmiotów. Każdy ma swoją masę i wartość Dysponujemy plecakiem, który jest w stanie pomieścić przedmioty o sumarycznej masie nie większej niż 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.
Dana jest liczba naturalna i liczb naturalnych ..., Chcemy powiedzieć, czy da się wybrać niektóre z liczb tak, aby sumowały się do
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 będzie tablicą, w której przechowujemy wyniki. jeśli liczby nie da się utworzyć, a w przeciwnym przypadku
Stan już zdefiniowaliśmy. Teraz czas na algorytm. Będziemy dodawać elementy po kolei i aktualizować tablicę 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 Dla reszty wynosi na początku
Teraz kolej na przejścia. Jakie mamy zależności między naszymi stanami? Rozważmy dodawanie liczby Wówczas, jeśli umieliśmy utworzyć sumę bez tej liczby to z pewnością liczbę też możemy w jakiś sposób utworzyć. Wystarczy bowiem wziąć ten zbiór, który dawał nam i dodać do niego liczbę 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 i pamięciowej Okazuje się, że możemy poradzić sobie nieznacznie lepiej.
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 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 razy mniejszej stałej, niż gdybyśmy trzymali 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ę ? Każda jedynka z tablicy przelatuje o 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 tablicę a potem używamy funkcji aby zanotować, że jeśli była jedynka w miejscu wcześniej lub znajduje się teraz to chcemy tę jedynkę zostawić. Teraz złażoność naszego programu to nadal ale działa on 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
}
}
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 będziemy chcieli stablicować największą możliwą wartość elementów wybranych do plecaka pod warunkiem, że zużyliśmy miejsca w plecaku. Oznaczymy to przez - to nasz stan.
Czego jeszcze potrzebujemy? Stanów bazowych. Na początku nie mamy żadnych elementów, czyli w tablicy są same zera.
Teraz kolej na przejścia. Zrobimy dokładnie to samo, co poprzednio. Powiedzmy, że rozpatrujemy -ty przedmiot o masie i wartości Dla każdej poprzedniej możliwości możemy teraz dodać ten element. Zapiszmy więc:
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 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.
Istnieje jeszcze jedna przydatna optymalizacja do problemu sumy, którą warto znać. Załóżmy, że wszystkie liczby z ciągu sumują się do pewnej liczby Okazuje się, że umiemy wówczas zaproponować algorytm działający w złożoności czasowej łącząc dwa powyżej omówione rozwiązania. Zauważmy, istnieje maksymalnie różnych elementów, ponieważ suma wszystkich elementów wynosi a najmniejsza możliwa suma różnych elementów to
Nic nie stoi na przeszkodzie, aby wszystkie takie same elementy rozważyć jednocześnie. Zajmie to nam nie więcej niż gdzie to czas rozważenia jednego typu elementów. Spróbujmy to zrobić. Zliczmy w pomocniczej tablicy ile razy występuje każdy z elementów. Niech liczba występuje razy. będzie mówiło nam, ile minimalnie elementów typu potrzebujemy, aby dało się przedstawić jako sumę pewnych elementów ze zbioru.
Na początku wszystkie przyjmują wartość z wyjątkiem tych dla których wynosiło już wcześniej 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. Ten wzór nie jest trudny - mamy do wyboru zostawić poprzednią opcję lub wziąć o jeden więcej element typu 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];
}