Programowanie dynamiczne (optymalizacja dynamiczna). W wielu przypadkach zadania, których złożoność wynikająca z pełnego przeglądu jest duża (zwykle wyk ładnicza) można rozwiązać w czasie wielomianowym stosując metodę optymalizacji dynamicznej. Programowanie dynamiczne - metoda znajdowania rozwiązań problemów optymalizacyjnych podobna do metody dziel i zwyciężaj - w metodzie tej zadany problem dzielimy na podproblemy, rozwiązujemy je, a następnie łączymy rozwiązania podproblemów. metoda dziel i zwyciężaj - wielokrotnie rozwiązuje ten sam problem, wykonuje więc dużo więcej pracy programowanie dynamiczne - algorytm oparty na tej metodzie rozwiązuje każdy podproblem tylko jeden raz zapamiętując wynik, unika się w ten sposób wielokrotnych obliczeń dla tego samego podproblemu
Rozpatrzmy rekurencyjny algorytm obliczający ciąg Fibonacciego: Fib(0)= 0 Fib(1)=1 Fib(n)= Fib(n-1)+Fib(n-2) dla n 2 int Fib( int n ) { if( n < 1 ) return 0; if( n == 1 ) return 1; return Fib(n-1) + Fib(n-2); [Fib(5)] = Fib(4) + Fib(3) [Fib(4)] = Fib(3) + Fib(2) [Fib(3)] = Fib(2) + Fib(1) [Fib(3)] = Fib(2) + Fib(1) [Fib(2)] = Fib(1) + Fib(0) [Fib(2)] = Fib(1) + Fib(0) [Fib(2)] = Fib(1) + Fib(0)
Algorytm rekurencyjny mo ż na poprawi ć wykorzystuj ą c tak zwane "zapamiętywanie". Wykorzystujemy w tym celu tablicę zawierającą już obliczone wartości: int fib[max]; // inicjujemy ją, aby oznaczyć wszystkie wartości jako "nieznane" for( i= 0; i < MAX; i++ ) fib[i]= -1; int Fib( int n { if ( n < 1 ) return 0; if ( n == 1 ) return 1; if ( fib[n] == -1 ) fib[n]= Fib(n-1) + Fib(n-2); return fib[n];
Etapy programowania dynamicznego Scharakteryzowanie struktury optymalnego rozwiązania Rekurencyjne zdefiniowanie kosztu optymalnego rozwiązania. Obliczenie optymalnego kosztu metodą wstępującą. Konstruowanie optymalnego rozwiązania na podstawie wcześniejszych obliczeń. Problem: Dla danego ciągu n macierzy <A 1, A 2,..., A n >, gdzie dla i=1, 2,..., n macierz A i ma wymiar p i-1 x p i, wyznaczyć taką kolejność mnożenia macierzy A 1 A 2... A n aby koszt obliczeń był jak najmniejszy.
Pseudokod standardowego algorytmu mnożenia dwóch macierzy MNOŻENIE_MACIERZY(A,B) { if ile_kolumn[a] ile_wierszy[b] then error else { for i=1 to ile_wierszy[a] for j=1 to ile_kolumn[b] do { C[i,j]:=0 for k=1 to ile_kolumn[a] do C[i,j]:=C[i,j]+A[i,k] B[k,j] return C
Obserwacja: Czas obliczeń jest zdeterminowany przez ilość mnożeń skalarnych. Jeśli A i B są macierzami o rozmiarze odpowiednio p x q i q x r, to ilość mnożeń wynosi pqr. Przykład: A1 o rozmiarze 10 x 100 A2 o rozmiarze 100 x 5 A3 o rozmiarze 5 x 50 Ile mnożeń skalarnych? ((A1A2)A3) 10 x 100 x 5 + 10 x 5 x 50 = 5000 + 2500 = 7500 (A1(A2A3) 100 x 5 x 50 + 10 x 100 x 50 = 25000 + 50000=75000
Struktura optymalnego rozwiązania Oznaczenie: A i..j =Ai A i+1 A j Obserwacja: Umieszczenie nawiasów w podci ą gu A 1 A 2 A k w optymalnym nawiasowaniu A 1 A 2 A n jest optymalnym nawiasowaniem A 1 A 2 A k. Dowód: Gdyby istniało nawiasowanie A 1 A 2 A k o niższym koszcie, to w połączeniu z optymalnym nawiasowaniem A 1 A 2 A n otrzymalibyśmy nawiasowanie A 1 A 2 A k o koszcie niższym czyli sprzeczność.
Zdefiniowanie rekurencyjne kosztu optymalnego. Podproblemy: wyznaczenie optymalnego nawiasowania iloczynów Ai A i+1 A j gdzie 1 i j n Oznaczenie: m[i, j] minimalna liczba mnożeń skalarnych niezbędnych do obliczenia macierzy A i..j Oznaczenie: s[i, j] wartość k, która realizuje minimalną wartość m[i, j] Niech dane wejściowe stanowi ciąg <p 0, p 1,...,p n > rozmiarów macierzy, gdzie d ł ugość [p]=n+1, uż ywamy macierzy m[1..n,1..n] oraz s[1..n,1..n] do zapamiętywania kosztów oraz indeksów k dających optymalny podział
MACIERZE_KOSZT(p) n:=długość[p]-1 for i=1 to n do m[i, i]:=0 for w=2 to n do { for i=1 to n-w+1 do { j=i+w-1 m[i,j]:= for k=i to j-1 do { q:=m[i,k]+m[k+1,j]+p i-1 p k p j if q<m[i,j] then { m[i,j]:=q, s[i,j]:=k return (m, s)
Konstruowanie optymalnego rozwiązania MNOŻENIE_ŁAŃCUCHA_MACIERZY(A, s, i, j) if j>i then X:= MNOŻENIE_ŁAŃCUCHA_MACIERZY(A, s, i, s[i, j]) Y:= MNOŻENIE_ŁAŃCUCHA_MACIERZY(A, s, s[i, j]+1, j) return MNOŻENIE_MACIERZY(X, Y) else return Ai
Charakterystyczne elementy metody programowania 1.Problem wykazuje optymalną podstrukturę, jeśli jego optymalne rozwiązanie jest funkcją optymalnych rozwiązań podproblemów; metoda dowodzenia jest następująca: zakładamy, że jest lepsze rozwiązanie podproblemu, po czym pokazujemy, iż to założenie zaprzecza optymalności rozwiązania całego problemu 2. Rozsądnie mała przestrzeń istotnie różnych podproblemów. Mówimy, że problem ma w ł asno ść wspólnych podproblemów, jeś li algorytm rekurencyjny wielokrotnie oblicza rozwiązanie tego samego podproblemu bo algorytmy oparte na programowaniu dynamicznym rozwiązują dany podproblem tylko jeden raz i zapamiętują gotowe rozwiązania, które potem wystarczy odczytać w stałym czasie. Jeśli problem wykazuje te własności warto spróbować, czy daje się stosować metoda programowania dynamicznego.
PROBLEM NAJDŁUŻSZEGO WSPÓLNEGO PODCIĄGU (Problem LCS) Problem znalezienia Najdłuższego Wspólnego Podciągu (LCS Longest Common Subsequences) pojawia się w kilku praktycznych zastosowaniach : 1.Poszukiwanie podobieństwa dwóch pary nici DNA jako jednej z miar stopnia pokrewieństwa odpowiadających im organizmów 2.Wył apywanie plagiatów muzycznych poprzez analiz ę ich linii melodycznych 3.Porównywanie zawartości plików 4.Kryptografii Najefektywniejsza metoda jego rozwiązania opiera się na programowaniu dynamicznym.
Dane są ciągi: X = <x 1, x 2,..., x m > i Y = <y 1, y 2,..., y n >. Problem: Znaleźć najdłuższy wspólny podciąg ciągów X i Y. Jak działa algorytm wyczerpujący? Bierzemy krótszy z ciągów (załóżmy, że jest to X), generujemy wszystkie podciągi ciągu X, sprawdzamy, który podciąg jest też podciągiem Y, zapamiętujemy najdłuższy podciąg. Ile jest podciągów ciągu X? Każdy podciąg ciągu X jest jednoznacznie wyznaczony przez pewien podzbiór zbioru {1, 2,..., m, zatem ilość podciągów jest równa ilości podzbiorów zbioru {1, 2,.., m, czyli 2 m. Czyli razem ze sprawdzeniem O(2 m n). Wniosek: algorytm wyczerpujący ma złożoność wykładniczą!.
Struktura optymalnego rozwiązania Na szczęście ten problem ma własność optymalnej podstruktury i naturalna przestrzeń podproblemów odpowiada w tym przypadku parom prefiksów ciągów wejściowych. Oznaczenie: Niech X = <x 1, x 2,..., x m > i Xi = <x 1, x 2,..., x i > dla i=0,1,2,...,m będzie i-tym prefiksem ciągu X, przy czym Xo jest ciągiem pustym. Twierdzenie (o optymalnej podstrukturze NWP) Niech X = <x1, x2,..., xm> i Y= <y1, y2,..., yn> oraz niech Z = <z1, z2,..., zk> będzie NWP ciągów X i Y. Wtedy: jeśli xm = yn, to zk= xm=yn oraz Zk-1 jest NWP(Xm-1, Yn-1) jeśli x m yn i zk xm, to Z jest NWP(Xm-1,Y), jeśli x m yn i zk yn, to Z jest NWP(X, Yn-1).
Rekurencyjne zdefiniowanie kosztu optymalnego rozwiązania Obserwacja: Z twierdzenia wynika, że aby obliczyć NWP(X, Y) trzeba rozwiązać jeden lub dwa podproblemy: jeżeli xm = yn, to znajdujemy NWP(Xm-1, Yn-1), a następnie dołączając xm=yn otrzymujemy NWP(X, Y) jeżeli xm yn, to znajdujemy NWP(Xm-1, Y) oraz NWP(X, Yn-1), a następnie wybierając dłuższy z nich otrzymujemy NWP(X, Y)
Oznaczenie: c[i, j] długość NWP(Xi, Yj) Uwaga: jeśli i=0 lub j=0, to c[i, j]=0 Wniosek:
Obliczanie długości NWP Obserwacja 1: Jest O(mn) rożnych podproblemów problemu NWP. Dowód: X ma długość m, Y ma długość n Obserwacja 2: Problem NWP ma własność wspólnych podproblemów. Dowód: Aby znaleźć NWP(X, Y) możemy potrzebować NWP(Xm-1, Y) oraz NWP(X, Yn-1). Oba te podproblemy mogą zawierać wspólny podproblem znalezienia NWP(Xm-1, Yn-1). Wniosek: Można stosować programowanie dynamiczne. dane wejściowe: ciągi X i Y c[1.. m, 1.. n ] - przechowuje wartości c[i, j] b[1.. m, 1.. n] - b[i, j] wskazuje na pole w tablicy c odpowiadające wybranemu podczas obliczeń c[i, j] optymalnemu rozwiązaniu podproblemu
NWP_Długość(X,Y){ m:=długość(x) n:=długość(y) for i:=1 to m do c[i,0]:=0 for j:=1 to n do c[0,j]:=0 for i:=1 to m for j:=1 to n if xi=yjthen { c[i, j]:=c[i-1, j-1]+1; b[i, j]:= else if c[i-1, j] c[i, j-1] then { c[i, j]:=c[i-1, j]; b[i, j]:= else { c[i, j]:=c[i, j-1]; b[i, j]:= return c i b
Przykład: X = <A, B, C, B, D, A, B> Y = <B, D, C, A, B, A> Rozwiązanie: <B, C, B, A>
Konstrukcja NWP na podstawie wcześniejszych obliczeń Procedura Drukuj_NWP - wypisuje NWP(X, Y) korzystając z tablicy b, każda strzałka typu w polu b[i, j] oznacza, że x i =y j należy do NWP Drukuj_NWP(b, X, Y, i, j) if i=0 lub j=0 then return; if b[i, j]= then { Drukuj_NWP(b, X, Y, i-1, j-1); wypisz xi else if b[i, j]= then Drukuj_NWP(b, X, Y, i-1, j) else Drukuj_NWP(b, X, Y, i, j-1); Obserwacja: Czas działania wynosi O(m*n)
Inne rozwiązania algorytmiczne tego samego problemu. Wagner i Fisher 1974 zaproponowa ł rozwią zanie problemu LCS z wykorzystaniem programowania dynamicznego przedstawiony wcześniej o rozwiązaniu O(mn). Hirschberg 1975 zaproponował metodę dziel i zwyciężaj w oparciu o programowanie dynamiczne o złożoności czasowej O(n 2 ) ale o pamięciowej tylko O(n). Hirschberg 1977 O(pn) gdzie p to długość LCS Hunt i Szymański 1977 O(rlogn) gdzie r liczba wszystkich dopasowań. Należy zwrócić uwagę, że te dwa algorytmy są efektywne dla p i r małych. Niestety w najgorszym przypadku gdy p=n a r=n 2 algorytmy osiągają wartości O(n 2 ) i O(n 2 logn) gorsze od standardowej metody opartej na programowaniu dynamicznym. Masek i Paterson 1980 zaproponowali algorytm O(n 2 /logn).
Allison i Dix 1986 (lub Crochemore 2001) zaproponowali algorytm oparty na bitowej równoległości o złożoności obliczeniowej gdzie w to rozmiar słowa maszynowego. Np. na 32-bitowej maszynie uzyskano szybszy o ok. 30 razy, na 64- bitowej maszynie uzyskano szybszy o ok. 55 razy.