Wróć do informacji o e-podręczniku Wydrukuj Pobierz materiał do PDF Pobierz materiał do EPUB Pobierz materiał do MOBI Zaloguj się, aby dodać do ulubionych Zaloguj się, aby skopiować i edytować materiał Zaloguj się, aby udostępnić materiał Zaloguj się, aby dodać całą stronę do teczki

Wiesz już, jak podchodzić do rozwiązywania stawianych przed tobą problemów programistycznych. Schemat wygląda tak, że zwykle najpierw próbujemy skorzystać z gotowego rozwiązania, a jeśli to nie zadziała, modyfikujemy i rozszerzamy rozwiązania, które znamy. Ostatecznie sięgamy po metodę łączenia rozwiązań częściowych. Próbujemy zatem uprościć proces. Są jednak problemy, które możemy rozwiązać, rozbijając je na takie podproblemy, które są podobne do całości. Rozwiązywanie mniejszych podproblemów ma zaś doprowadzić do rozwiązania problemu głównego. Taką metodę nazywamy metodą rekurencyjną.

Definicja rekurencji

rekurencjąrekurencjarekurencją w informatyce spotykamy się w sytuacji, gdy korzystamy z rozwiązań tego samego problemu, ale dla mniejszej wartości lub liczby danych. W takim wypadku często stosujemy algorytm rekurencyjny, czyli odwołujący się do siebie. Algorytm kończy się, gdy potrafimy podać odpowiedź bez korzystania z rozwiązań dla mniejszych danych.

Przejdźmy do matematycznej definicji ciągu zdefiniowanego rekurencyjnie. Najpierw zapisujemy wyraz ciągu o indeksie 0 (lub kilka początkowych jego wyrazów). Następnie podajemy wzór na wyraz o indeksie n wyrażony za pomocą wyrazów poprzednich.

Wzór rekurencyjny uzależnia więc wartość dowolnego (ogólnego) wyrazu tego ciągu od wartości poprzedzających go wyrazów.

Przykład 1

Zapiszmy za pomocą pseudokodu algorytm jedzenia kaszki, który przedstawiliśmy w sekcji „Wprowadzenie”.

Linia 1. Jedz kaszkę. Linia 2. jeśli talerz jest pusty. Linia 3. koniec jedzenia. Linia 4. w przeciwnym razie. Linia 5. zjedz łyżkę kaszki. Linia 6. Jedz kaszkę.

By lepiej zobrazować działanie algorytmu, zapiszmy go tak:

Linia 1. Jedz kaszkę. Linia 2. jeśli talerz jest pusty. Linia 3. koniec jedzenia. Linia 4. w przeciwnym razie. Linia 5. zjedz łyżkę kaszki. Linia 6. Jedz kaszkę. Linia 7. jeśli talerz jest pusty. Linia 8. koniec jedzenia. Linia 9. w przeciwnym razie. Linia 10. zjedz łyżkę kaszki. Linia 11. Jedz kaszkę. Linia 12. kropka kropka kropka.

Zauważyć można, że sekwencja się powtarza – jedzenie kaszki (w programie byłaby to funkcja) powtarza się w ramach jedzenia kaszki (czyli funkcja wywołuje funkcję).

Przykład 2

Zapoznajmy się z przykładem matematycznym.

Jak wyznaczyć wyraz ciągu o indeksie z ciągu zdefiniowanego rekurencyjnie?

Oto definicja pewnego ciągu określonego rekurencyjnie. Wyraz ciągu o indeksie 0 jest podany, np.:

a n = { 2 d l a a n 1 + 3 d l a n = 0 n > 0

W jaki sposób możemy wyznaczyć wyraz ciągu o indeksie n? Przeanalizuj algorytm zapisany za pomocą pseudokodu wykorzystujący funkcję rekurencyjną.

Specyfikacja problemu:

Dane

  • n – liczba całkowita; indeks wyrazu w ciągu, który chcemy obliczyć

Wynik

  • obliczony wyraz ciągu o indeksie n

Linia 1. funkcja rekurencja otwórz nawias okrągły n zamknij nawias okrągły dwukropek. Linia 2. jeżeli n znak równości 0 dwukropek. Linia 3. zwróć 2. Linia 4. w przeciwnym razie dwukropek. Linia 5. zwróć rekurencja otwórz nawias okrągły n minus 1 zamknij nawias okrągły plus 3.

Największą zaletą funkcji rekurencyjnych jest prostota ich zapisu. Algorytm jest krótki i zrozumiały.

W przykładzie została opisana za pomocą pseudokodu funkcja rekurencyjna, która oblicza n-ty wyraz ciągu według podanego wzoru rekurencyjnego. Jej parametrem jest indeks wyrazu ciągu, który chcemy obliczyć.

Jeżeli argumentem funkcji będzie wartość 0, to zwróconą wartością będzie 2. Jeśli nie, ponownie wywoływana jest funkcja rekurencyjna, ale z inną wartością argumentu. W tym przypadku wartością argumentu jest indeks poprzedniego wyrazu ciągu, czyli n - 1.

Algorytm jest rekurencyjny wtedy, gdy do rozwiązania problemu wykorzystuje on sam siebie. Funkcja jest rekurencyjna wtedy, gdy wywołuje samą siebie. Kolejne wywołania takiej funkcji nazywamy rekurencyjnym ciągiem wywołań.

Rekurencyjny ciąg wywołań musi być skończony ten nie może być nieskończony, stąd w rekurencji musi być określony warunek kończący rekurencję.

Proces taki możemy pokazać za pomocą drzewa wywołań rekurencyjnych. Przedstawiony na ilustracji przykład jest drzewem wywołań rekurencyjnych dla początkowego wywołania funkcji a(5). Takie wywołanie zwróci wartość 17.

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

Algorytm Euklidesa

Przypomnijmy: algorytm Euklidesa stosowany jest do wyznaczania największego wspólnego dzielnika (NWDNWDNWD) dwóch liczb naturalnych. NWD może być wykorzystany do skracania ułamków czy obliczania najmniejszej wspólnej wielokrotności (NWW). Więcej na temat tego algorytmu przeczytasz w e‑materiale: Algorytm EuklidesaP7OAFYVSiAlgorytm Euklidesa.

Specyfikacja problemu:

Dane

  • a – liczba naturalna

  • b – liczba naturalna

Wynik

  • NWD liczb ab

Algorytm Euklidesa z odejmowaniem

Wersja rekurencyjna prezentuje się następująco:

Linia 1. funkcja NWD otwórz nawias okrągły a przecinek b zamknij nawias okrągły. Linia 2. jeżeli a wykrzyknik znak równości b dwukropek. Linia 3. jeżeli a zamknij nawias ostrokątny b. Linia 4. zwróć NWD otwórz nawias okrągły a minus b przecinek b zamknij nawias okrągły. Linia 5. w przeciwnym razie. Linia 6. zwróć NWD otwórz nawias okrągły a przecinek b minus a zamknij nawias okrągły. Linia 7. zwróć a.

Funkcje są rekurencyjnie wywoływane do momentu, gdy wartości argumentów funkcji będą takie same. Wtedy zwracana jest wartość jednego z nich i to on jest największym wspólnym dzielnikiem liczb a oraz b.

Algorytm Euklidesa opiera się na prostej obserwacji: jeżeli liczba naturalna c dzieli liczby naturalne ab, to dzieli również ich różnicę. Rzeczywiście jeśli c = NWD(a, b), wówczas istnieją takie względnie pierwszeliczby względnie pierwszewzględnie pierwsze liczby naturalne kl, dla których a = c * k oraz b = c * l. Niech ponadto a > b. Wówczas a - b = c * k - c * l = c * (k - l), co oznacza, że liczba a - b również dzieli się przez c. To oznacza, że zarówno różnica liczb b oraz a - b, jak i różnica liczb a oraz a - b dzielą się przez c. Powtarzanie odejmowania doprowadzi do obliczenia c.

Algorytm Euklidesa z resztą z dzielenia

W tym wariancie algorytmu do znalezienia NWD – zamiast różnicy dwóch liczb – używa się reszty z ich dzielenia.

W podejściu rekurencyjnym dla b różnego od 0 przykładowa funkcja NWD(a, b) wywołuje samą siebie z argumentami NWD(b, a mod b) do momentu trafienia na przypadek podstawowy, kiedy b będzie równe 0. Pseudokod takiej funkcji wygląda następująco:

Linia 1. funkcja NWD otwórz nawias okrągły a przecinek b zamknij nawias okrągły dwukropek. Linia 2. jeżeli a wykrzyknik znak równości 0 dwukropek. Linia 3. zwróć NWD otwórz nawias okrągły b mod a przecinek a zamknij nawias okrągły. Linia 4. w przeciwnym razie. Linia 5. zwróć b.

Funkcje są rekurencyjnie wywoływane do momentu, gdy wartość jednego argumentu funkcji jest równa 0. Wtedy zwracana jest wartość jednego z nich i to on jest największym wspólnym dzielnikiem liczb a oraz b.

Zastanówmy się, dlaczego możemy w ten sposób podejść do tego problemu.

Załóżmy, że chcemy obliczyć NWD dwóch liczb a oraz b. Są to liczby naturalne. Chcemy wyznaczyć liczbę c, która jest ich największym wspólnym dzielnikiem.

W naszym problemie a ≤ b. Podzielmy b przez a. Otrzymamy iloraz (x oraz resztę (r). Zapiszmy to.

b ÷ a = x reszta r

Zatem:

b = ax + r

natomiast:

0  r < m

Jeśli r = 0, to NWD(a, b) = a. Oznacza to, że jedna z liczb dzieli drugą bez reszty, zatem mniejsza z liczb jest wspólnym dzielnikiem.

Jeśli natomiast r ≠ 0, możemy zapisać, że r = b - xa. Możemy zatem zauważyć, że każda liczba, która dzieli liczby a oraz b, dzieli również całe wyrażenie b - xa, zatem dzieli również r. Wynika z tego to, że NWD liczb a oraz b dzieli również resztę r.

Możemy to zapisać jako NWD(a, b) = NWD(r, a), a ponieważ reszta z dzielenia jest wynikiem operacji modulo, to NWD(a, b) = NWD(b mod a, a).

Problemy związane z rekurencją

Programy wykorzystujące rekurencję wymagają użycia dużej ilości pamięci. Dla każdego wywołania funkcji (w tym rekurencyjnego) program musi umieścić w pamięci adres powrotu, wartości zmiennych lokalnych, jak również wartości argumentów wywoływanej funkcji. Dane te są niezbędne do odtworzenia stanu przed wywołaniem, w momencie zakończenia danej funkcji. Może to spowodować przepełnienie pamięciprzepełnienie pamięciprzepełnienie pamięci. Informacje te zapisywane są w wyznaczonym miejscu pamięci, które zwiemy stosemstosstosem.

Rozwiązywanie niektórych problemów metodą rekurencyjną może także znacząco zwiększyć złożoność czasową algorytmu. W takiej sytuacji wskazane jest użycie wersji iteracyjnej rozwiązania problemu.

Aby zobrazować problem przepełnienia stosu, posłużmy się przykładem. Uruchomienie funkcji rekurencja(n), zdefiniowanej na początku tego e‑materiału, spowoduje uruchomienie drzewa rekurencyjnego o wysokości n. Oznacza to, że aby obliczyć wartość funkcji rekurencja(n), potrzebujemy na stosie obszaru o wielkości n c, gdzie c jest pewnym współczynnikiem stanowiącym średnią ilości pamięci zajmowanej na stosie przy każdym kolejnym wywołaniu funkcji.

Ważne!

Miejsce na stosie zajmowane przez funkcję rekurencyjną zależy od liczby wywołań rekurencyjnych i ilości danych przechowywanych na stosie przez każde wywołanie.

W przypadku funkcji rekurencja(n), która wywołuje samą siebie dwukrotnie z argumentami (n - 1)(n - 2), na stosie trzeba przechować dwa argumenty (typu int) oraz wartość zwracaną przez każde z dwóch wywołań. Ponieważ typ int zwykle zajmuje 4 bajty, to każde wywołanie funkcji rek zajmuje na stosie około 12 bajtów (2 x 4 bajty na argumenty i 4 bajty na wartość zwracaną).Dla przykładu, dla n = 5 funkcja rekurencja wykona 15 wywołań rekurencyjnych, a więc zajmie na stosie około 180 bajtów (15 x 12 bajtów). Jednak w przypadku większych wartości n ilość danych na stosie będzie znacznie większa, co może prowadzić do przepełnienia stosu.

Niech c wynosi 16 bajtów. Wówczas przy zakładanej wielkości stosu maksymalna wartość n, dla której możemy wywołać funkcję rekurencja(n) bez błędu przepełnienia stosu, wynosi:

1   M B 16   B = 1024 1024   B 16   B = 65536

W rzeczywistości program może zakończyć się dużo wcześniej. Np. interpreter języka Python domyślnie ogranicza liczbę wywołań rekurencyjnych do 1000. W języku C++ nie ma takiego ograniczenia.

Złożoność obliczeniowa tej funkcji wynosi O(n), ponieważ funkcja wywołuje samą siebie n razy, a każde wywołanie wykonuje stałą liczbę operacji (jedno dodawanie i jeden warunek).

Złożoność pamięciowa jest również O(n), ponieważ dla każdego wywołania funkcji rezerwowany jest nowy stos o stałym rozmiarze (w tym przypadku 4 bajty) na wartości zwracane przez wywołania rekurencyjne oraz zmienne lokalne. Zatem całkowita ilość pamięci potrzebna na utworzenie wszystkich stosów wynosi O(n * 4) = O(n).

Ciekawostka
R1Uvx1k6avDMt1
Źródło: domena publiczna.

Efekt Droste'a swoją nazwę zawdzięcza opakowaniu kakao o tej samej nazwie. Na etykiecie produktu przedstawiona została kobieta trzymająca tacę z filiżanką i puszką kakao, na której w sposób rekurencyjny powtórzony został cały rysunek.

Podobny efekt zastosowano również na opakowaniach popularnego serka. Co ciekawe, jego nazwa w Polsce to fonetyczny zapis skróconej nazwy francuskiego oryginału (fr. La vache qui rit).

Innym przykładem rekurencji mogą być matrioszki, czyli rosyjskie drewniane lalki. Ich specyfika polega na tym, że wewnątrz największej lalki znajdują się identyczne, coraz mniejszych rozmiarów.

Problem 1

Wskaż przykłady rekurencji w sztuce.

Słownik

iteracja
iteracja

technika programowania polegająca na powtarzaniu tych samych operacji w pętli określoną liczbę razy lub do momentu, aż zostanie spełniony zadany warunek

NWD
NWD

największy wspólny dzielnik dwóch liczb – największa liczba naturalna, która dzieli obie te liczby bez reszty

przepełnienie pamięci
przepełnienie pamięci

błąd polegający na przekroczeniu rozmiaru zarezerwowanego miejsca dla programu w pamięci operacyjnej

rekurencja
rekurencja

technika programowania polegająca na odwoływaniu się procedur lub funkcji do samych siebie, aż do momentu spełnienia warunku podstawowego

stos
stos

dynamiczna struktura danych, w której informacje pobierane są ze szczytu i na niego odkładane; struktura typu LIFO (Last‑In, First‑Out – ostatni na wejściu, pierwszy na wyjściu)

liczby względnie pierwsze
liczby względnie pierwsze

liczby całkowite, których największym wspólnym dzielnikiem jest liczba jeden