Rekurencja a iteracja w praktyce

Do rozwiązania problemu możemy wykorzystać zarówno metodę iteracyjną, jak i rekurencyjnąrekurencjarekurencyjną (rekursywną). Ale która z nich jest lepsza?

Nie ma tu jednoznacznej odpowiedzi. Metoda rekurencyjna wymaga wolnego miejsca w stosie. Stos używany jest do zapisu zmiennych lokalnych, przekazywanych argumentów, jak również adresu powrotuadres powrotuadresu powrotu. Każde kolejne wywołanie funkcji rekurencyjnej wymaga użycia pamięci stosu dla tych danych, co sprawia, że liczba wywołań jest ograniczona. Jednak w niektórych przypadkach metoda rekurencyjna jest czytelniejsza niż iteracyjna, np. w algorytmie sortowania przez scalanie.

Zmiana rozmiaru stosu

Jeżeli wyczerpie się pamięć przeznaczona na stos, system operacyjny zgłosi błąd i zakończy działanie programu. Przepełnienie stosu sygnalizowane jest zwykle komunikatem Segmentation fault (w systemach Linux) lub Access Violation (w systemach Windows).

Rozmiar stosu ogranicza maksymalną liczbę wywołań rekurencyjnych. Domyślnie jest to rozmiar rzędu kilku megabajtów i zależy od użytego kompilatora oraz systemu operacyjnego. W systemie Linux z kompilatorem GCC wynosi on domyślnie 8 MB, co pozwala na ok. 200 000 wywołań rekurencyjnych. Rozmiar stosu można tu zmienić systemowym poleceniem ulimit -s [wartość w kB]. W systemie Windows o rozmiarze stosu decyduje linkerlinkerlinker, domyślnie ma on rozmiar 1 MB.

Ciąg Fibonacciego – implementacja rekurencyjna oraz iteracyjna

Przeanalizujmy powyższe rozumowanie na konkretnym przykładzie – porównajmy dwie funkcje obliczające n‑ty wyraz ciągu Fibonacciego.

Na początek przypomnijmy definicję ciągu Fibonacciego:

