Metoda rekurencyjna – przypomnienie

Program będący zapisem metody rekurencyjnej ma postać funkcji, aby mógł odwoływać się do samego siebie. Każda funkcja rekurencyjna musi mieć warunek zakończenia rekurencji (zwany również warunkiem stopu) oraz przynajmniej jedno miejsce, w którym funkcja wywołuje samą siebie.

Kiedy ostatnie wywołanie funkcji kończy swoją pracę, również funkcja, która je spowodowała, może zakończyć swoje działanie. Proces ten powtarza się dla każdego wywołania funkcji, aż pierwsze w kolejności wywołanie funkcji zwróci wartość będącą wynikiem działania rekurencji.

W następstwie wywołania funkcji zostaje ona odłożona na szczyt stosu (charakterystyka tej struktury danych znajduje się w e‑materiale Podstawowe struktury danych: stos i kolejkaPE9Hyf0CXPodstawowe struktury danych: stos i kolejka), natomiast kiedy zakończy ona swoje działanie, jest ze stosu zdejmowana.

Ważne!

W materiale dotyczącym języka Java zamiast pojęcia funkcja używamy pojęcia metoda.

Prześledźmy, jak wygląda metoda rekurencyjna i proces tworzenia stosu z jej wykonania.

Linia 1. public class Main otwórz nawias klamrowy. Linia 2. public static int potega otwórz nawias okrągły int a przecinek int n zamknij nawias okrągły otwórz nawias klamrowy. Linia 3. if otwórz nawias okrągły n znak równości znak równości 1 zamknij nawias okrągły return a średnik. Linia 4. return a asterysk potega otwórz nawias okrągły a przecinek n minus 1 zamknij nawias okrągły średnik. Linia 5. zamknij nawias klamrowy. Linia 7. public static void main otwórz nawias okrągły String otwórz nawias kwadratowy zamknij nawias kwadratowy args zamknij nawias okrągły otwórz nawias klamrowy. Linia 8. potega otwórz nawias okrągły 2 przecinek 3 zamknij nawias okrągły średnik. Linia 9. zamknij nawias klamrowy. Linia 10. zamknij nawias klamrowy.

Najpierw wywołana zostaje metoda main, która jako pierwsza zostaje odłożona na stosie. Następnie wywołujemy metodę potega(2, 3), a po niej metodę potega(2, 2). Jako ostatnia wywołana zostaje metoda potega(2, 1), w której napotkany jest warunek zakończenia rekurencji.

R1FUe0e1Uzf14
Źródło: Contentplus.pl Sp. z o.o., licencja: CC BY-SA 3.0.
Ważne!

Jeżeli funkcja rekurencyjna nie będzie miała instrukcji warunkowej, która pozwoli na zakończenie jej pracy, nie wywołując przy tym kolejnej instancji funkcji, funkcja będzie rekursywnierekursjarekursywnie wywoływać się w nieskończoność, podobnie jak nieskończona pętlaNieskończona pętlanieskończona pętla.

Gdy wywołanie metody potega(2, 1) zakończy swoje działanie, zostaje ono zdjęte ze stosu, następnie przetwarzane jest wywołanie metody potega(2, 2), które także jest zdejmowane ze stosu, ponieważ zwróciło swój wynik, analogiczna operacja zajdzie dla wywołania metody potega(2, 3).

Ryqo1OpuigLyD
Źródło: Contentplus.pl Sp. z o.o., licencja: CC BY-SA 3.0.

Kiedy liczba wywołań rekurencyjnych będzie stale rosła, np. z powodu nieumieszczenia w metodzie warunku zakończenia rekurencji, może dojść do przepełnienia stosuprzepełnienie stosuprzepełnienia stosu (o czym poinformuje komunikat: Exception in thread "main" java.lang.StackOverflowError).

