Algorytmy z nawrotami
Transkrypt
Algorytmy z nawrotami
Podstawy Programowania – semestr drugi Wykład dziesiąty Algorytmy z nawrotami (ang. backtracking algorithms) Istnieje pewna grupa problemów, których ogólną postać można scharakteryzować następująco: „Mając punkt początkowy i końcowy, lub jego charakterystykę znajdź sposób przejścia od pierwszego do drugiego”. Nie istnieje ogólna, efektywna metoda radzenia sobie z tego typu problemami. Najprościej jest do nich zastosować metodę „prób i błędów”, która sprowadza się do żmudnego poszukiwania rozwiązania. Do klasy tych problemów zaliczają się między innymi: problem skoczka szachowego, problem ośmiu hetmanów, problem znalezienia optymalnej strategii gry. Można je rozwiązać za pomocą kartki i ołówka, lub innych „klasycznych” przyrządów, ale ze względu na pracochłonność całej operacji lepiej jest w tym celu posłużyć się komputerem. Algorytmy, które sobie radzą z takimi problemami nazywamy algorytmami 1 z nawrotami . Ich charakterystyczną cechą jest to, że korzystają one z techniki rekurencji, a więc należą również do klasy algorytmów rekurencyjnych. Poznamy sposób konstruowania takich algorytmów na przykładzie algorytmu rozwiązującego problem dzbanów z wodą (ang. water jug problem), który jest sformułowany następująco: „Mając dwa dzbany, o pojemności odpowiednio, cztery i trzy litry, odmierzyć dwa litry wody. Zakładamy, że rozwiązanie jest znalezione, jeśli w dzbanie czterolitrowym są dwa litry wody, a w dzbanie trzylitrowym nie ma wody.” Zastanówmy się chwilę nad tym sformułowaniem. Wiemy, że na początku oba dzbany są puste. Wiemy również, że w wyniku pewnych działań w dzbanie o większej pojemności mają znaleźć się dwa litry wody, a dzban o mniejszej pojemności ma być pusty. Po bardziej wnikliwym przemyśleniu problemu dojdziemy do wniosku, że liczba czynności jakie możemy wykonać na dzbanach jest ograniczona i że możemy dokładnie określić co to będą za czynności. Zauważmy jeszcze, że na początku nasze dzbany nie zawierają wody, a po wykonaniu którejkolwiek z czynności w każdym z nich zostaje pewna określona (być może zerowa) ilość wody, a więc możemy mówić o kolejnych stanach dzbanów, które powstają w wyniku wykonania na nich pewnych czynności . Te czynności (operatory) tworzą krawędzie, a stany wierzchołki grafu (drzewa). W takim razie rozwiązanie problemu dzbanów z wodą sprowadza się do znalezienia ścieżki w grafie prowadzącej od korzenia (stanu początkowego dzbanów) do wierzchołka docelowego, jakim jest ustalony stan końcowy dzbanów! Powstaje jednak pewna trudność: nie znamy tego grafu. Okazuje się, że mamy już gotowy sposób na ominięcie tej trudności: mając stan początkowy możemy wygenerować następne stany używając w tym celu wcześniej zdefiniowanych operatorów. Niestety, ta metoda może okazać się skrajnie nieefektywna: proces generowania całego grafu będzie czasochłonny, a ostatecznie może się okazać, że nie uda nam się go zmieścić w pamięci operacyjnej. Rozwiązanie ostatniej trudności jest równie proste jak poprzedniej: należy generować tylko wierzchołki należące do badanej bieżąco gałęzi, jeśli któryś z wierzchołków powtórzy się w tej ścieżce, to wracamy do jego przodka i próbujemy wygenerować dla niego innego potomka, używając innego operatora. Proces ten zakończymy, jeśli znajdziemy wierzchołek końcowy. Opisana wyżej metoda sprowadza się więc do przechodzenia grafu metodą deep-first search lub przechodzenia drzewa metodą preorder. Zdefiniujmy sobie operatory dla problemu dzbanów z wodą: Niech „j3” oznacza stan dzbana o pojemności trzech litrów, a „j4” oznacza stan dzbana o pojemności czterech litrów. 1. Pierwszy operator: Napełnij dzban czterolitrowy. 2. Drugi operator: Napełnij dzban trzylitrowy. 3. Trzeci operator: Opróżnij dzban czterolitrowy. 4. Czwarty operator: Opróżnij dzban trzylitrowy. 5. Piąty operator: Przelej wodę z dzbana trzylitrowego do czterolitrowego, tak aby ten ostatni był wypełniony całkowicie. 6. Szósty operator: Przelej wodę z dzbana czterolitrowego do trzylitrowego, tak aby ten ostatni był wypełniony całkowicie. 7. Operator siódmy: Przelej wodę z dzbana trzylitrowego do czterolitrowego, tak aby opróżnić ten pierwszy. 8. Operator ósmy: Przelej wodę z dzbana czterolitrowego do trzylitrowego, tak aby opróżnić ten pierwszy. Warto zwrócić uwagę, że w zapisie symbolicznym lewa strona wyrażenia zawiera warunek, który musi spełniać stan, aby móc do niego zastosować określony operator, a prawa określa stan jaki powstanie w wyniku zastosowania tego operatora. Rozwiązaniem problemu dzbanów z wodą będzie więc ciąg stanów (wierzchołków grafu) wraz z operatorami (krawędziami), które będą określały w jaki sposób przejść z jednego do drugiego stanu. Metodę przeszukiwania takiego grafu można opisać za pomocą pseudokodu, zanim to jednak uczynimy spróbujmy przedstawić za pomocą rysunku jego fragment. Korzeniem będzie oczywiście stan początkowy, każda krawędź będzie oznaczona numerem operatora, który generuje następny stan na danej ścieżce, ścieżki przekreślone będą oznaczały ścieżki, które nie prowadzą do rozwiązania: 1 Spotyka się również w literaturze termin „algorytmy z powrotami”. 1 Podstawy Programowania – semestr drugi (0,0) 1 2 (0,3) (4,0) 2 (4,3) 3 6 (0,0) 1 (1,3) (4,3) 4 (0,0) 7 (3,0) Na powyższym rysunku wyróżnione zostały dwa wierzchołki. Odpowiadają one temu samemu stanowi, co korzeń, a więc nie będą dalej rozwijane, gdyż nie prowadzą do rozwiązania problemu. Pozostałe wierzchołki nie wystąpiły jeszcze na ścieżce, a więc będą dalej przetwarzane. Któraś z tak generowanych ścieżek będzie na pewną zawierała wierzchołek końcowy i będzie stanowiła rozwiązanie problemu dzbanów z wodą. Ścieżkę, którą bieżąco rozpatruje algorytm z nawrotami będziemy zapisywać w kolejce. Proces przeszukiwania możemy opisać w pseudokodzie następująco: Jeśli po znalezieniu stanu końcowego bezwarunkowo byłby wywoływany podprogram „znajdź”, to otrzymalibyśmy taki sam wynik jaki daje wersja pseudokodu umieszczona obok. Różnica polegałaby na szybkości wykonania obu wersji – ta zamieszczona obok wykonuje się szybciej. Wersja z bezwarunkowym wywołaniem rekurencyjnym wygenerowałaby jeszcze dodatkowe stany po znalezieniu stanu końcowego, ale któryś z nich powtarzałby się, a zatem nie prowadziłby do prawidłowego znajdź( ścieżka) begin jeśli warunek_dla_operatora_1(ostatni_stan) to begin stwórz(nowy_stan); rozwiązania. jeśli jeszcze_nie_wystąpił(ścieżka) to Czytając zamieszczony obok pseudokod można zauważyć że algorytm znajduje tylko jedno rozwiązanie, czyli jedną ścieżkę wiodącą od korzenia do wierzchołka docelowego. Czy jest zatem możliwe znalezienie wszystkich możliwych rozwiązań tego problemu? Odpowiedź na to pytanie jest pozytywna. Metoda taka istnieje i wymaga tylko niewielkich przeróbek w algorytmie i strukturach danych na których on pracuje. Musimy zamienić kolejkę FIFO na kolejkę o ograniczonym wejściu, by móc usuwać ostatni element tej kolejki po zakończeniu każdego wywołania rekurencyjnego. Pseudokod ilustrujący to rozwiązanie umieszczono niżej: begin dodaj_do_ścieżki(nowy_stan); jeśli nowy_stan=stan_końcowy to wypisz(ścieżka) w przeciwnym przypadku znajdź(ścieżka); end w przeciwnym przypadku usuń(nowy_stan); end ... jeśli warunek_dla_operatora_N(ostatni_stan) to begin stwórz(nowy_stan); jeśli jeszcze_nie_wystąpił(ścieżka) to begin dodaj_do_ścieżki(nowy_stan); jeśli nowy_stan=stan_końcowy to wypisz(ścieżka) w przeciwnym przypadku znajdź(ścieżka); end w przeciwnym przypadku usuń(nowy_stan); end end 2 Podstawy Programowania – semestr drugi znajdź( ścieżka) begin jeśli warunek_dla_operatora_1(ostatni_stan) to begin stwórz(nowy_stan); jeśli jeszcze_nie_wystąpił(ścieżka) to begin dodaj_do_ścieżki(nowy_stan); jeśli nowy_stan=stan_końcowy to wypisz(ścieżka) w przeciwnym przypadku znajdź(ścieżka); usuń_ostatni_stan(ścieżka); end w przeciwnym przypadku usuń(nowy_stan); end ... jeśli warunek_dla_operatora_N(ostatni_stan) to begin stwórz(nowy_stan); jeśli jeszcze_nie_wystąpił(ścieżka) to begin dodaj_do_ścieżki(nowy_stan); jeśli nowy_stan=stan_końcowy to wypisz(ścieżka) w przeciwnym przypadku znajdź(ścieżka); usuń_ostatni_stan(ścieżka); end w przeciwnym przypadku usuń(nowy_stan); end end Mając określony już schemat algorytmu z nawrotami, który znajduje wszystkie rozwiązania problemu dzbanów z wodą, możemy przystąpić do jego implementacji w Turbo Pascalu: Wiersze 3 – 8 zawierają definicję typu bazowego kolejki. Pole „j3” reprezentuje stan dzbana trzylitrowego, pole „j4” stan dzbana czterolitrowego, a pole „oper” numer operatora, który został zastosowany do wygenerowania stanu dzbanów (numer zero będzie oznaczał, że jest to stan początkowy – korzeń drzewa). Pole „next” jest oczywiście wskaźnikiem na następny element kolejki. Zmienna globalna „solution” (wiersz 13) zawiera numer rozwiązania problemu, jakie zostało znalezione przez program. Procedura „enqueue” (wiersze 15 – 19) służy oczywiście do dodawania nowego elementu na koniec listy, procedura „dequeue” (wiersze 21 – 31) służy do usuwania pierwszego elementu z kolejki. Funkcja „already_been” (wiersze 33 – 45), sprawdza, czy dany wierzchołek (stan) pojawił się już na ścieżce. Jeśli tak, zwraca wartość „true”, jeśli nie, zwraca wartość „false”. Jej działanie sprowadza się do przeszukania całej kolejki, na której zapisane są poszczególne, odwiedzone już stany i sprawdzeniu, czy element opisujący nowy stan ma takie samie wartości pól „j3” i „j4”, jak któryś z elementów należących do kolejki. Procedura „remove_tail” (wiersze 47 – 65) usuwa element znajdujący się na końcu kolejki. Dzięki niej kolejka jest kolejką dwustronną o ograniczonym wejściu – można z niej zdejmować elementy zarówno na końcu jak i na początku, ale dodawać można tylko na końcu. Procedura „print_queue” (wiersze 67 – 86) wypisuje na ekran znalezione rozwiązanie, podając jego numer oraz numerując kolejne kroki jakie są wykonywane w rozwiązaniu. Procedura ta zatrzymuje się po każdym wypisanym kroku, pozwalając użytkownikowi przeczytać wypisaną na ekranie treść i w razie konieczności dając możliwość wyczyszczenia ekranu przez naciśnięcie klawisza Esc. Procedura „remove_queue” (wiersze 88 – 92) służy do usunięcia kolejki z pamięci. Jest ona 1 program water_jug_problem; 2 uses crt; 3 type 4 queue=^element; 5 element=record 6 j3,j4,oper:byte; 7 next:queue; 8 end; 9 10 var 11 head,tail,first:queue; 12 mem:longint; 13 solution:byte; 3 Podstawy Programowania – semestr drugi 14 15 procedure enqueue(var h,t:queue; nowy:queue); 16 begin 17 t^.next:=nowy; 18 t:=nowy; 19 end; 20 21 procedure dequeue(var h:queue); 22 var 23 tmp:queue; 24 begin 25 if h<>nil then 26 begin 27 tmp:=h^.next; 28 dispose(h); 29 h:=tmp; 30 wywoływana po procedurze „search” w programie głównym i jako jedyna korzysta z procedury „dequeue”. Procedura „create” (wiersze 94 – 101) służy do tworzenia nowego stanu – przydziela pamięć na element go reprezentujący oraz wypełnia jego pola przekazanymi przez parametr wartościami. Jej nagłówek odpowiada prawej stronie wyrażenia opisującego operator, ale „j3” i „j4” zostały zamienione miejscami. Główną procedurą w tym programie jest procedura „search”. Jej kod źródłowy został podzielony na części rozdzielone komentarzami, które odpowiadają poszczególnym operatorom. Łatwo zauważyć, że jej treść jest zgodna z pseudokodem, który znajdował wszystkie rozwiązania problemu dzbanów z wodą. Kod realizujący pojedynczy operator zamknięty jest w instrukcji warunkowej, której zadaniem jest określenie, czy ostatni wygenerowany stan nadaje się do zastosowania do niego tego operatora. Jeśli tak, to generowany jest nowy stan zgodnie z regułami określonymi przez ten operator i następuje sprawdzenie, czy ten stan już nie wystąpił. Jeśli nie, to dodawany jest on do kolejki, a następnie procedura sprawdza, czy był on stanem końcowym. Jeśli tak, to wypisywana jest całość kolejki, która stanowi pojedyncze rozwiązanie problemu. Jeżeli nie, to wywoływana jest rekurencyjnie „search” dla nowo utworzonego stanu. Po zakończeniu wywołania rekurencyjnego dodany przez operator stan jest usuwany z kolejki i na tym kończy się działanie operatora. Jeśli stan wygenerowany przez operator już wystąpił, wówczas zwalniana jest pamięć na reprezentujący go element i kończone jest działanie operatora. W programie głównym tworzony jest pierwszy element kolejki odpowiadający stanowi wejściowemu w problemie i wywoływana jest dla niego procedura „search”. Po jej zakończeniu usuwana jest z pamięci kolejka, przy pomocy procedury „remove_queue”. end; 31 end; 32 33 function already_been(h,nowy:queue):boolean; 34 begin 35 already_been:=false; 36 while h<>nil do 37 begin 38 if (h^.j3=nowy^.j3) and (h^.j4=nowy^.j4) then 39 begin 40 already_been:=true; 41 break; 42 end; 43 h:=h^.next; 44 end; 45 end; 46 47 procedure remove_tail(var h,t:queue); 48 var 49 tmp:queue; 50 begin 51 if h^.next=nil then 52 begin 53 dispose(h); 54 h:=nil; 55 t:=nil; 56 end 57 else 58 begin 4 Podstawy Programowania – semestr drugi 59 tmp:=h; 60 while tmp^.next<>t do tmp:=tmp^.next; 61 tmp^.next:=nil; 62 dispose(t); 63 t:=tmp; 64 end; 65 end; 66 67 procedure print_queue(h:queue; var s:byte); 68 var 69 krok:byte; 70 a:char; 71 begin 72 krok:=0; 73 writeln('Rozwiązanie: ',s); 74 while h<>nil do 75 begin 76 writeln('Krok: ',krok); 77 writeln('Dzban 4 litrowy: ',h^.j4,' dzban 3 litrowy: ',h^.j3, ' operator: ', h^.oper,'.'); 78 while keypressed do readkey; 79 a:=readkey; 80 if a=#27 then clrscr; 81 if (h^.j4=2) and (h^.j3=0) then clrscr; 82 inc(krok); 83 h:=h^.next; 84 end; 85 inc(s); 86 end; 87 88 procedure remove_queue(var h,t:queue); 89 begin 90 while h<>nil do dequeue(h); 91 t:=nil; 92 end; 93 94 procedure create(var nowy:queue; j3,j4,o:byte); 95 begin 96 new(nowy); 97 nowy^.j3:=j3; 98 nowy^.j4:=j4; 99 nowy^.oper:=o; 100 nowy^.next:=nil; 101 end; 102 103 procedure search(var h,t:queue; var s:byte); 5 Podstawy Programowania – semestr drugi 104 var 105 nowy:queue; 106 begin 107 {Napełnianie dzbana 4 litrowego.} 108 if t^.j4<4 then 109 begin 110 create(nowy,t^.j3,4,1); 111 if not already_been(h,nowy) then 112 begin 113 enqueue(h,t,nowy); 114 if (t^.j4=2) and (t^.j3=0) then print_queue(h,s) else 115 search(h,t,s); 116 remove_tail(h,t); 117 118 end else dispose(nowy); 119 end; 120 {Napełnianie dzbana 3 litrowego.} 121 if t^.j3<3 then 122 begin 123 create(nowy,3,t^.j4,2); 124 if not already_been(h,nowy) then 125 begin 126 enqueue(h,t,nowy); 127 if (t^.j4=2) and (t^.j3=0) then print_queue(h,s) else 128 search(h,t,s); 129 remove_tail(h,t); 130 131 end else dispose(nowy); 132 end; 133 {Opróżnianie dzbana 4 litrowego.} 134 if t^.j4>0 then 135 begin 136 create(nowy,t^.j3,0,3); 137 if not already_been(h,nowy) then 138 begin 139 enqueue(h,t,nowy); 140 if (t^.j4=2) and (t^.j3=0) then print_queue(h,s) else 141 search(h,t,s); 142 remove_tail(h,t); 143 144 end else dispose(nowy); 145 end; 146 {Opróżnianie dzbana 3 litrowego.} 147 if t^.j3>0 then 148 begin 6 Podstawy Programowania – semestr drugi 149 create(nowy,0,t^.j4,4); 150 if not already_been(h,nowy) then 151 begin 152 enqueue(h,t,nowy); 153 if (t^.j4=2) and (t^.j3=0) then print_queue(h,s) else 154 search(h,t,s); 155 remove_tail(h,t); 156 157 end else dispose(nowy); 158 end; 159 {Przelanie zawartości dzbana 3 litrowego do 4 litrowego.} 160 if (t^.j3+t^.j4>=4) and (t^.j3>0) then 161 begin 162 create(nowy,t^.j3-(4-t^.j4),4,5); 163 if not already_been(h,nowy) then 164 begin 165 enqueue(h,t,nowy); 166 if (t^.j4=2) and (t^.j3=0) then print_queue(h,s) else 167 search(h,t,s); 168 169 170 remove_tail(h,t); end else dispose(nowy); 171 end; 172 {Przelanie zawartości dzbana 4 litrowego do 3 litrowego.} 173 if (t^.j3+t^.j4>=3) and (t^.j4>0) then 174 begin 175 create(nowy,3,t^.j4-(3-t^.j3),6); 176 if not already_been(h,nowy) then 177 begin 178 enqueue(h,t,nowy); 179 if (t^.j4=2) and (t^.j3=0) then print_queue(h,s) else 180 search(h,t,s); 181 remove_tail(h,t); 182 183 end else dispose(nowy); 184 end; 185 {Przelanie zawartości dzbana 3 litrowego do 4 litrowego z opróżnieniem.} 186 if (t^.j3+t^.j4<=4) and (t^.j3>0) then 187 begin 188 create(nowy,0,t^.j3+t^.j4,7); 189 if not already_been(h,nowy) then 190 begin 191 enqueue(h,t,nowy); 192 if (t^.j4=2) and (t^.j3=0) then print_queue(h,s) else 193 search(h,t,s); 7 Podstawy Programowania – semestr drugi 194 195 196 remove_tail(h,t); end else dispose(nowy); 197 end; 198 {Przelanie zawartości dzbana 4 litrowego do 3 litrowego z opróżnieniem.} 199 if (t^.j3+t^.j4<=3) and (t^.j4>0) then 200 begin 201 create(nowy,t^.j4+t^.j3,0,7); 202 if not already_been(h,nowy) then 203 begin 204 enqueue(h,t,nowy); 205 if (t^.j4=2) and (t^.j3=0) then print_queue(h,s) else 206 search(h,t,s); 207 remove_tail(h,t); 208 209 210 end else dispose(nowy); end; 211 end; 212 213 begin 214 clrscr; 215 mem:=memavail; 216 new(first); 217 first^.oper:=0; 218 first^.j4:=0; 219 first^.j3:=0; 220 first^.next:=nil; 221 head:=first; 222 tail:=first; 223 solution:=1; 224 search(head,tail,solution); 225 remove_queue(head,tail); 226 if mem-memavail<>0 then writeln('Błąd zarządzania pamięcią!'); 227 end. Podsumowanie Przedstawiony wyżej program znajduje czternaście rozwiązań problemu dzbanów z wodą. Po ich bliższej analizie można dojść do wniosku, że część z nich jest nieoptymalna (zawiera zbędne kroki). Rzeczywiście, przeszukiwanie realizowane przez algorytm z nawrotami jest przeszukiwaniem siłowym (ang. brute force). Aby wybierać tylko rozwiązania optymalne, należałoby tak go zmodyfikować, aby oceniał przydatność niektórych posunięć. Algorytmy z nawrotami nie służą jedynie do rozwiązywania problemów, które mają formę zagadki matematycznej. Mogą być również stosowane w wyznaczaniu ekstremów rozbudowanych funkcji, a także do konstrukcji części kompilatorów (parserów), które odpowiedzialne są za sprawdzenie poprawności gramatycznej kodu źródłowego programu. Algorytmy te pierwotnie były związane z zagadnieniami dotyczącymi sztucznej inteligencji. Aby przedstawiony wyżej program można było skompilować i uruchomić przy pomocy środowiska Free Pascal wystarczy usunąć wiersze o numerach 12, 215 i 226. Jeśli chcemy zachować sprawdzanie poprawności przydziału i zwalniania pamięci na stercie, to wystarczy w wierszu 2 włączyć moduł „heaptrc” przed modułem „crt”. 8