F n = { 0 gdy  n = 0 1 gdy  n = 1 F n 1 + F n 2 gdy  n > 1

Jest to definicja rekurencyjna, zatem możemy zaimplementować ją wprost, zgodnie z poniższą specyfikacją:

Specyfikacja:

Dane:

  • n – liczba naturalna oznaczająca numer elementu ciągu Fibonacciego do obliczenia przez program

Wynik:

  • liczba – liczba całkowita, n‑ty wyraz ciągu Fibonacciego

Linia 1. long long fibonacciRekurencyjnie otwórz nawias okrągły int n zamknij nawias okrągły otwórz nawias klamrowy. Linia 2. if otwórz nawias okrągły n znak równości znak równości 0 zamknij nawias okrągły return 0 średnik. Linia 3. else if otwórz nawias okrągły n znak równości znak równości 1 zamknij nawias okrągły return 1 średnik. Linia 4. return fibonacciRekurencyjnie otwórz nawias okrągły n minus 1 zamknij nawias okrągły plus fibonacciRekurencyjnie otwórz nawias okrągły n minus 2 zamknij nawias okrągły średnik. Linia 5. zamknij nawias klamrowy.

Przykładową implementację iteracyjną możemy zrealizować następująco (ona również spełnia warunki powyższej specyfikacji):

Linia 1. long long fibonacciIteracyjnie otwórz nawias okrągły int n zamknij nawias okrągły otwórz nawias klamrowy. Linia 2. long long wyrazAktualny znak równości 0 przecinek. Linia 3. wyrazNastepny znak równości 1 przecinek. Linia 4. wyrazPoprzedni średnik. Linia 6. for otwórz nawias okrągły int i znak równości 0 średnik i otwórz nawias ostrokątny n średnik i plus plus zamknij nawias okrągły otwórz nawias klamrowy. Linia 7. wyrazPoprzedni znak równości wyrazAktualny średnik. Linia 8. wyrazAktualny znak równości wyrazNastepny średnik. Linia 9. wyrazNastepny plus znak równości wyrazPoprzedni średnik. Linia 10. zamknij nawias klamrowy. Linia 12. return wyrazAktualny średnik. Linia 13. zamknij nawias klamrowy.

Sprawdźmy teraz, która implementacja jest wydajniejsza. W tym celu uruchomimy prosty kod, który wykona po 20 wywołań każdej z napisanych funkcji ze stopniowo rosnącym argumentem. Aby porównać liczbę czynności koniecznych do wykonania przez każdą z funkcji, zadeklarujemy dwie zmienne globalne: licznikRekurencyjny oraz licznikIteracyjny. Pierwsza z nich będzie zwiększana z każdym kolejnym wywołaniem funkcji rekurencyjnej, a druga - w pętli w funkcji iteracyjnej. W funkcji głównej programu porównamy wyniki, a przed każdym kolejnym porównaniem obu funkcji liczniki zostaną wyzerowane.

Linia 1. kratka include otwórz nawias ostrokątny iostream zamknij nawias ostrokątny. Linia 2. using namespace std średnik. Linia 4. int licznikRekurencyjny przecinek. Linia 5. licznikIteracyjny średnik. Linia 7. long long fibonacciRekurencyjnie otwórz nawias okrągły int n zamknij nawias okrągły otwórz nawias klamrowy. Linia 8. licznikRekurencyjny plus plus średnik. Linia 9. if otwórz nawias okrągły n znak równości znak równości 0 zamknij nawias okrągły return 0 średnik. Linia 10. else if otwórz nawias okrągły n znak równości znak równości 1 zamknij nawias okrągły return 1 średnik. Linia 11. return fibonacciRekurencyjnie otwórz nawias okrągły n minus 1 zamknij nawias okrągły plus fibonacciRekurencyjnie otwórz nawias okrągły n minus 2 zamknij nawias okrągły średnik. Linia 12. zamknij nawias klamrowy. Linia 14. long long fibonacciIteracyjnie otwórz nawias okrągły int n zamknij nawias okrągły otwórz nawias klamrowy. Linia 15. long long wyrazAktualny znak równości 0 przecinek. Linia 16. wyrazNastepny znak równości 1 przecinek. Linia 17. wyrazPoprzedni średnik. Linia 19. for otwórz nawias okrągły int i znak równości 0 średnik i otwórz nawias ostrokątny n średnik i plus plus zamknij nawias okrągły otwórz nawias klamrowy. Linia 20. licznikIteracyjny plus plus średnik. Linia 21. wyrazPoprzedni znak równości wyrazAktualny średnik. Linia 22. wyrazAktualny znak równości wyrazNastepny średnik. Linia 23. wyrazNastepny plus znak równości wyrazPoprzedni średnik. Linia 24. zamknij nawias klamrowy. Linia 26. return wyrazAktualny średnik. Linia 27. zamknij nawias klamrowy. Linia 29. int main otwórz nawias okrągły zamknij nawias okrągły otwórz nawias klamrowy. Linia 30. cout otwórz nawias ostrokątny otwórz nawias ostrokątny cudzysłów Rekurencyjnie dwukropek lewy ukośnik tIteracyjnie dwukropek cudzysłów otwórz nawias ostrokątny otwórz nawias ostrokątny endl średnik. Linia 31. for otwórz nawias okrągły int i znak równości 0 średnik i otwórz nawias ostrokątny 20 średnik plus plus i zamknij nawias okrągły otwórz nawias klamrowy. Linia 32. licznikIteracyjny znak równości 0 średnik. Linia 33. licznikRekurencyjny znak równości 0 średnik. Linia 35. fibonacciIteracyjnie otwórz nawias okrągły i zamknij nawias okrągły średnik. Linia 36. fibonacciRekurencyjnie otwórz nawias okrągły i zamknij nawias okrągły średnik. Linia 38. cout otwórz nawias ostrokątny otwórz nawias ostrokątny licznikRekurencyjny otwórz nawias ostrokątny otwórz nawias ostrokątny cudzysłów lewy ukośnik t cudzysłów otwórz nawias ostrokątny otwórz nawias ostrokątny licznikIteracyjny otwórz nawias ostrokątny otwórz nawias ostrokątny endl średnik. Linia 39. zamknij nawias klamrowy. Linia 40. zamknij nawias klamrowy.

Po uruchomieniu kod wypisuje na standardowe wyjście programu następujące rezultaty:

Linia 1. Rekurencyjnie dwukropek Iteracyjnie dwukropek. Linia 2. 1 0. Linia 3. 1 1. Linia 4. 3 2. Linia 5. 5 3. Linia 6. 9 4. Linia 7. 15 5. Linia 8. 25 6. Linia 9. 41 7. Linia 10. 67 8. Linia 11. 109 9. Linia 12. 177 10. Linia 13. 287 11. Linia 14. 465 12. Linia 15. 753 13. Linia 16. 1219 14. Linia 17. 1973 15. Linia 18. 3193 16. Linia 19. 5167 17. Linia 20. 8361 18. Linia 21. 13529 19.

Oczywiście wyników nie należy interpretować dosłownie, gdyż nie jesteśmy w stanie stwierdzić, która operacja jest bardziej kosztowna – pojedyncze wywołanie funkcji czy pojedyncze wykonanie kodu w pętli. Widzimy jednak, że złożoność obliczeniowa funkcji rekurencyjnej jest znacząco większa niż w przypadku funkcji iteracyjnej. Przyczynę takiego stanu rzeczy przedstawia schemat ( oznacz -ty wyraz ciągu Fibonacciego):

RCnZIlfQeK9tq

Już na drugim i trzecim poziomie wartość musi zostać policzona dwa razy osobno. Podobna sytuacja powtarza się podczas kolejnych wywołań – dla , itd. Wielokrotnie liczymy te same wartości, przez co zwiększa się złożoność obliczeniowa.

Spróbujmy porównać również złożoność pamięciową obu funkcji. W przypadku metody iteracyjnej możemy od razu stwierdzić, że jest ona stała. Funkcja iteracyjna deklaruje tylko dwie (włącznie z parametrem) zmienne typu int i trzy zmienne typu long long. Natomiast funkcja rekurencyjna – biorąc pod uwagę fakt, że przy każdym kolejnym wywołaniu musimy zapisać na stosie przekazywane argumenty, adres powrotu oraz wszystkie zmienne lokalne – potrzebuje dużo więcej pamięci. Dodatkową wadą funkcji rekurencyjnej jest również możliwość przepełnienia stosu dla dużego argumentu n.

Łatwo zatem stwierdzić, że rekurencyjne obliczanie elementów ciągu Fibonacciego nie jest dobrym przykładem zastosowania rekurencji. Błędem byłoby jednak założenie, że należy unikać tej metody. Istnieją algorytmy oraz struktury danych specjalnie projektowane i optymalizowane pod rekurencję. Przykładem mogą być tu algorytmy szybkiego sortowania, np. sortowanie przez scalanie lub sortowanie szybkie (quick sort). Zadaniem programisty jest ocenienie, które podejście będzie lepsze dla danego problemu.

Silnia – implementacja rekurencyjna oraz iteracyjna

Funkcję silnia możemy zdefiniować iteracyjnie w następujący sposób:

n ! = 1 2 3 . . . ( n 1 )

Z kolei definicja rekurencyjna może wyglądać tak:

n ! = { 1 gdy  n = 0 n = 1 n ( n 1 ) ! gdy  n > 1

Aby lepiej wskazać różnice między obiema metodami, zaimplementujemy (korzystając z powyższych definicji) obliczanie wartości silni.

Specyfikacja:

Dane:

  • n – liczba naturalna, dla której należy obliczyć wartość

Wynik:

  • silnia – liczba naturalna, wartość

Obliczanie metodą rekurencyjną:

Linia 1. kratka include otwórz nawias ostrokątny iostream zamknij nawias ostrokątny. Linia 2. using namespace std średnik. Linia 4. long long silniaRekurencja otwórz nawias okrągły int zamknij nawias okrągły średnik. Linia 6. int main otwórz nawias okrągły zamknij nawias okrągły otwórz nawias klamrowy. Linia 7. cout otwórz nawias ostrokątny otwórz nawias ostrokątny silniaRekurencja otwórz nawias okrągły 5 zamknij nawias okrągły otwórz nawias ostrokątny otwórz nawias ostrokątny endl średnik. Linia 9. return 0 średnik. Linia 10. zamknij nawias klamrowy. Linia 12. long long silniaRekurencja otwórz nawias okrągły int n zamknij nawias okrągły otwórz nawias klamrowy. Linia 13. if otwórz nawias okrągły n znak równości znak równości 0 zamknij nawias okrągły otwórz nawias klamrowy. Linia 14. return 1 średnik. Linia 15. zamknij nawias klamrowy else if otwórz nawias okrągły n znak równości znak równości 1 zamknij nawias okrągły otwórz nawias klamrowy. Linia 16. return 1 średnik. Linia 17. zamknij nawias klamrowy. Linia 19. return n asterysk silniaRekurencja otwórz nawias okrągły n minus 1 zamknij nawias okrągły średnik. Linia 20. zamknij nawias klamrowy.

Obie zasady działania rekurencji zostały zachowane.

  1. Każde kolejne rekurencyjne wywołanie funkcji ma inną wartość argumentu, ponieważ za każdym razem zmienna n zmniejsza swoją wartość o 1;

  2. Funkcja będzie wywoływana tak długo, aż zmienna n osiągnie wartość 1. Gdy tylko tak się stanie, funkcja zwróci wartość 1! do funkcji, która ją wywołała. Ta z kolei pomnoży tę liczbę przez wartość zmiennej n, która została jej przekazana, a następnie zwróci wynik. Czynność ta będzie powtarzać się do momentu, gdy końcowa wartość zostanie zwrócona do funkcji main, gdzie zostanie wypisana na ekran.

Ważne!

W sytuacji, kiedy wartość parametru wynosi 0 lub 1, należy zwrócić wartość 1.

Oto fragment kodu poszerzony o implementację algorytmu wyliczającego wartość silni metodą iteracyjną:

Linia 1. kratka include otwórz nawias ostrokątny iostream zamknij nawias ostrokątny. Linia 2. using namespace std średnik. Linia 4. prawy ukośnik prawy ukośnik Metoda rekurencyjna. Linia 5. long long silniaRekurencja otwórz nawias okrągły int n zamknij nawias okrągły otwórz nawias klamrowy. Linia 6. if otwórz nawias okrągły n znak równości znak równości 0 zamknij nawias okrągły otwórz nawias klamrowy. Linia 7. return 1 średnik. Linia 8. zamknij nawias klamrowy else if otwórz nawias okrągły n znak równości znak równości 1 zamknij nawias okrągły otwórz nawias klamrowy. Linia 9. return 1 średnik. Linia 10. zamknij nawias klamrowy. Linia 12. return n asterysk silniaRekurencja otwórz nawias okrągły n minus 1 zamknij nawias okrągły średnik. Linia 13. zamknij nawias klamrowy. Linia 15. prawy ukośnik prawy ukośnik Metoda iteracyjna. Linia 16. long long silniaIteracyjna otwórz nawias okrągły int n zamknij nawias okrągły otwórz nawias klamrowy. Linia 17. long long silniaIteracyjna znak równości 1 średnik. Linia 19. for otwórz nawias okrągły int i znak równości 2 średnik i otwórz nawias ostrokątny znak równości n średnik i plus plus zamknij nawias okrągły otwórz nawias klamrowy. Linia 20. silniaIteracyjna asterysk znak równości i średnik. Linia 21. zamknij nawias klamrowy. Linia 23. return silniaIteracyjna średnik. Linia 24. zamknij nawias klamrowy. Linia 26. int main otwórz nawias okrągły zamknij nawias okrągły otwórz nawias klamrowy. Linia 27. prawy ukośnik prawy ukośnik Argument silni. Linia 28. int n średnik. Linia 29. cin zamknij nawias ostrokątny zamknij nawias ostrokątny n średnik. Linia 31. prawy ukośnik prawy ukośnik Wypisywanie wyników. Linia 32. cout otwórz nawias ostrokątny otwórz nawias ostrokątny cudzysłów Silnia rekurencyjna dwukropek cudzysłów otwórz nawias ostrokątny otwórz nawias ostrokątny silniaRekurencja otwórz nawias okrągły n zamknij nawias okrągły otwórz nawias ostrokątny otwórz nawias ostrokątny endl średnik. Linia 33. cout otwórz nawias ostrokątny otwórz nawias ostrokątny cudzysłów Silnia iteracyjna dwukropek cudzysłów otwórz nawias ostrokątny otwórz nawias ostrokątny silniaIteracyjna otwórz nawias okrągły n zamknij nawias okrągły otwórz nawias ostrokątny otwórz nawias ostrokątny endl średnik. Linia 35. return 0 średnik. Linia 37. zamknij nawias klamrowy.

W metodzie iteracyjnej zmienną sterującą pętli for inicjujemy wartością 2, ponieważ mnożenie 1 1 nie będzie miało żadnego wpływu na wynik działania programu. Przy  każdej iteracji wartość silniaIteracyjna jest mnożona przez i. Pętla kończy działanie, gdy wartość zmiennej sterującej staje się większa niż zmienna n.

Bez względu na to, jakiej metody użyjemy, wynik będzie taki sam. Główną różnicą jest wygoda i sposób zapisu. Poza tym przy zastosowaniu metody rekurencyjnej program będzie wykonywał się nieco dłużej. Mają na to wpływ instrukcje warunkowe, które będą sprawdzane przy każdym wywołaniu funkcji, jak również zapis i odczyt ze stosu.

Słownik

adres powrotu
adres powrotu

adres w pamięci, od którego program powinien kontynuować działanie po zakończeniu wywołania funkcji

iteracja
iteracja

metoda polegająca na używaniu pętli w celu wykonania zawartego w nich kodu, aż do osiągnięcia zakładanego celu

linker
linker

inaczej konsolidator, program wchodzący w skład środowiska kompilatora; łączy on ze sobą skompilowane pliki i biblioteki statyczne, a także dodaje nagłówki (zawarta jest w nich informacja o maksymalnym rozmiarze stosu w systemie Windows); wynikiem działania linkera jest plik wykonywalny

nieskończona pętla
nieskończona pętla

pętla, która nigdy nie spełni warunku kończącego jej działanie

rekurencja
rekurencja

sytuacja, w której funkcja wywołuje samą siebie, aż do napotkania przypadku podstawowego

rekursja
rekursja

inaczej: rekurencja