Jest to błąd w programowaniu polegający na zapełnieniu się roboczego obszaru pamięci operacyjnej programu wywołaniami funkcji oraz ich lokalnymi zmiennymi. Stos ma określoną pojemność, gdy zostanie przepełniony, nowe zmienne zostają umieszczone poza nim, co prowadzi do błędów. Tak wyglądałoby przepełnienie stosu dla metody potega(2, 3), w której nie umieszczono warunku zakończenia rekurencji.

RZl9I1JfkkYse
Źródło: Contentplus.pl Sp. z o.o., licencja: CC BY-SA 3.0.

Metoda iteracyjna – przypomnienie

Metoda iteracyjna opiera się na powtarzaniu tej samej operacji w pętli. Pętli for używamy, gdy wiemy, ile iteracji pętli jest potrzebnych do rozwiązania problemu. Natomiast pętlę while wykorzystamy, gdy nie będziemy umieli określić, ile iteracji należy wykonać w celu rozwiązania problemu. Istnieje jeszcze pętla do while, której działanie jest podobne do pętli while, różni się jednak tym, że warunek pętli sprawdzany jest dopiero po wykonaniu każdej iteracji.

Porównanie metod

Główne różnice między metodami rekurencyjną i iteracyjną to szybkość oraz sposób działania, a także – łączące się z nimi – wymagania dotyczące zasobów komputera.

W większości przypadków algorytmy rekurencyjne są mniej efektywne pamięciowo niż ich iteracyjne wersje. Różnica jest spowodowana tym, że metoda rekurencyjna wielokrotnie odwołuje się do samej siebie. Każde rekurencyjne wywołanie funkcji ma swoje własne zmienne, ponieważ wywoływana jest nowa funkcja. Każde wywołanie funkcji zajmuje pewien obszar pamięci. Problem pojawia się wówczas, gdy tworzone jest kolejne wywołanie, a nie zostało zakończone poprzednie, a tak właśnie się dzieje przy wykorzystaniu rekurencji. Nie jest to zauważalne w przypadku mniej skomplikowanych programów, w których wywołań rekurencyjnych jest niewiele. Jednak jeśli problem jest bardziej wymagający, wykorzystanie pamięci rośnie. W momencie, gdy nieumiejętnie zastosujemy rekurencję, może dojść do przepełnienia stosu i pojawienia się komunikatu: Exception in thread "main" java.lang.StackOverflowError. Zaletą zastosowania algorytmu rekurencyjnego jest przejrzystość oraz czytelność kodu.

Częstym błędem, przez który rekurencyjne wersje algorytmów działają wolniej niż ich iteracyjne odpowiedniki, są nieoptymalnie skonstruowane wywołania. Jest tak w wypadku algorytmu, w którym funkcja kilka razy liczy to samo, np. algorytmu rekurencyjnego wyznaczającego elementy ciągu Fibonacciego.

Warto wiedzieć, kiedy należy użyć metody rekurencyjnej, a kiedy lepiej pozostać przy iteracyjnej. Rekurencja często ułatwia zrozumienie i rozwiązanie problemu, jeśli faktycznie jest to zgodne z jego charakterem.

Dobrym przykładem odstępstwa metody iteracyjnej w kwestii tworzenia algorytmów jest problem potęgowania. Standardową definicję potęgowania możemy zapisać następująco:

an=a·a··an
an=agdy n=1a·an-1gdy n>1 
a – podstawa potęgi
n – liczba całkowita wykładnik potęgi

Zaprezentowana definicja nie posiada żadnej istotnej zalety względem wersji iteracyjnej. Można powiedzieć, że utrudnia zrozumienie natury problemu. Dodatkowo jej implementacja wprowadza sporo niewystępujących w przypadku wersji iteracyjnej problemów, związanych na przykład z przepełnieniem stosu.

Implementacja potęgowania liczb

Aby omówić różnice programistyczne w przypadku różnych podejść do problemu, porównajmy przykładowe implementacje metod realizujących definicje matematyczne w języku Java. Zacznijmy od potęgowania liczb.

Specyfikacja problemu:

Dane:

  • a – liczba naturalna; podstawa potęgi

  • n – liczba całkowita; wykładnik potęgi

Wynik:

Program zwraca wartość zmiennej a podniesionej do potęgi n.

Wersja iteracyjna:

Linia 1. public static int potegowanieIteracyjnie otwórz nawias okrągły int a przecinek int n zamknij nawias okrągły otwórz nawias klamrowy. Linia 2. int wynik znak równości a średnik. Linia 3. for otwórz nawias okrągły int i znak równości 1 średnik i otwórz nawias ostrokątny n średnik plus plus i zamknij nawias okrągły otwórz nawias klamrowy. Linia 4. wynik asterysk znak równości a średnik. Linia 5. zamknij nawias klamrowy. Linia 7. return wynik średnik. Linia 8. zamknij nawias klamrowy.

Wersja rekurencyjna:

Linia 1. public static int potegowanieRekurencyjnie otwórz nawias okrągły int a przecinek 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 1 zamknij nawias okrągły return a średnik. Linia 3. return a asterysk potegowanieRekurencyjnie otwórz nawias okrągły a przecinek n minus 1 zamknij nawias okrągły średnik. Linia 4. zamknij nawias klamrowy.

Widoczna na pierwszy rzut oka różnica, która przemawia na korzyść rozwiązania rekurencyjnego, to długość kodu. Zauważmy, że zapis metody potegowanieRekurencyjnie() jest krótszy. W wielu algorytmach funkcje rekurencyjne są prostsze i krótsze w zapisie.

Porównywanie długości kodu to często jednak błąd. Liczba linijek kodu jest mniej ważna od jego wydajności. Przeanalizujmy zatem aspekty wydajnościowe obu rozwiązań. Złożoność czasowa obu funkcji jest taka sama. Inaczej sprawa ma się w przypadku złożoności pamięciowej. W wersji iteracyjnej, aby obliczyć wartość funkcji dla danego parametru n, deklarujemy tylko cztery zmienne. W przypadku funkcji napisanej rekurencyjnie liczba zmiennych jest znacząco zwiększona. Przy obliczaniu wartości potęgi dla danego parametru n, będziemy mieli liczbę zmiennych rzędu 2·n, ponieważ dla każdego rekurencyjnego wywołania funkcji tworzymy dwie zmienne lokalne an.

Niepotrzebnie zwiększone jest zatem zapotrzebowanie na pamięć na stosie, co może powodować niemożliwość wykonania funkcji dla dużych parametrów n. W przypadku operacji potęgowania mamy do czynienia z bardzo szybkim wzrostem wyniku. Prawdopodobnie nie doświadczymy w tym wypadku problemu z przepełnieniem stosu, gdyż i tak podniesienie liczby do odpowiednio dużej potęgi byłoby niemożliwe z uwagi na pojemność zmiennej typu int czy nawet long. Inaczej byłoby w przypadku próby obliczenia następującego działania:

210000000 mod 270

Uzyskanie wyniku przedstawionej operacji byłoby niemożliwe do osiągnięcia z pomocą podejścia rekurencyjnego zbudowanego w sposób analogiczny do zdefiniowanej wcześniej funkcji. W tym wypadku trzeba zastosować podejście iteracyjne lub algorytm tzw. szybkiego potęgowania (w wersji zarówno iteracyjnej, jak i rekurencyjnej).

Implementacja silni matematycznej

Porównajmy teraz dwie wersje algorytmu obliczającego silnię matematyczną zapisane w języku Java.

Silnia to jednoargumentowa funkcja matematyczna, której wynikiem jest iloczyn wszystkich liczb naturalnych (oprócz zera) nie większych niż argument silni.

Przykład 1
5!=1·2·3·4·5=120

Rozpiszmy przykładową postać algorytmów. Zaczniemy od metody iteracyjnej.

Specyfikacja problemu:

Dane:

  • n – liczba całkowita; argument silni

Wynik:

Program zwraca wartość silni n.

