Wykład szesnasty: Rekurencja oraz metoda "dziel i zwyciężaj"
Transkrypt
Wykład szesnasty: Rekurencja oraz metoda "dziel i zwyciężaj"
Podstawy Programowania semestr drugi Wykład czternasty 1. Rekurencja i technika „dziel i zwyciężaj” Rekurencja jest silnie związana z jedną z najefektywniejszych technik projektowania algorytmów, którą określamy nazwą „dziel i zwyciężaj” (ang. divide-and-conquer)1. Technikę tę można opisać w trzech punktach: 1. Dziel Dzielimy problem na prostsze podproblemy. 2. Zwyciężaj Rozwiązujemy podproblemy rekurencyjnie, chyba że są one małego rozmiaru i już nie wymagają zastosowania rekursji wtedy metod bezpośrednich. 3. używamy Połącz Łączymy rozwiązania podproblemów, aby otrzymać rozwiązanie całego problemu. Otrzymane przy użyciu tej metody algorytmy mają charakter rekurencyjny i są implementowane właśnie jako podprogramy rekurencyjnie, chyba, że w naturalny sposób da się je wyrazić za pomocą konstrukcji iteracyjnych2. Analiza problemu z użyciem tej techniki zostanie przedstawiona na przykładzie problemu wież Hanoi. Problem ten sformułował francuski matematyk Edouard Lucas w roku 1883 następująco: mamy wieżę stworzoną z „n” krążków o różnej średnicy i nadzianą na jeden z trzech prętów, tak, że na dole znajduje się krążek o największej średnicy, a na górze o najmniejszej3. Zadanie polega na przeniesieniu całej wieży na jeden z pozostałych, niezajętych prętów, tak aby w każdym ruchu brać tylko jeden krążek i nie kłaść większego krążka na mniejszym. Na tym wykładzie zadanie zostanie sformułowane inaczej: znając liczbę krążków napisz program, który obliczy najmniejszą liczbę ruchów koniecznych do przeniesienia wieży na inny pręt. Przeanalizujmy ten problem posługując się metodą dziel i zwyciężaj. W pierwszym kroku tej metody musimy dokonać podziału (dekompozycji) problemu. Problemem prostszym od przeniesienia „n” krążków jest przeniesienie „n-1” krążków. Inaczej: wiedząc w jaki sposób przenieść „n-1” krążków na pewno poradzimy sobie z przeniesieniem „n” klocków. Ta sama relacja odnosi się również do „n-1” i „n-2” krążków. Skoro dokonaliśmy podziału możemy przejść do następnego kroku metody „dziel i zwyciężaj”. Załóżmy, że przeniesienie „n-1” krążków wymaga od nas wykonania Tn-1 ruchów. W takim razie przeniesienie Tn krążków wymaga od nas wykonania 2*Tn-1+1 ruchów, bo najpierw przełożymy „n-1” krążków na pręt pomocniczy, później krążek o największej średnicy umieścimy na pręcie docelowym i na końcu przeniesiemy „n-1” krążków z pręta pomocniczego na pręt docelowy. W tym kroku pozostaje jeszcze ustalić co to są problemy małego rozmiaru dla problemu wieży Hanoi i jakie jest ich rozwiązanie. Otóż takie problemy występują, kiedy mamy małą ilość krążków do przeniesienia. Najmniejszym z nich jest przeniesienie zera krążków wówczas wykonujemy zero ruchów, co możemy zapisać bardziej formalnie T0 = 0. Etap łączenia jest stosunkowo prosty: Mając rozwiązanie najprostszego problemu możemy znaleźć rozwiązanie problemu z jednym krążkiem, potem z dwoma, trzema, aż znajdziemy rozwiązanie dla „n” krążków. Oto funkcja implementująca znalezione rozwiązanie, wraz z jej drzewem rekursji dla n=4: hanoi(4) function hanoi(n:byte):longint; begin 2*hanoi(3)+1 if n=0 then hanoi:=0 else hanoi:=2*hanoi(n-1)+1; end; 2*hanoi(2)+1 Drzewo rekurencji pozwala policzyć ilość wywołań rekurencyjnych funkcji dla pewnej ustalonej wartości początkowej parametru wejściowego i jej sposób 2*hanoi(1)+1 działania. Przy pierwszym wywołaniu tej funkcji wykonywana jest część instrukcji warunkowej umieszczona za słowem kluczowym „else”. Funkcja próbuje ustalić wartość wyrażenia hanoi:=2*hanoi(3)+1, ale nie może tego uczynić, ponieważ nie zna swojej wartości dla parametru wywołania (argumentu) równego 3. Wywołuje 2*hanoi(0)+1 więc siebie samą, aby tę wartość ustalić (dziel). Dzieje się tak do momentu, kiedy parametr, którego wartość jest zmniejszana o jeden za każdym wywołaniem funkcji osiągnie wartość 0. Wówczas funkcja zwraca dla niego ustaloną wartość, również 0 równą zero (zwyciężaj). Ta wartość jest następnie przekazywana poprzedniemu wywołaniu funkcji, która teraz może ustalić wartość wyrażenia hanoi:=2*hanoi(0). Ten wynik, z kolei przekazywany jest do jeszcze wcześniejszego wywołania, które też ustala wartość „swojego” wyrażenia i ten proces powtarzany jest tak długo, aż zostanie ustalony wynik hanoi(4). Innym „klasycznym” problemem dla którego można znaleźć rekurencyjne rozwiązanie jest problem obliczania silni. Użyjemy ponownie techniki „dziel i zwyciężaj”, aby otrzymać rozwiązanie tego problemu. Dekompozycja problemu jest równie prosta jak poprzednio: znając wartość silni dla liczby „n-1” możemy w prosty sposób policzyć silnię dla liczby „n” mnożąc tę wartość przez „n”. Ta zależność obowiązuje również dla liczb mniejszych od „n-1”, a więc podział problemu możemy wykonywać dotąd aż będziemy musieli obliczyć silnię dla zera. Wówczas możemy odpowiedź podać natychmiast wynikiem będzie wartość jeden. Znając tę wartość możemy obliczyć wartość silni dla dwójki, trójki i w konsekwencji dla każdej wartości „n”. Funkcja, której kod jest zaprezentowany poniżej dokonuje opisanego wyżej obliczenia wartości silni. Obok narysowano drzewo jej wywołań dla n=3. 1 2 3 W literaturze można też spotkać tłumaczenie „dziel i rządź”. Przykładem takiego algorytmu jest wyszukiwanie binarne w tablicy, choć algorytm ma charakter rekurencyjny i można go zaimplementować zarówno w postaci rekurencyjnej jak i iteracyjnej, to najczęściej wybiera się tę ostatnią. Tak naprawdę oryginalne zadanie Lucasa brzmiało inaczej. Więcej informacji na ten temat można znaleźć w książce D.E.Knuth'a, R.L.Grahama i O.Patashnika, „Matematyka konkretna”, PWN, Warszawa 1996 1 Podstawy Programowania function silniar(n:byte):longint; silniar(3) begin if n=0 then silniar:=1 else 3*silniar(2) silniar:=silniar(n-1)*n; end; 2*silniar(1) semestr drugi Funkcja „silniar” wywołuje się rekurencyjnie do momentu, kiedy wartość jej parametry „n” osiągnie zero. Wówczas jako wynik swojego działania zawraca jedynkę. Ten wynik jest wykorzystywany przez wcześniejsze wywołanie tej funkcji do policzenia wartości silni dla parametry „n” równego 1. Z kolei ten wynik jest wykorzystywany do policzenia wartości silni dla n=2. Znając już wynik dla tego przypadku funkcja oblicza wartość dla n=3 i kończy swoje działanie. 1*silniar(0) 1 2. Efektywność rekurencji Podprogramy napisane z użyciem rekurencji odznaczają się elegancją i zwięzłością zapisu. Niestety procedury rekurencyjne najczęściej nie są tak efektywne jak ich iteracyjne odpowiedniki. Na czas ich działania wpływ ma oczywiście czas wywołania kolejnych ich wersji. Okazuje się również, że wiele problemów, których definicja w sposób „naturalny” narzuca implementacje w postaci kodu rekurencyjnego, jest rozwiązywanych bardziej efektywnie przez kod iteracyjny. Rozważmy działanie funkcji liczącej liczby Fibonacciego. Te liczby określone są następującą zależnością: fib(0) = 1 fib(1) = 1 fib(n) = fib(n-1) + fib(n-2), dla n>=2 Kod funkcji obliczającej liczbę Fibonacciego dla danego „n” jest następujący, wraz z drzewem wywołań, dla n=4 jest następujący: function gen_fibonacci(n:byte):longint; f(4) begin if n=1 then gen_fibonacci:=1 else f(3) f(2) if n=0 then gen_fibonacci:=0 else gen_fibonacci:=gen_fibonacci(n-1)+gen_fibonacci(n-2); f(2) f(1) f(1) f(0) end; f(1) f(0) W drzewie rekurencji nazwa funkcji została zmieniona na „f”, aby móc je łatwiej i wyraźniej narysować. Na jego podstawie możemy stwierdzić, że część wywołań tej funkcji4 jest poświęcona na obliczenie wartości, liczonych przez inne wywołania. To oczywiście znacznie wydłuża czas działania takiej funkcji. Podobny problem ujawnia się przy liczeniu wartości symbolu Newtona metodą rekurencyjną korzystając z zależności: Kod funkcji obliczającej jest następujący: function newton(n,k:longint):longint; begin if k=0 then newton:=1 else if n=k then newton:=1 else if k=1 then newton:=n else newton:=newton(n-1,k-1)+newton(n-1,k); end; 4 Zostały one zaznaczone na czerwono. 2 Podstawy Programowania semestr drugi W przypadku tej funkcji oprócz zbędnych wywołań może również dojść do niestabilności obliczanych wartości, tzn. wynik działania funkcji może odbiegać od wyniku prawidłowego. Przyczyny nieefektywności działania podprogramów rekurencyjnych mogą tkwić nie tylko w sposobie ich działania. Okazuje się, że jeśli podejmiemy złe decyzje dotyczące dekompozycji problemu lub określenia warunków brzegowych, czyli najmniejszych problemów, dla których możemy bezpośrednio podać rozwiązanie, to taki podprogram będzie wykonywał się dłużej niż powinien. Można to zauważyć na przykładzie funkcji „silniar”, liczącej silnie w sposób rekurencyjny. Jeśli uwzględnimy w jej kodzie, że dla n=1 możemy również podać bezpośrednio odpowiedź, to zaoszczędzimy na jednym wywołaniu tej funkcji. 3. Inne problemy związane z rekurencją. Najczęstszą przyczyną złego działanie podprogramów rekurencyjnych jest błędne określenie warunku zakończenia dokonywania kolejnych wywołań rekurencyjnych. Błędy te mogą mieć charakter „matematyczny” i polegać na tym, że w wyniku kolejnych redukcji problemu niegdy nie osiągniemy określonego przez nas warunku brzegowego lub mogą mieć charakter „informatyczny”, tzn. warunku brzegowego nie osiągniemy nigdy, ponieważ występuje jakiś błąd przetwarzania danych, np.: przekroczenie zakresu zmiennej. Oczywiście w pierwszym przypadku pomaga weryfikacja warunku zakończenia przy pomocy narzędzi matematycznych, takich jak indukcja matematyczna, w drugim ostrożność w programowaniu i użycie technik debugowania. Dla pewnych wartości początkowych parametrów niekiedy poprawnie napisane podprogramy rekurencyjne kończą swoje wykonanie komunikatem o przepełnieniu stosu. Jak wiemy wszystkie informacje związane z wywołaniem procedur i funkcji przechowywane są na stosie w postaci ramek stosu. Stos jest stosunkowo niewielkim obszarem pamięci. Jeśli problem, który rozwiązujemy wymaga wielu wywołań rekurencyjnych podprogramu, to może się okazać, że w pewnym momencie zostanie przekroczona ilość dostępnej na stosie pamięci, co jest błędem powodującym krytyczne zakończenia działania programu. Inną przyczyną powstawania błędów jest sposób generowania kodu wynikowego niektórych kompilatorów, ale ten problem nie zostanie tu bliżej opisany. 4. Podsumowanie Mimo, że rekurencja nie zawsze jest efektywna, to znajomość tej techniki jest nieodzowna dla każdego programisty. Współczesne kompilatory potrafią automatycznie zamieniać niektóre podprogramy rekurencyjne na ich odpowiedniki iteracyjne. Dzięki temu możemy pisać programy, których treść jest elegancka i zwarta nie tracąc wiele lub nawet nic nie tracąc na szybkości ich działania. Rekurencja uzasadnia istnienie w języku Pascal przekazywania parametrów przez wartość. Jeśli nie byłoby takiej możliwości nie moglibyśmy się posługiwać techniką rekurencji. Ten wykład nie wyczerpuje oczywiście problemów związanych z rekurencją, w szczególności nie została omówiona tu rekurencja skrośna, czyli taka implementacja rekurencji, w której dwa podprogramy wzajemnie się wywołują. Na zakończenie przedstawiona zostanie procedura rysująca fraktal nazwany od nazwiska twórcy Trójkątem Sierpińskiego. Wysokość drzewa rekurencji dla tej procedury określa parametr „n”. Pomimo, że procedura ta ma tylko kilka wierszy kodu figura jaką rysuje ma dosyć skomplikowany kształt. procedure draw_sierpinski(x,y,x1,y1,x2,y2:integer; n:iter); begin moveto(x,479-y); lineto(x1,479-y1); lineto(x2,479-y2); lineto(x,479-y); dec(n); if n<> 0 then begin draw_sierpinski(x,y,(x+x1) div 2, (y+y1) div 2, (x+x2) div 2, (y+y2) div 2,n); draw_sierpinski((x+x1) div 2,(y+y1) div 2,x1,y1,(x2+x1) div 2, (y2+y1) div 2,n); draw_sierpinski((x+x2) div 2,(y+y2) div 2,(x1+x2) div 2, (y1+y2) div 2, x2,y2,n); end; end; 3