Algorytmy z nawrotami

Transkrypt

Algorytmy z nawrotami
Podstawy Programowania
semestr drugi
Wykład piętnasty
1.
Algorytmy z nawrotami (ang. backtraking algorithms)
Istnieje pewna grupa problemów, których ogólną postać można scharakteryzować następująco: „Mając dane wejściowe i znając wynik znajdź
sposób rozwiązania”. Nie istnieje ogólna, efektywna metoda radzenie 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 z nawrotami1. 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, jaki 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 już się 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, aż 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ć 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
1
(0,0)
semestr drugi
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:
Czytając zamieszczony obok pseudokod można zauważyć dwa
wymagające wyjaśnienia problemy. Po pierwsze: po
znalezieniu stanu końcowego algorytm dalej szuka następnych
stanów, czy jest więc algorytmem, który się zakończy?
Odpowiedź jest pozytywna, gdyż kolejne stany nie prowadzą
do rozwiązania i są stanami, które już wystąpiły. Ponieważ
możliwości ich generowania są również ograniczone, więc cały
proces musi się zakończyć. Po drugie: algorytm znajduje tylko
jedno rozwiązanie, czyli jedną ścieżkę wiodącą od korzenia do
wierzchołka docelowego. Czy jest możliwe znalezienie
wszystkich możliwych rozwiązań tego problemu. Również
odpowiedź na to drugie 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, oraz usuwać
ostatni element tej kolejki, po zakończeniu każdego wywołania
rekurencyjnego. Oto pseudokod ilustrujący to rozwiązanie:
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);
znajdź(ścieżka);
end
w przeciwnym przypadku usuń(nowy_stan);
end
...
jeśli warunek_dla_operatora_8(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);
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);
znajdź(ścieżka);
usuń_ostatni_stan(ścieżka);
end
w przeciwnym przypadku usuń(nowy_stan);
end
...
jeśli warunek_dla_operatora_8(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);
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 elementu kolejki. Pole „j3”
1 program water_jugs_problem;
reprezentuje dzban trzylitrowy, pole „j4” dzban czterolitrowy, a pole
„oper” numer operatora, który został zastosowany do wygenerowania
2 uses crt;
tego pola (numer zero będzie oznaczał, że jest to stan początkowy
korzeń drzewa). Pole „next” jest oczywiście wskaźnikiem na następny
3 type
element kolejki. Zmienna globalna „solution” (wiersz 13) zawiera numer
4 queue=^element;
rozwiązania problemu, jakie zostało znalezione przez program.
Procedura „enqueue” (wiersze 15 19) służy oczywiście do dodawania
5 element=record
nowego elementu na koniec listy, procedura „dequeue” (wiersze 21
31) służy do usuwania pierwszego elementu z kolejki. Funkcja
6
j3,j4,oper:byte;
„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
7
next:queue;
nie, zwraca wartość „false”. Jej działanie sprowadza się do przeszukania
8 end;
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
9
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
10 var
kolejki. Dzięki niej kolejka jest kolejką dwustronną o ograniczonym
11 head,tail,first:queue;
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
12 mem:longint;
„print_queue”(wiersze 67
86) wypisuje na ekran znalezione
rozwiązanie, podając jego numer oraz numerując kolejne kroki jakie są
13 solution:byte;
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. Procedura „remove_queue” (wiersze 88 92) służy do usunięcia
kolejki z pamięci. Jest ona wywoływana po procedurze „search” w
3
Podstawy Programowania
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óre 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. Po wypisaniu lub nie kolejki 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 wyjściowemu w problemie i
wywoływana jest dla niego procedura „search”. Po jej zakończeniu
usuwana jest z pamięci przy pomocy procedury „remove_queue”
kolejka.
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
30
h:=tmp;
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
59
semestr drugi
begin
tmp:=h;
4
Podstawy Programowania
60
while tmp^.next<>t do tmp:=tmp^.next;
61
tmp^.next:=nil;
62
dispose(t);
63
t:=tmp;
64
semestr drugi
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);
104 var
105 nowy:queue;
5
Podstawy Programowania
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);
115
search(h,t,s);
116
117
118
remove_tail(h,t);
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);
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);
141
search(h,t,s);
142
remove_tail(h,t);
143
end
144
else dispose(nowy);
145 end;
146 {Opróżnianie dzbana 3 litrowego.}
147 if t^.j3>0 then
148 begin
149
create(nowy,0,t^.j4,4);
150
if not already_been(h,nowy) then
151
begin
6
semestr drugi
Podstawy Programowania
152
enqueue(h,t,nowy);
153
if (t^.j4=2) and (t^.j3=0) then print_queue(h,s);
154
search(h,t,s);
155
remove_tail(h,t);
156
157
semestr drugi
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);
167
search(h,t,s);
168
remove_tail(h,t);
169
170
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);
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);
193
search(h,t,s);
194
remove_tail(h,t);
195
196
end
else dispose(nowy);
197 end;
7
Podstawy Programowania
semestr drugi
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);
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.
2.
Podsumowanie
Przedstawiony wyżej program znajduje czternaście rozwiązań problemu dzbanów z wodą. Po bliższej analizie tych rozwiązań można dojść do
wniosku, że część z tych rozwiązań jest nieoptymalna (zawiera zbędne kroki). Rzeczywiście, przeszukiwanie realizowane przez algorym z
nawrotami jest przeszukiwaniem siłowym (ang. brute force). Aby wybierać tylko rozwiązania optymalne, należałoby zmodyfikować ten
algorytm, aby oceniał przydatność niektórych posunięć. Algorytmy z nawrotami nie służą jedynie do rozwiązywania problemów, które mają
formę zagadki matematycznej. Są 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.
8

Podobne dokumenty