Linia 1. public class Main otwórz nawias klamrowy. Linia 2. public static int silniaIteracyjna otwórz nawias okrągły int n zamknij nawias okrągły otwórz nawias klamrowy. Linia 3. int wynik znak równości 1 średnik. Linia 5. while otwórz nawias okrągły n zamknij nawias ostrokątny 0 zamknij nawias okrągły otwórz nawias klamrowy. Linia 6. wynik znak równości wynik asterysk n średnik. Linia 7. n minus minus średnik. Linia 8. zamknij nawias klamrowy. Linia 9. return wynik średnik. Linia 11. zamknij nawias klamrowy. Linia 12. public static void main otwórz nawias okrągły String otwórz nawias kwadratowy zamknij nawias kwadratowy args zamknij nawias okrągły otwórz nawias klamrowy. Linia 13. System kropka out kropka print otwórz nawias okrągły silniaIteracyjna otwórz nawias okrągły 5 zamknij nawias okrągły zamknij nawias okrągły średnik. Linia 14. zamknij nawias klamrowy. Linia 16. zamknij nawias klamrowy.

Zmienna n przechowuje argument metody silnia, w tym przypadku jest to 5, więc matematyczna reprezentacja problemu wygląda następująco: 5!

Specjalnym przykładem silni jest liczba 0!, gdyż jako wynik zwraca ona 1. Dlatego do zmiennej wynik przypisywana jest wartość 1. W pętli najpierw sprawdzany jest warunek czy n>0. Pozwala to na mnożenie zmiennej wynik przez kolejne wartości n. Po zakończeniu pętli wynik działania 5! zostaje zwrócony z metody silniaIteracyjna, a następnie wypisany w konsoli.

W przypadku rekurencyjnej wersji problemu, kod wygląda następująco:

Linia 1. public class Main otwórz nawias klamrowy. Linia 2. public static int silniaRekurencyjna otwórz nawias okrągły int n zamknij nawias okrągły otwórz nawias klamrowy. Linia 3. if otwórz nawias okrągły n znak równości znak równości 0 zamknij nawias okrągły return 1 średnik. Linia 4. return n asterysk silniaRekurencyjna otwórz nawias okrągły n minus 1 zamknij nawias okrągły średnik. Linia 5. zamknij nawias klamrowy. Linia 7. public static void main otwórz nawias okrągły String otwórz nawias kwadratowy zamknij nawias kwadratowy args zamknij nawias okrągły otwórz nawias klamrowy. Linia 8. System kropka out kropka print otwórz nawias okrągły silniaRekurencyjna otwórz nawias okrągły 5 zamknij nawias okrągły zamknij nawias okrągły średnik. Linia 9. zamknij nawias klamrowy. Linia 11. zamknij nawias klamrowy.

Ponownie rozważamy przypadek specjalny dla 0!, więc gdy n = 0 zwracamy wartość 1. Gdy n jest różne od 0, wywołujemy metodę silniaRekurencyjna(n‑1) oraz mnożymy ją razy n.

Rekurencyjne powielanie obliczeń

Wspomnieliśmy już o tym, że nieumiejętna implementacja funkcji rekurencyjnej może skutkować zwiększoną liczbą wykonanych operacji. Zagadnienie to dobrze obrazuje problem obliczania n-tego wyrazu ciągu Fibonacciego, kod w języku Java reprezentujący rekurencyjne rozwiązanie problemu wygląda następująco:

