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

Podobne dokumenty