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