Linia 1. public class Main otwórz nawias klamrowy. Linia 2. public static int fib otwórz nawias okrągły int n zamknij nawias okrągły otwórz nawias klamrowy prawy ukośnik prawy ukośnik rekursywnie. Linia 3. prawy ukośnik prawy ukośnik warunki zakończenia. Linia 4. 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 5. 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 6. prawy ukośnik prawy ukośnik wywołanie rekurencyjne. Linia 7. return fib otwórz nawias okrągły n minus 2 zamknij nawias okrągły plus fib otwórz nawias okrągły n minus 1 zamknij nawias okrągły średnik. Linia 8. zamknij nawias klamrowy. Linia 10. public static void main otwórz nawias okrągły String otwórz nawias kwadratowy zamknij nawias kwadratowy args zamknij nawias okrągły otwórz nawias klamrowy. Linia 11. System kropka out kropka println otwórz nawias okrągły fib otwórz nawias okrągły 8 zamknij nawias okrągły zamknij nawias okrągły średnik. Linia 12. zamknij nawias klamrowy. Linia 13. zamknij nawias klamrowy.

Dla wywołania metody fib(8) otrzymamy następujące drzewo wywołań rekurencyjnych:

R1Bn6c4XEkhdU
Źródło: Contentplus.pl Sp. z o.o., licencja: CC BY-SA 3.0.

Na grafice kolorami zaznaczone są wielokrotne wywołania metody fib(6), fib(5), fib(4) itd. Przez nieumiejętne użycie rekurencji wielokrotnie obliczamy wartości identycznych wywołań metody, co powoduje ogromny wzrost zajmowanej pamięci na stosie.

Na przytoczonym przykładzie możemy zauważyć, że używanie rekurencji może czasem prowadzić do niepotrzebnego powielania obliczeń, w przypadku tego problemu opłaca się zastosować metodę iteracyjną.

Linia 1. public class Main otwórz nawias klamrowy. Linia 2. public static int fib otwórz nawias okrągły int n zamknij nawias okrągły otwórz nawias klamrowy prawy ukośnik prawy ukośnik iteracyjnie. Linia 3. int a znak równości 1 średnik prawy ukośnik prawy ukośnik pierwszy i drugi wyraz. Linia 4. int b znak równości 1 średnik prawy ukośnik prawy ukośnik ciągu Fibbonaciego. Linia 5. int wyraz znak równości 1 średnik prawy ukośnik prawy ukośnik wartość bierzącego wyrazu ciągu. Linia 7. for otwórz nawias okrągły int i znak równości 3 ś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 prawy ukośnik prawy ukośnik pierwsze 2 wyrazy. Linia 8. wyraz znak równości a plus b średnik prawy ukośnik prawy ukośnik są już obliczone. Linia 9. a znak równości b średnik. Linia 10. b znak równości wyraz średnik. Linia 11. zamknij nawias klamrowy. Linia 12. return wyraz średnik. Linia 13. zamknij nawias klamrowy. Linia 15. public static void main otwórz nawias okrągły String otwórz nawias kwadratowy zamknij nawias kwadratowy args zamknij nawias okrągły otwórz nawias klamrowy. Linia 16. System kropka out kropka println otwórz nawias okrągły fib otwórz nawias okrągły 1 zamknij nawias okrągły zamknij nawias okrągły średnik. Linia 17. zamknij nawias klamrowy. Linia 18. zamknij nawias klamrowy.

Dzięki metodzie iteracyjnej używamy tylko 4 zmiennych, a także nie powielamy obliczeń.

Słownik

void
void

metoda, która nie przekazuje informacji zwrotnej, nie zwraca danych, nie przyjmuje żadnych parametrów

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

pętla, w której nigdy nie zajdzie warunek zakończenia pętli, a polecenia w niej zawarte będą się wykonywać w nieskończoność

przepełnienie stosu
przepełnienie stosu

błąd w programowaniu polegający na zapełnieniu się roboczego obszaru pamięci operacyjnej programu; problem dotyczy organizacji pamięci; lokalne zmienne tworzone w programie zapisywane są na stosie, który ma określoną pojemność; gdy zmienne przestają być używane, pamięć jest sukcesywnie zwalniana; w momencie, gdy zaczynamy tworzyć zmienne lokalne lub wywołania funkcji w niekontrolowanym tempie, dochodzi do zapełnienia obszaru stosu, przez co następne zmienne zostają umieszczone poza stosem

rekursja
rekursja

alternatywne określenie rekurencji