Podstawy Programowania – semestr drugi Wykład dziewiąty 1

Transkrypt

Podstawy Programowania – semestr drugi Wykład dziewiąty 1
Podstawy Programowania – semestr drugi
Wykład dziewiąty
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 – używamy wtedy metod bezpośrednich.
3.
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 iteracyjnych 2. 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óżnych średnicach 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 najmniejszej 3. 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 ile ruchów potrzebujemy, aby przenieść „n­1” krążków na pewno poradzimy sobie określeniem liczby ruchów potrzebnych do przeniesienia „n” krążkó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łą liczbę 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 T 0 = 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
2*hanoi(1)+1
2*hanoi(0)+1
0
Drzewo rekurencji pozwala policzyć liczbę wywołań rekurencyjnych funkcji dla pewnej ustalonej wartości początkowej parametru wejściowego i zilustrować sposób jej działania. Przy wywołaniu tej funkcji, dla argumentu wywołania równego 4 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 (argumentu) wywołania równego trzy. Wywołuje więc siebie samą, aby tę wartość ustalić (krok dziel). Dzieje się tak do momentu, kiedy parametr, którego wartość jest zmniejszana o jeden za każdym wywołaniem rekurencyjnym funkcji osiągnie wartość 0. Wówczas funkcja zwraca dla niego ustaloną wartość, również równą zero (krok 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)+1. 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 jedynki, 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 – semestr drugi
function silniar(n:byte):longint;
Funkcja „silniar” wywołuje się rekurencyjnie do momentu, kiedy wartość jej parametru „n” osiągnie zero. Wówczas jako wynik swojego działania zawraca jedynkę. Ten wynik jest wykorzystywany przez wcześniejsze wywołanie rekurencyjne tej funkcji do policzenia wartości silni dla parametru „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.
silniar(3) begin
if n=0 then silniar:=1 else
3*silniar(2)
silniar:=silniar(n­1)*n;
end;
2*silniar(1)
1*silniar(0)
1
2.
Efektywność rekurencji
Podprogramy napisane z użyciem rekurencji odznaczają się elegancją i zwięzłością zapisu. Niestety nie są one tak efektywne jak ich iteracyjne odpowiedniki. Na czas ich działania wpływ ma oczywiście czas wywołania kolejnych ich instancji (wywołań). Okazuje się również, że wiele problemów, których definicja w sposób „naturalny” narzuca implementację 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) = 0 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 (obok znajduje się drzewo wywołań, dla n=4):
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 jej czas działania. Podobny problem ujawnia się przy liczeniu wartości symbolu Newtona metodą rekurencyjną, korzystając z zależności:
+ n−1
(n0 )= (nn )=1 ; (n1)=n ; ( nk)=( n−1
k−1) ( k )
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;
W przypadku tej funkcji oprócz zbędnych wywołań może również dojść do przekroczenia zakresu typu „longint” co oznacza, że 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łania 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 nigdy 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.: 4
Zostały one zaznaczone na czerwono.
2
Podstawy Programowania – semestr drugi
przekroczenie zakresu typu 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 w przypadku programów napisanych w Turbo Pascalu. 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 przez niektóre kompilatory, 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. Możliwość stosowania techniki rekurencji częściowo uzasadnia istnienie w języku Pascal przekazywania parametrów przez wartość. Jeśli nie byłoby takiej możliwości to stosowanie rekurencji w niektórych przypadkach byłoby utrudnione. 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ą. Poniżej przedstawiona jest procedura rysująca fraktal nazwany od nazwiska twórcy Trójkątem Sierpińskiego. Wysokość drzewa rekurencji dla tej procedury określa parametr „n”.5 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;
Rekurencyjne podprogramy są stosowane w implementacji abstrakcyjnej struktury danych jaką jest drzewo BST. Można je także zastosować do tworzenia innych dynamicznych struktur danych. Poniżej zamieszczony jest program, który implementuje jednokierunkową listę liniową z użyciem takich podprogramów 6. Jego funkcjonalność jest taka sama, jak programu przedstawionego na czwartym wykładzie. Zastosowanie rekurencji pozwoliło jednakże znacznie skrócić zapis jego kodu. 1 program singly_linked_list; 2 uses heaptrc,crt; 3 type 4 wskaznik=^element; 5 element=record 6 dana:integer; 7 next:wskaznik; 8 end; 9 var 10 first:wskaznik; 11 ne:integer; 12 13 procedure insert_node(var f:wskaznik; a:integer); 14 {Wstaw element do listy.} 15 var 16 nowy:wskaznik; 17 begin 18 if f<>nil then 5
6
Typ „iter” należy zdefiniować w programie głównym następująco: type iter = 1..10;
Program jest przygotowany dla środowiska Free Pascal. Aby skompilować i uruchomić go w środowisku Turbo Pascal należy usunąć nazwę modułu heaptrc za słowem kluczowym uses.
3
Podstawy Programowania – semestr drugi
19 begin 20 if f^.dana >= a then 21 begin 22 new(nowy); 23 nowy^.dana:=a; 24 nowy^.next:=f; 25 f:=nowy; 26 end 27 else 28 insert_node(f^.next,a); 29 end 30 else 31 begin 32 new(f); 33 f^.dana:=a; 34 f^.next:=nil; 35 end 36 end; 37 38 procedure delete_node(var f:wskaznik; a:integer); 39 {Usuń jeden element z listy.} 40 var 41 tmp:wskaznik; 42 begin 43 if f<>nil then 44 if f^.dana=a then 45 begin 46 tmp:=f^.next; 47 dispose(f); 48 f:=tmp; 49 end 50 else 51 delete_node(f^.next,a); 52 end; 53 54 procedure remove(var f:wskaznik); 55 {Usuń całą listę.} 56 begin 57 if f<>nil then 58 begin 59 remove(f^.next); 60 dispose(f); 61 end; 62 end; 63 64 procedure show(f:wskaznik); 65 begin 66 if f <> nil then 4
Podstawy Programowania – semestr drugi
67 begin 68 write(f^.dana:4); 69 show(f^.next); 70 end 71 else 72 writeln; 73 end; 74 75 begin 76 clrscr; 77 writeln; 78 insert_node(first,1); 79 show(first); 80 delete_node(first,1); 81 first:=nil; 82 for ne:=1 to 5 do insert_node(first,ne); 83 for ne:=10 to 15 do insert_node(first,ne); 84 show(first); 85 insert_node(first,16); 86 show(first); 87 insert_node(first,6); 88 show(first); 89 insert_node(first,0); 90 show(first); 91 delete_node(first,0); 92 show(first); 93 delete_node(first,7); 94 show(first); 95 delete_node(first,5); 96 show(first); 97 delete_node(first,16); 98 show(first); 99 remove(first); 100 readln; 101 end. Obok rekurencji kluczowym rozwiązaniem, które pozwoliło uprościć kod procedury insert_node jest przekazywanie wskaźnika przez zmienną. Dzięki temu wyeliminowana została potrzeba stosowania wskaźnika prev. W tej wersji programu, w kolejnych wywołaniach rekurencyjnych pierwszy parametr procedury jest tożsamy z polem next elementu poprzedzającego element bieżąco odwiedzany. Dodatkową korzyścią jest to, że przypadki kiedy jest tworzony pierwszy i ostatni element listy są oprogramowane za pomocą tego samego kodu (wiersze 30 ­ 35). Podobnie rozwiązania zastosowano w procedurze delete_node. Dzięki temu, jeśli istnieje element, który należy usunąć, to niezależnie od jego położenia w liście to usuwanie jest wykonywane przez ten sam kod (wiersze 44 ­ 49). Analizując zapis procedury remove warto zwrócić uwagę na miejsce, w którym umieszczone jest wywołanie rekurencyjne ­ przed instrukcją usuwającą dany element listy. Oznacza to, że lista jest usuwana od końca, czyli począwszy od ostatniego elementu. Dzięki temu unika się odwoływania do obszarów pamięci, które już zostały zwolnione. Z kolei w procedurze show zawartość pola dana elementu jest najpierw wypisywana, a potem następuje wywołania rekurencyjne tej procedury. W ten sposób liczby znajdujące się w liście wypisywane są na ekranie w takiej kolejności, w jakiej są na niej zapisane. Proszę również zwrócić uwagę na to, że parametr procedury show, tak jak jej odpowiedniczki z czwartego wykładu jest przekazywany przez wartość. W tym przypadku mógłby równie dobrze być przekazywany przez zmienną, ale nie ma to uzasadnienia, gdyż ta procedura nie dokonuje żadnych zmian w liście.
5

Podobne dokumenty